feat: add scoped history and artifacts
Generic Container CI/CD / test-build-publish (push) Failing after 8m25s
Agent CI/CD / build-test-publish-and-deploy (push) Failing after 8m25s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent bc4e9834e5
commit 7d47a2df86
48 changed files with 2632 additions and 78 deletions
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from "node:crypto";
export class HistoryCursorError extends Error {}
type Cursor = { scope: string; kind: string; position?: number; anchor?: string; updatedAt?: string; id?: string };
export function historyScope(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function encode(value: Cursor) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function decode(value: string, scope: string, kind: string): Cursor {
try {
const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Cursor;
if (cursor.scope !== scope || cursor.kind !== kind) throw new Error();
return cursor;
} catch {
throw new HistoryCursorError("invalid history cursor");
}
}
export function sessionHistoryPage<T extends { sessionId: string; updatedAt: string; title?: string; searchSnippet?: string }>(
records: T[], options: { scope: string; q?: string; limit: number; cursor?: string },
) {
const query = (options.q ?? "").trim().toLocaleLowerCase();
const scope = historyScope([options.scope, query]);
const boundary = options.cursor ? decode(options.cursor, scope, "sessions") : null;
if (boundary && (typeof boundary.updatedAt !== "string" || typeof boundary.id !== "string")) {
throw new HistoryCursorError("invalid history cursor");
}
const ordered = records.filter((record) => (record.title ?? "新对话").toLocaleLowerCase().includes(query) || Boolean(query && record.searchSnippet))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.sessionId.localeCompare(a.sessionId));
const remaining = boundary ? ordered.filter((record) => record.updatedAt < boundary.updatedAt!
|| (record.updatedAt === boundary.updatedAt && record.sessionId.localeCompare(boundary.id!) < 0)) : ordered;
const sessions = remaining.slice(0, options.limit);
const has_more = remaining.length > sessions.length;
const last = sessions.at(-1);
return { sessions, has_more, next_cursor: has_more && last
? encode({ scope, kind: "sessions", updatedAt: last.updatedAt, id: last.sessionId }) : null };
}
function messageAnchor(message: unknown) {
if (message && typeof message === "object" && "id" in message && typeof message.id === "string") return message.id;
return historyScope(message);
}
export function sessionMessagePage(messages: unknown[], options: { scope: string; limit: number; before?: string }) {
const boundary = options.before ? decode(options.before, options.scope, "messages") : null;
const end = boundary?.position ?? messages.length;
if (!Number.isInteger(end) || end < 0 || end > messages.length || (boundary &&
(end >= messages.length || messageAnchor(messages[end]) !== boundary.anchor))) {
throw new HistoryCursorError("message history changed; reload this conversation");
}
const start = Math.max(0, end - options.limit);
return {
messages: messages.slice(start, end),
message_page: { has_more: start > 0, before_cursor: start > 0
? encode({ scope: options.scope, kind: "messages", position: start, anchor: messageAnchor(messages[start]) }) : null },
};
}
+21
View File
@@ -0,0 +1,21 @@
/** Search only visible user/assistant text, never tool arguments or hidden reasoning. */
export function matchHistoryText(messages: unknown[], query: string): string | undefined {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return;
for (const value of messages) {
if (!value || typeof value !== "object") continue;
const message = value as Record<string, unknown>;
if (message.role !== "user" && message.role !== "assistant") continue;
const parts = Array.isArray(message.parts) ? message.parts : [message];
for (const value of parts) {
if (!value || typeof value !== "object") continue;
const part = value as Record<string, unknown>;
if (part.type !== "text" || typeof part.text !== "string") continue;
const index = part.text.toLocaleLowerCase().indexOf(needle);
if (index < 0) continue;
const start = Math.max(0, index - 36);
const end = Math.min(part.text.length, index + needle.length + 80);
return `${start ? "…" : ""}${part.text.slice(start, end).replace(/\s+/g, " ")}${end < part.text.length ? "…" : ""}`;
}
}
}
+10 -3
View File
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { SessionTopicStore } from "./topic-store.js";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
@@ -30,6 +31,7 @@ export type SessionRecord = {
requestId?: string;
modelId?: string;
title?: string;
topicId?: string | null;
triggerId?: string;
triggerSource?: string;
triggerSubjectType?: string;
@@ -63,14 +65,17 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
runId?: string;
requestId?: string;
modelId?: string;
topicId?: string;
};
export class SessionMetadataStore {
readonly topics: SessionTopicStore;
private readonly ensureQueues = new Map<string, Promise<void>>();
private index?: Map<string, Map<string, SessionRecord>>;
private indexLoading?: Promise<void>;
private indexVersion = 0;
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
this.topics = new SessionTopicStore(join(baseDir, "topics"));
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
}
@@ -133,6 +138,7 @@ export class SessionMetadataStore {
runId: input.runId?.trim(),
requestId: input.requestId?.trim(),
modelId: input.modelId?.trim(),
topicId: input.topicId,
...(input.trigger ? {
triggerId: input.trigger.triggerId,
triggerSource: input.trigger.source,
@@ -175,7 +181,7 @@ export class SessionMetadataStore {
async touch(
record: SessionRecord,
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {},
) {
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
@@ -314,12 +320,13 @@ const matchesContext = (record: SessionRecord, context: SessionMetadataContext)
(context.projectId === undefined || record.projectId === context.projectId);
const normalizeSessionUpdates = (
updates: Partial<Pick<SessionRecord, "title" | "status">>,
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">>,
) => {
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
const normalized: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
if (updates.topicId === null || typeof updates.topicId === "string") normalized.topicId = updates.topicId;
if (typeof updates.title === "string") {
const trimmed = updates.title.trim();
if (trimmed) {
+66
View File
@@ -0,0 +1,66 @@
import { createHash, randomUUID } from "node:crypto";
import { join } from "node:path";
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
export type TopicScope = { actorKey: string; projectKey: string; userId?: string };
export type SessionTopic = { id: string; name: string; createdAt: string; updatedAt: string };
/** Topics organize one user's chats within an authorized data scope. */
export class SessionTopicStore {
private queues = new Map<string, Promise<void>>();
constructor(private readonly directory: string) {}
async list(scope: TopicScope): Promise<SessionTopic[]> {
return (await readJsonFile<{ topics: SessionTopic[] }>(this.path(scope)))?.topics ?? [];
}
async get(scope: TopicScope, id: string) {
return (await this.list(scope)).find((topic) => topic.id === id);
}
create(scope: TopicScope, name: string) {
return this.update(scope, (topics) => {
const now = new Date().toISOString();
const topic = { id: randomUUID(), name, createdAt: now, updatedAt: now };
topics.push(topic);
return topic;
});
}
rename(scope: TopicScope, id: string, name: string) {
return this.update(scope, (topics) => {
const topic = topics.find((entry) => entry.id === id);
if (topic) { topic.name = name; topic.updatedAt = new Date().toISOString(); }
return topic;
});
}
remove(scope: TopicScope, id: string) {
return this.update(scope, (topics) => {
const index = topics.findIndex((topic) => topic.id === id);
if (index < 0) return false;
topics.splice(index, 1);
return true;
});
}
private async update<T>(scope: TopicScope, change: (topics: SessionTopic[]) => T): Promise<T> {
const path = this.path(scope);
const previous = this.queues.get(path) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(async () => {
const topics = await this.list(scope);
const value = change(topics);
await atomicWriteJson(path, { topics }, 0o600);
return value;
});
const pending = result.then(() => undefined, () => undefined);
this.queues.set(path, pending);
try { return await result; }
finally { if (this.queues.get(path) === pending) this.queues.delete(path); }
}
private path(scope: TopicScope) {
const hash = createHash("sha256").update(JSON.stringify([scope.actorKey, scope.projectKey, scope.userId ?? null])).digest("hex");
return join(this.directory, `${hash}.json`);
}
}