revert: remove unused Agent PostgreSQL persistence
This commit is contained in:
@@ -6,6 +6,7 @@ import { config } from "../config.js";
|
||||
export type LlmRequestAuditEntry = {
|
||||
kind: "tool" | "skill";
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
target: string;
|
||||
|
||||
+71
-51
@@ -2,17 +2,20 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
|
||||
import {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../session/toolContextStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
|
||||
export type SessionBinding = {
|
||||
clientSessionId: string;
|
||||
sessionId: string;
|
||||
runtimeSessionId: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export type SessionContext = {
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
@@ -25,15 +28,15 @@ export type ChatRequestContext = SessionContext & {
|
||||
};
|
||||
|
||||
export class ChatSessionBridge {
|
||||
// runtime session 仅在单次请求生命周期内有效;线程连续性由 sessionId 对应的持久状态承担。
|
||||
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。
|
||||
private readonly activeRuntimeSessions = new Map<string, string>();
|
||||
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
|
||||
private readonly runtimeSessionStore = new RuntimeSessionStore();
|
||||
private readonly toolContextStore = new ToolSessionContextStore();
|
||||
|
||||
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
|
||||
|
||||
async resolve(context: {
|
||||
sessionId?: string;
|
||||
clientSessionId?: string;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
@@ -44,24 +47,30 @@ export class ChatSessionBridge {
|
||||
created: boolean;
|
||||
}> {
|
||||
const requestContext = this.buildRequestContext(context);
|
||||
await this.abortActiveRuntime(requestContext.sessionId);
|
||||
await this.abortActiveRuntime(requestContext.clientSessionId);
|
||||
|
||||
const session = await this.runtime.createSession(requestContext.sessionId);
|
||||
const session = await this.runtime.createSession(requestContext.clientSessionId);
|
||||
const binding: SessionBinding = {
|
||||
sessionId: requestContext.sessionId,
|
||||
runtimeSessionId: session.id,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: session.id,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
this.activeRuntimeSessions.set(requestContext.sessionId, session.id);
|
||||
this.activeSensitiveContexts.set(session.id, requestContext);
|
||||
await this.runtimeSessionStore.write({
|
||||
runtimeSessionId: session.id,
|
||||
const sessionScopeKey = buildToolSessionScopeKey(
|
||||
requestContext.actorKey,
|
||||
requestContext.projectKey,
|
||||
requestContext.clientSessionId,
|
||||
);
|
||||
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
|
||||
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
|
||||
await this.toolContextStore.write({
|
||||
actorKey: requestContext.actorKey,
|
||||
allowLearningWrite: true,
|
||||
sessionId: requestContext.sessionId,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
learningMode: "interactive",
|
||||
projectId: requestContext.projectId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: session.id,
|
||||
sessionScopeKey,
|
||||
traceId: requestContext.traceId,
|
||||
});
|
||||
|
||||
@@ -72,59 +81,58 @@ export class ChatSessionBridge {
|
||||
return this.activeRuntimeSessions.size;
|
||||
}
|
||||
|
||||
createSessionId() {
|
||||
createClientSessionId() {
|
||||
return `agent-${randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
getActiveSensitiveContext(runtimeSessionId: string) {
|
||||
return this.activeSensitiveContexts.get(runtimeSessionId) ?? null;
|
||||
getActiveSensitiveContext(sessionScopeKey: string) {
|
||||
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
|
||||
}
|
||||
|
||||
async abort(context: { sessionId?: string }): Promise<SessionBinding | null> {
|
||||
const sessionId = context.sessionId?.trim();
|
||||
async abort(context: {
|
||||
clientSessionId?: string;
|
||||
}): Promise<SessionBinding | null> {
|
||||
const clientSessionId = context.clientSessionId?.trim();
|
||||
if (!clientSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeSessionId = this.activeRuntimeSessions.get(sessionId);
|
||||
if (!runtimeSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.abortActiveRuntime(sessionId);
|
||||
await this.abortActiveRuntime(clientSessionId);
|
||||
return {
|
||||
clientSessionId,
|
||||
sessionId,
|
||||
runtimeSessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async releaseRuntimeSession(sessionId: string, runtimeSessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(sessionId);
|
||||
if (activeSessionId === runtimeSessionId) {
|
||||
this.activeRuntimeSessions.delete(sessionId);
|
||||
async releaseRuntimeSession(clientSessionId: string, sessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (activeSessionId === sessionId) {
|
||||
this.activeRuntimeSessions.delete(clientSessionId);
|
||||
}
|
||||
this.activeSensitiveContexts.delete(runtimeSessionId);
|
||||
await this.runtimeSessionStore.release(runtimeSessionId).catch((error) => {
|
||||
logger.debug(
|
||||
{ runtimeSessionId, err: error },
|
||||
"failed to cleanup persisted runtime session",
|
||||
);
|
||||
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||
await this.toolContextStore.remove(sessionId).catch((error) => {
|
||||
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context");
|
||||
});
|
||||
await this.runtime.abortSession(runtimeSessionId).catch((error) => {
|
||||
logger.debug({ runtimeSessionId, err: error }, "failed to cleanup runtime session");
|
||||
await this.runtime.abortSession(sessionId).catch((error) => {
|
||||
logger.debug({ sessionId, err: error }, "failed to cleanup runtime session");
|
||||
});
|
||||
}
|
||||
|
||||
private buildRequestContext(context: {
|
||||
sessionId?: string;
|
||||
clientSessionId?: string;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
userId?: string;
|
||||
}): ChatRequestContext {
|
||||
return {
|
||||
sessionId: context.sessionId?.trim() || this.createSessionId(),
|
||||
clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(),
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
projectId: context.projectId,
|
||||
@@ -134,25 +142,37 @@ export class ChatSessionBridge {
|
||||
};
|
||||
}
|
||||
|
||||
private async abortActiveRuntime(sessionId: string) {
|
||||
const activeRuntimeSessionId = this.activeRuntimeSessions.get(sessionId);
|
||||
if (!activeRuntimeSessionId) {
|
||||
private async abortActiveRuntime(clientSessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
this.activeRuntimeSessions.delete(sessionId);
|
||||
this.activeSensitiveContexts.delete(activeRuntimeSessionId);
|
||||
await this.runtimeSessionStore.release(activeRuntimeSessionId).catch(() => undefined);
|
||||
await this.runtime.abortSession(activeRuntimeSessionId).catch((error) => {
|
||||
this.activeRuntimeSessions.delete(clientSessionId);
|
||||
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||
await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
|
||||
await this.runtime.abortSession(activeSessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
|
||||
{ clientSessionId, sessionId: activeSessionId, err: error },
|
||||
"failed to abort previous active runtime session",
|
||||
);
|
||||
});
|
||||
await this.runtime.waitForSessionIdle(activeRuntimeSessionId).catch((error) => {
|
||||
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ sessionId, runtimeSessionId: activeRuntimeSessionId, err: error },
|
||||
{ clientSessionId, sessionId: activeSessionId, err: error },
|
||||
"failed while waiting for previous runtime session to become idle",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const findScopeKey = (
|
||||
contexts: Map<string, ChatRequestContext>,
|
||||
clientSessionId: string,
|
||||
) => {
|
||||
for (const [scopeKey, context] of contexts.entries()) {
|
||||
if (context.clientSessionId === clientSessionId) {
|
||||
return scopeKey;
|
||||
}
|
||||
}
|
||||
return clientSessionId;
|
||||
};
|
||||
|
||||
@@ -25,22 +25,6 @@ const envSchema = z
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
// HTTP 服务监听地址。
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
// PostgreSQL connection string; if omitted, PG* env vars are used.
|
||||
DATABASE_URL: optionalString(),
|
||||
// PostgreSQL host.
|
||||
PGHOST: optionalString(),
|
||||
// PostgreSQL port.
|
||||
PGPORT: z.coerce.number().int().positive().optional(),
|
||||
// PostgreSQL user.
|
||||
PGUSER: optionalString(),
|
||||
// PostgreSQL password.
|
||||
PGPASSWORD: optionalString(),
|
||||
// PostgreSQL database name.
|
||||
PGDATABASE: optionalString(),
|
||||
// PostgreSQL SSL mode.
|
||||
PGSSLMODE: z.enum(["disable", "prefer", "require"]).default("disable"),
|
||||
// PostgreSQL schema used by TJWaterAgent.
|
||||
AGENT_DB_SCHEMA: z.string().default("public"),
|
||||
// Pino 日志级别。
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
// LLM 工具/技能调用审计日志路径。
|
||||
@@ -132,16 +116,6 @@ const envSchema = z
|
||||
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!env.DATABASE_URL &&
|
||||
(!env.PGHOST || !env.PGUSER || !env.PGPASSWORD || !env.PGDATABASE)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["DATABASE_URL"],
|
||||
message: "DATABASE_URL or PGHOST/PGUSER/PGPASSWORD/PGDATABASE must be set",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { type QueryResultRow } from "pg";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type ConversationStateRecord = {
|
||||
sessionId: string;
|
||||
@@ -9,81 +15,27 @@ export type ConversationStateRecord = {
|
||||
branchGroups: unknown[];
|
||||
};
|
||||
|
||||
type ConversationStateRow = QueryResultRow & {
|
||||
session_id: string;
|
||||
is_title_manually_edited: boolean;
|
||||
messages: unknown[];
|
||||
branch_groups: unknown[];
|
||||
};
|
||||
|
||||
export class ConversationStateStore {
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await this.db.initialize();
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async read(sessionId: string) {
|
||||
const result = await this.db.query<ConversationStateRow>(
|
||||
`
|
||||
SELECT session_id, is_title_manually_edited, messages, branch_groups
|
||||
FROM ${this.db.table("conversation_states")}
|
||||
WHERE session_id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[sessionId],
|
||||
);
|
||||
return mapConversationStateRow(result.rows[0]);
|
||||
async read(sessionScopeKey: string) {
|
||||
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey));
|
||||
}
|
||||
|
||||
async write(sessionId: string, state: ConversationStateRecord) {
|
||||
const result = await this.db.query<ConversationStateRow>(
|
||||
`
|
||||
INSERT INTO ${this.db.table("conversation_states")} (
|
||||
session_id,
|
||||
is_title_manually_edited,
|
||||
messages,
|
||||
branch_groups
|
||||
)
|
||||
VALUES ($1, $2, $3::jsonb, $4::jsonb)
|
||||
ON CONFLICT (session_id)
|
||||
DO UPDATE SET
|
||||
is_title_manually_edited = EXCLUDED.is_title_manually_edited,
|
||||
messages = EXCLUDED.messages,
|
||||
branch_groups = EXCLUDED.branch_groups
|
||||
RETURNING session_id, is_title_manually_edited, messages, branch_groups
|
||||
`,
|
||||
[
|
||||
sessionId,
|
||||
state.isTitleManuallyEdited ?? false,
|
||||
JSON.stringify(state.messages),
|
||||
JSON.stringify(state.branchGroups),
|
||||
],
|
||||
);
|
||||
return mapConversationStateRow(result.rows[0]) ?? state;
|
||||
async write(sessionScopeKey: string, state: ConversationStateRecord) {
|
||||
await atomicWriteJson(this.filePath(sessionScopeKey), state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async remove(sessionId: string) {
|
||||
await this.db.query(
|
||||
`
|
||||
DELETE FROM ${this.db.table("conversation_states")}
|
||||
WHERE session_id = $1
|
||||
`,
|
||||
[sessionId],
|
||||
);
|
||||
async remove(sessionScopeKey: string) {
|
||||
await removeFileIfExists(this.filePath(sessionScopeKey));
|
||||
}
|
||||
|
||||
private filePath(sessionScopeKey: string) {
|
||||
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
const mapConversationStateRow = (
|
||||
row?: ConversationStateRow | null,
|
||||
): ConversationStateRecord | null => {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
isTitleManuallyEdited: row.is_title_manually_edited,
|
||||
messages: Array.isArray(row.messages) ? row.messages : [],
|
||||
branchGroups: Array.isArray(row.branch_groups) ? row.branch_groups : [],
|
||||
};
|
||||
};
|
||||
|
||||
+59
-167
@@ -1,13 +1,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { type QueryResultRow } from "pg";
|
||||
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||
|
||||
export type ConversationStatus = "active" | "archived";
|
||||
|
||||
export type ConversationRecord = {
|
||||
sessionId: string;
|
||||
sessionScopeKey: string;
|
||||
actorKey: string;
|
||||
ownerUserId?: string;
|
||||
projectId?: string;
|
||||
@@ -15,8 +23,6 @@ export type ConversationRecord = {
|
||||
parentSessionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isStreaming: boolean;
|
||||
streamingStartedAt?: string;
|
||||
status: ConversationStatus;
|
||||
title?: string;
|
||||
};
|
||||
@@ -33,59 +39,40 @@ type EnsureConversationInput = ConversationContext & {
|
||||
parentSessionId?: string;
|
||||
};
|
||||
|
||||
type ConversationRow = QueryResultRow & {
|
||||
session_id: string;
|
||||
actor_key: string;
|
||||
owner_user_id: string | null;
|
||||
project_id: string | null;
|
||||
project_key: string;
|
||||
parent_session_id: string | null;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
is_streaming: boolean;
|
||||
streaming_started_at: Date | string | null;
|
||||
status: ConversationStatus;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
export class ConversationStore {
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
constructor(private readonly baseDir = config.CONVERSATION_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await this.db.initialize();
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async ensure(input: EnsureConversationInput) {
|
||||
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
|
||||
const existing = await this.get(input, sessionId);
|
||||
const sessionScopeKey = toConversationScopeKey(
|
||||
input.actorKey,
|
||||
input.projectKey,
|
||||
sessionId,
|
||||
);
|
||||
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
|
||||
if (existing) {
|
||||
return { created: false, record: existing };
|
||||
}
|
||||
|
||||
const inserted = await this.db.query<ConversationRow>(
|
||||
`
|
||||
INSERT INTO ${this.db.table("conversations")} (
|
||||
session_id,
|
||||
actor_key,
|
||||
owner_user_id,
|
||||
project_id,
|
||||
project_key,
|
||||
parent_session_id,
|
||||
status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active')
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
sessionId,
|
||||
input.actorKey,
|
||||
input.userId?.trim() || null,
|
||||
input.projectId ?? null,
|
||||
input.projectKey,
|
||||
normalizeSessionId(input.parentSessionId) ?? null,
|
||||
],
|
||||
);
|
||||
return { created: true, record: requireConversationRow(inserted.rows[0]) };
|
||||
const now = new Date().toISOString();
|
||||
const record: ConversationRecord = {
|
||||
sessionId,
|
||||
sessionScopeKey,
|
||||
actorKey: input.actorKey,
|
||||
ownerUserId: input.userId?.trim(),
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
parentSessionId: normalizeSessionId(input.parentSessionId),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "active",
|
||||
};
|
||||
await atomicWriteJson(this.filePath(sessionScopeKey), record);
|
||||
return { created: true, record };
|
||||
}
|
||||
|
||||
async get(context: ConversationContext, sessionId: string) {
|
||||
@@ -93,111 +80,47 @@ export class ConversationStore {
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
const result = await this.db.query<ConversationRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("conversations")}
|
||||
WHERE session_id = $1
|
||||
AND actor_key = $2
|
||||
AND project_key = $3
|
||||
LIMIT 1
|
||||
`,
|
||||
[normalizedSessionId, context.actorKey, context.projectKey],
|
||||
return await readJsonFile<ConversationRecord>(
|
||||
this.filePath(
|
||||
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
|
||||
),
|
||||
);
|
||||
return mapConversationRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async touch(
|
||||
record: ConversationRecord,
|
||||
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
|
||||
) {
|
||||
const normalized = normalizeConversationUpdates(updates);
|
||||
const result = await this.db.query<ConversationRow>(
|
||||
`
|
||||
UPDATE ${this.db.table("conversations")}
|
||||
SET
|
||||
title = COALESCE($2, title),
|
||||
status = COALESCE($3, status)
|
||||
WHERE session_id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[record.sessionId, normalized.title ?? null, normalized.status ?? null],
|
||||
);
|
||||
return requireConversationRow(result.rows[0]);
|
||||
const next: ConversationRecord = {
|
||||
...record,
|
||||
...normalizeConversationUpdates(updates),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteJson(this.filePath(record.sessionScopeKey), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async list(context: ConversationContext) {
|
||||
const result = await this.db.query<ConversationRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("conversations")}
|
||||
WHERE actor_key = $1
|
||||
AND project_key = $2
|
||||
ORDER BY updated_at DESC
|
||||
`,
|
||||
[context.actorKey, context.projectKey],
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(
|
||||
files.map((file) => readJsonFile<ConversationRecord>(file)),
|
||||
);
|
||||
return result.rows
|
||||
.map(mapConversationRow)
|
||||
.filter((record): record is ConversationRecord => Boolean(record));
|
||||
return records
|
||||
.filter((record): record is ConversationRecord => Boolean(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey,
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
async remove(record: ConversationRecord) {
|
||||
await this.db.query(
|
||||
`
|
||||
DELETE FROM ${this.db.table("conversations")}
|
||||
WHERE session_id = $1
|
||||
`,
|
||||
[record.sessionId],
|
||||
);
|
||||
await removeFileIfExists(this.filePath(record.sessionScopeKey));
|
||||
}
|
||||
|
||||
async markStreaming(record: ConversationRecord, runtimeSessionId: string) {
|
||||
const result = await this.db.query<ConversationRow>(
|
||||
`
|
||||
UPDATE ${this.db.table("conversations")}
|
||||
SET
|
||||
is_streaming = TRUE,
|
||||
active_runtime_session_id = $2,
|
||||
streaming_started_at = NOW()
|
||||
WHERE session_id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[record.sessionId, runtimeSessionId],
|
||||
);
|
||||
return requireConversationRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async clearStreaming(sessionId: string, runtimeSessionId: string) {
|
||||
const result = await this.db.query<ConversationRow>(
|
||||
`
|
||||
UPDATE ${this.db.table("conversations")}
|
||||
SET
|
||||
is_streaming = FALSE,
|
||||
active_runtime_session_id = NULL,
|
||||
streaming_started_at = NULL
|
||||
WHERE session_id = $1
|
||||
AND active_runtime_session_id = $2
|
||||
RETURNING *
|
||||
`,
|
||||
[sessionId, runtimeSessionId],
|
||||
);
|
||||
return mapConversationRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async resetStreamingSessions() {
|
||||
await this.db.query(
|
||||
`
|
||||
UPDATE ${this.db.table("conversations")}
|
||||
SET
|
||||
is_streaming = FALSE,
|
||||
active_runtime_session_id = NULL,
|
||||
streaming_started_at = NULL
|
||||
WHERE is_streaming = TRUE
|
||||
OR active_runtime_session_id IS NOT NULL
|
||||
OR streaming_started_at IS NOT NULL
|
||||
`,
|
||||
);
|
||||
private filePath(sessionScopeKey: string) {
|
||||
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,34 +146,3 @@ const normalizeConversationUpdates = (
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const mapConversationRow = (row?: ConversationRow | null): ConversationRecord | null => {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
actorKey: row.actor_key,
|
||||
ownerUserId: row.owner_user_id ?? undefined,
|
||||
projectId: row.project_id ?? undefined,
|
||||
projectKey: row.project_key,
|
||||
parentSessionId: row.parent_session_id ?? undefined,
|
||||
createdAt: toIsoString(row.created_at),
|
||||
updatedAt: toIsoString(row.updated_at),
|
||||
isStreaming: row.is_streaming,
|
||||
streamingStartedAt: row.streaming_started_at ? toIsoString(row.streaming_started_at) : undefined,
|
||||
status: row.status,
|
||||
title: row.title ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const requireConversationRow = (row?: ConversationRow | null) => {
|
||||
const record = mapConversationRow(row);
|
||||
if (!record) {
|
||||
throw new Error("conversation row not found");
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
const toIsoString = (value: Date | string) =>
|
||||
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
|
||||
-486
@@ -1,486 +0,0 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from "pg";
|
||||
|
||||
import { config } from "../config.js";
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export class AgentDatabase {
|
||||
private readonly pool: Pool;
|
||||
private readonly schemaName: string;
|
||||
private initialized = false;
|
||||
private initializePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(options?: {
|
||||
connectionString?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
sslmode?: "disable" | "prefer" | "require";
|
||||
schema?: string;
|
||||
}) {
|
||||
const schema = options?.schema ?? config.AGENT_DB_SCHEMA;
|
||||
if (!IDENTIFIER_PATTERN.test(schema)) {
|
||||
throw new Error(`invalid PostgreSQL schema name: ${schema}`);
|
||||
}
|
||||
this.schemaName = schema;
|
||||
this.pool = new Pool({
|
||||
...(options?.connectionString ?? config.DATABASE_URL
|
||||
? {
|
||||
connectionString: options?.connectionString ?? config.DATABASE_URL,
|
||||
}
|
||||
: {
|
||||
host: options?.host ?? config.PGHOST,
|
||||
port: options?.port ?? config.PGPORT ?? 5432,
|
||||
user: options?.user ?? config.PGUSER,
|
||||
password: options?.password ?? config.PGPASSWORD,
|
||||
database: options?.database ?? config.PGDATABASE,
|
||||
}),
|
||||
ssl:
|
||||
(options?.sslmode ?? config.PGSSLMODE) === "require"
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
get schema() {
|
||||
return this.schemaName;
|
||||
}
|
||||
|
||||
table(name: string) {
|
||||
if (!IDENTIFIER_PATTERN.test(name)) {
|
||||
throw new Error(`invalid PostgreSQL table name: ${name}`);
|
||||
}
|
||||
return `"${this.schemaName}"."${name}"`;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (!this.initializePromise) {
|
||||
this.initializePromise = (async () => {
|
||||
await this.query(`CREATE SCHEMA IF NOT EXISTS "${this.schemaName}"`);
|
||||
await this.query(buildSchemaSql(this.schemaName));
|
||||
this.initialized = true;
|
||||
})().catch((error) => {
|
||||
this.initializePromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
await this.initializePromise;
|
||||
}
|
||||
|
||||
async query<T extends QueryResultRow>(
|
||||
text: string,
|
||||
values?: unknown[],
|
||||
client?: PoolClient,
|
||||
) {
|
||||
const executor = client ?? this.pool;
|
||||
return await executor.query<T>(text, values);
|
||||
}
|
||||
|
||||
async withTransaction<T>(task: (client: PoolClient) => Promise<T>) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await task(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
let defaultDatabase: AgentDatabase | undefined;
|
||||
|
||||
export const getAgentDatabase = () => {
|
||||
if (!defaultDatabase) {
|
||||
defaultDatabase = new AgentDatabase();
|
||||
}
|
||||
return defaultDatabase;
|
||||
};
|
||||
|
||||
const buildSchemaSql = (schema: string) => `
|
||||
CREATE OR REPLACE FUNCTION "${schema}".set_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."conversations" (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
actor_key TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
project_id TEXT,
|
||||
project_key TEXT NOT NULL,
|
||||
parent_session_id TEXT REFERENCES "${schema}"."conversations"(session_id) ON DELETE SET NULL,
|
||||
title VARCHAR(120),
|
||||
is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
active_runtime_session_id TEXT,
|
||||
streaming_started_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE "${schema}"."conversations"
|
||||
ADD COLUMN IF NOT EXISTS is_streaming BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS active_runtime_session_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS streaming_started_at TIMESTAMPTZ;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'conversations'
|
||||
AND column_name = 'session_scope_key'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."conversations" DROP COLUMN session_scope_key';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_actor_project_updated
|
||||
ON "${schema}"."conversations" (actor_key, project_key, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_parent_session
|
||||
ON "${schema}"."conversations" (parent_session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."conversation_states" (
|
||||
session_id TEXT PRIMARY KEY REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
||||
is_title_manually_edited BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
branch_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE "${schema}"."conversation_states"
|
||||
ADD COLUMN IF NOT EXISTS is_title_manually_edited BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS branch_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'conversation_states'
|
||||
AND column_name = 'session_scope_key'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."conversation_states" DROP COLUMN session_scope_key';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."conversation_turns" (
|
||||
turn_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
||||
turn_index INTEGER NOT NULL CHECK (turn_index >= 0),
|
||||
actor_key TEXT NOT NULL,
|
||||
project_key TEXT NOT NULL,
|
||||
user_message TEXT NOT NULL,
|
||||
assistant_message TEXT NOT NULL,
|
||||
tool_call_count INTEGER NOT NULL DEFAULT 0 CHECK (tool_call_count >= 0),
|
||||
turn_timestamp TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id, turn_index)
|
||||
);
|
||||
|
||||
ALTER TABLE "${schema}"."conversation_turns"
|
||||
ADD COLUMN IF NOT EXISTS turn_index INTEGER;
|
||||
|
||||
WITH ranked_turns AS (
|
||||
SELECT
|
||||
turn_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY session_id
|
||||
ORDER BY turn_timestamp ASC, turn_id ASC
|
||||
) - 1 AS computed_turn_index
|
||||
FROM "${schema}"."conversation_turns"
|
||||
WHERE turn_index IS NULL
|
||||
)
|
||||
UPDATE "${schema}"."conversation_turns" turns
|
||||
SET turn_index = ranked_turns.computed_turn_index
|
||||
FROM ranked_turns
|
||||
WHERE turns.turn_id = ranked_turns.turn_id;
|
||||
|
||||
ALTER TABLE "${schema}"."conversation_turns"
|
||||
ALTER COLUMN turn_index SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'conversation_turns'
|
||||
AND column_name = 'client_session_id'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."conversation_turns" DROP COLUMN client_session_id';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_turns_session_time
|
||||
ON "${schema}"."conversation_turns" (session_id, turn_index DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_turns_actor_project_time
|
||||
ON "${schema}"."conversation_turns" (actor_key, project_key, turn_timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_turns_search
|
||||
ON "${schema}"."conversation_turns"
|
||||
USING GIN (to_tsvector('simple', coalesce(user_message, '') || ' ' || coalesce(assistant_message, '')));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."runtime_sessions" (
|
||||
runtime_session_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
||||
actor_key TEXT NOT NULL,
|
||||
allow_learning_write BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
learning_mode TEXT CHECK (learning_mode IN ('interactive', 'review') OR learning_mode IS NULL),
|
||||
project_id TEXT,
|
||||
project_key TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
released_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
ALTER TABLE "${schema}"."runtime_sessions"
|
||||
ADD COLUMN IF NOT EXISTS session_id TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'runtime_sessions'
|
||||
AND column_name = 'conversation_id'
|
||||
) THEN
|
||||
EXECUTE 'UPDATE "${schema}"."runtime_sessions" SET session_id = COALESCE(session_id, conversation_id) WHERE session_id IS NULL';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'runtime_sessions_session_id_fkey'
|
||||
AND connamespace = to_regnamespace('${schema}')
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."runtime_sessions" ADD CONSTRAINT runtime_sessions_session_id_fkey FOREIGN KEY (session_id) REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "${schema}"."runtime_sessions"
|
||||
ALTER COLUMN session_id SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('"${schema}"."tool_session_contexts"') IS NOT NULL THEN
|
||||
INSERT INTO "${schema}"."runtime_sessions" (
|
||||
runtime_session_id,
|
||||
session_id,
|
||||
actor_key,
|
||||
allow_learning_write,
|
||||
learning_mode,
|
||||
project_id,
|
||||
project_key,
|
||||
trace_id,
|
||||
released_at
|
||||
)
|
||||
SELECT
|
||||
runtime_session_id,
|
||||
client_session_id,
|
||||
actor_key,
|
||||
allow_learning_write,
|
||||
learning_mode,
|
||||
project_id,
|
||||
project_key,
|
||||
trace_id,
|
||||
NOW()
|
||||
FROM "${schema}"."tool_session_contexts"
|
||||
WHERE client_session_id IS NOT NULL
|
||||
ON CONFLICT (runtime_session_id) DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP INDEX IF EXISTS "${schema}"."idx_runtime_sessions_conversation";
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_session
|
||||
ON "${schema}"."runtime_sessions" (session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_trace_id
|
||||
ON "${schema}"."runtime_sessions" (trace_id);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'runtime_sessions'
|
||||
AND column_name = 'conversation_id'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."runtime_sessions" DROP COLUMN conversation_id';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP TABLE IF EXISTS "${schema}"."tool_session_contexts";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."learning_states" (
|
||||
session_id TEXT PRIMARY KEY REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
||||
last_gated_turn INTEGER NOT NULL DEFAULT 0 CHECK (last_gated_turn >= 0),
|
||||
last_reviewed_turn INTEGER NOT NULL DEFAULT 0 CHECK (last_reviewed_turn >= 0),
|
||||
pending_review BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."result_refs" (
|
||||
result_ref TEXT PRIMARY KEY,
|
||||
actor_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('dynamic-http-result', 'render-junctions-payload')),
|
||||
preview JSONB NOT NULL,
|
||||
project_id TEXT,
|
||||
project_key TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1 CHECK (schema_version > 0),
|
||||
size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
|
||||
source TEXT NOT NULL CHECK (source IN ('dynamic_http', 'agent_generated', 'legacy', 'migration')),
|
||||
trace_id TEXT NOT NULL,
|
||||
payload_path TEXT,
|
||||
object_key TEXT,
|
||||
CONSTRAINT chk_result_refs_payload_locator CHECK (
|
||||
num_nonnulls(payload_path, object_key) = 1
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "${schema}"."result_refs"
|
||||
ADD COLUMN IF NOT EXISTS session_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS payload_path TEXT,
|
||||
ADD COLUMN IF NOT EXISTS object_key TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'result_refs'
|
||||
AND column_name = 'conversation_id'
|
||||
) THEN
|
||||
EXECUTE 'UPDATE "${schema}"."result_refs" SET session_id = COALESCE(session_id, conversation_id) WHERE session_id IS NULL';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'result_refs'
|
||||
AND column_name = 'client_session_id'
|
||||
) THEN
|
||||
EXECUTE 'UPDATE "${schema}"."result_refs" SET session_id = COALESCE(session_id, client_session_id) WHERE session_id IS NULL';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'result_refs_session_id_fkey'
|
||||
AND connamespace = to_regnamespace('${schema}')
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" ADD CONSTRAINT result_refs_session_id_fkey FOREIGN KEY (session_id) REFERENCES "${schema}"."conversations"(session_id) ON DELETE CASCADE';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'result_refs'
|
||||
AND column_name = 'client_session_id'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" DROP COLUMN client_session_id';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'result_refs'
|
||||
AND column_name = 'data'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" ALTER COLUMN data DROP NOT NULL';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '${schema}'
|
||||
AND table_name = 'result_refs'
|
||||
AND column_name = 'conversation_id'
|
||||
) THEN
|
||||
EXECUTE 'ALTER TABLE "${schema}"."result_refs" DROP COLUMN conversation_id';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "${schema}"."result_refs"
|
||||
ALTER COLUMN session_id SET NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_result_refs_session_created
|
||||
ON "${schema}"."result_refs" (session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_result_refs_actor_project_created
|
||||
ON "${schema}"."result_refs" (actor_key, project_key, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_result_refs_trace_id
|
||||
ON "${schema}"."result_refs" (trace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "${schema}"."memories" (
|
||||
memory_id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('user', 'workspace')),
|
||||
scope_key TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
source TEXT NOT NULL CHECK (source IN ('review', 'tool')),
|
||||
session_id TEXT REFERENCES "${schema}"."conversations"(session_id) ON DELETE SET NULL,
|
||||
trace_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (scope, scope_key, content)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_scope_key
|
||||
ON "${schema}"."memories" (scope, scope_key, updated_at DESC);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_conversations_set_updated_at ON "${schema}"."conversations";
|
||||
CREATE TRIGGER trg_conversations_set_updated_at
|
||||
BEFORE UPDATE ON "${schema}"."conversations"
|
||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_conversation_states_set_updated_at ON "${schema}"."conversation_states";
|
||||
CREATE TRIGGER trg_conversation_states_set_updated_at
|
||||
BEFORE UPDATE ON "${schema}"."conversation_states"
|
||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_runtime_sessions_set_updated_at ON "${schema}"."runtime_sessions";
|
||||
CREATE TRIGGER trg_runtime_sessions_set_updated_at
|
||||
BEFORE UPDATE ON "${schema}"."runtime_sessions"
|
||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_learning_states_set_updated_at ON "${schema}"."learning_states";
|
||||
CREATE TRIGGER trg_learning_states_set_updated_at
|
||||
BEFORE UPDATE ON "${schema}"."learning_states"
|
||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_memories_set_updated_at ON "${schema}"."memories";
|
||||
CREATE TRIGGER trg_memories_set_updated_at
|
||||
BEFORE UPDATE ON "${schema}"."memories"
|
||||
FOR EACH ROW EXECUTE FUNCTION "${schema}".set_updated_at();
|
||||
`;
|
||||
+166
-212
@@ -1,9 +1,14 @@
|
||||
import { type QueryResultRow } from "pg";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
toStableId,
|
||||
} from "../utils/fileStore.js";
|
||||
import { sanitizePersistentDocument } from "../utils/persistencePolicy.js";
|
||||
import { toStableId } from "../utils/fileStore.js";
|
||||
|
||||
export type SessionTurnRecord = {
|
||||
id: string;
|
||||
@@ -15,6 +20,7 @@ export type SessionTurnRecord = {
|
||||
|
||||
type SessionTranscriptRecord = {
|
||||
actorKey: string;
|
||||
clientSessionId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
turns: SessionTurnRecord[];
|
||||
@@ -32,28 +38,18 @@ export type SessionSearchHit = {
|
||||
|
||||
type SessionHistoryContext = {
|
||||
actorKey: string;
|
||||
clientSessionId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type ConversationTurnRow = QueryResultRow & {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
turn_index: number;
|
||||
actor_key: string;
|
||||
project_key: string;
|
||||
user_message: string;
|
||||
assistant_message: string;
|
||||
tool_call_count: number;
|
||||
turn_timestamp: Date | string;
|
||||
created_at: Date | string;
|
||||
};
|
||||
|
||||
export class SessionHistoryStore {
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
private readonly writeQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private readonly baseDir = config.SESSION_HISTORY_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await this.db.initialize();
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async appendTurn(
|
||||
@@ -63,146 +59,73 @@ export class SessionHistoryStore {
|
||||
toolCallCount: number;
|
||||
userMessage: string;
|
||||
},
|
||||
): Promise<SessionTranscriptRecord> {
|
||||
const userMessage = sanitizePersistentDocument(turn.userMessage, 4000);
|
||||
const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000);
|
||||
if (!userMessage || !assistantMessage) {
|
||||
return (await this.readTranscript(context)) ?? emptyTranscript(context);
|
||||
}
|
||||
) {
|
||||
const key = this.filePath(context);
|
||||
return this.serializeWrite(key, async () => {
|
||||
const transcript = (await this.readTranscript(context)) ?? {
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.sessionId,
|
||||
turns: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const userMessage = sanitizePersistentDocument(turn.userMessage, 4000);
|
||||
const assistantMessage = sanitizePersistentDocument(turn.assistantMessage, 4000);
|
||||
if (!userMessage || !assistantMessage) {
|
||||
return transcript;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.db.withTransaction(async (client) => {
|
||||
await this.db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [context.sessionId], client);
|
||||
const nextIndexResult = await this.db.query<{ next_index: number }>(
|
||||
`
|
||||
SELECT COALESCE(MAX(turn_index), -1) + 1 AS next_index
|
||||
FROM ${this.db.table("conversation_turns")}
|
||||
WHERE session_id = $1
|
||||
`,
|
||||
[context.sessionId],
|
||||
client,
|
||||
);
|
||||
const nextIndex = nextIndexResult.rows[0]?.next_index ?? 0;
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO ${this.db.table("conversation_turns")} (
|
||||
turn_id,
|
||||
session_id,
|
||||
turn_index,
|
||||
actor_key,
|
||||
project_key,
|
||||
user_message,
|
||||
assistant_message,
|
||||
tool_call_count,
|
||||
turn_timestamp
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
[
|
||||
toStableId(context.sessionId, String(nextIndex), timestamp, userMessage, assistantMessage),
|
||||
context.sessionId,
|
||||
nextIndex,
|
||||
context.actorKey,
|
||||
context.projectKey,
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
Math.max(0, turn.toolCallCount),
|
||||
timestamp,
|
||||
],
|
||||
client,
|
||||
);
|
||||
const timestamp = new Date().toISOString();
|
||||
const record: SessionTurnRecord = {
|
||||
id: toStableId(context.sessionId, timestamp, userMessage, assistantMessage),
|
||||
assistantMessage,
|
||||
timestamp,
|
||||
toolCallCount: Math.max(0, turn.toolCallCount),
|
||||
userMessage,
|
||||
};
|
||||
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
||||
transcript.sessionId = context.sessionId;
|
||||
transcript.turns.push(record);
|
||||
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
|
||||
transcript.turns = transcript.turns.slice(
|
||||
transcript.turns.length - config.SESSION_HISTORY_MAX_TURNS_PER_SESSION,
|
||||
);
|
||||
}
|
||||
transcript.updatedAt = timestamp;
|
||||
await atomicWriteJson(key, transcript);
|
||||
return transcript;
|
||||
});
|
||||
return (await this.readTranscript(context)) ?? emptyTranscript(context);
|
||||
}
|
||||
|
||||
async getRecentTurns(
|
||||
context: SessionHistoryContext,
|
||||
limit: number,
|
||||
): Promise<SessionTurnRecord[]> {
|
||||
const result = await this.db.query<ConversationTurnRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("conversation_turns")}
|
||||
WHERE session_id = $1
|
||||
AND actor_key = $2
|
||||
AND project_key = $3
|
||||
ORDER BY turn_index DESC
|
||||
LIMIT $4
|
||||
`,
|
||||
[context.sessionId, context.actorKey, context.projectKey, Math.max(1, limit)],
|
||||
);
|
||||
return result.rows
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(mapTurnRow);
|
||||
const transcript = await this.readTranscript(context);
|
||||
if (!transcript) {
|
||||
return [];
|
||||
}
|
||||
return transcript.turns.slice(-Math.max(1, limit));
|
||||
}
|
||||
|
||||
async cloneThread(
|
||||
sourceContext: SessionHistoryContext,
|
||||
targetContext: SessionHistoryContext,
|
||||
keepMessageCount: number,
|
||||
): Promise<SessionTranscriptRecord> {
|
||||
const keepTurnCount = Math.floor(keepMessageCount / 2);
|
||||
const sourceTurns = keepTurnCount
|
||||
? await this.db.query<ConversationTurnRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("conversation_turns")}
|
||||
WHERE session_id = $1
|
||||
AND actor_key = $2
|
||||
AND project_key = $3
|
||||
ORDER BY turn_index ASC
|
||||
LIMIT $4
|
||||
`,
|
||||
[
|
||||
sourceContext.sessionId,
|
||||
sourceContext.actorKey,
|
||||
sourceContext.projectKey,
|
||||
keepTurnCount,
|
||||
],
|
||||
)
|
||||
: { rows: [] as ConversationTurnRow[] };
|
||||
|
||||
await this.db.withTransaction(async (client) => {
|
||||
for (const [turnIndex, row] of sourceTurns.rows.entries()) {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO ${this.db.table("conversation_turns")} (
|
||||
turn_id,
|
||||
session_id,
|
||||
turn_index,
|
||||
actor_key,
|
||||
project_key,
|
||||
user_message,
|
||||
assistant_message,
|
||||
tool_call_count,
|
||||
turn_timestamp
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
[
|
||||
toStableId(
|
||||
targetContext.sessionId,
|
||||
String(turnIndex),
|
||||
toIsoString(row.turn_timestamp),
|
||||
row.user_message,
|
||||
row.assistant_message,
|
||||
),
|
||||
targetContext.sessionId,
|
||||
turnIndex,
|
||||
targetContext.actorKey,
|
||||
targetContext.projectKey,
|
||||
row.user_message,
|
||||
row.assistant_message,
|
||||
row.tool_call_count,
|
||||
row.turn_timestamp,
|
||||
],
|
||||
client,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (await this.readTranscript(targetContext)) ?? emptyTranscript(targetContext);
|
||||
) {
|
||||
const sourceTranscript = await this.readTranscript(sourceContext);
|
||||
const timestamp = new Date().toISOString();
|
||||
const nextTranscript: SessionTranscriptRecord = {
|
||||
actorKey: targetContext.actorKey,
|
||||
clientSessionId: targetContext.clientSessionId,
|
||||
projectKey: targetContext.projectKey,
|
||||
sessionId: targetContext.sessionId,
|
||||
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await atomicWriteJson(this.filePath(targetContext), nextTranscript);
|
||||
return nextTranscript;
|
||||
}
|
||||
|
||||
async search(
|
||||
@@ -214,86 +137,104 @@ export class SessionHistoryStore {
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
const rows = await this.db.query<ConversationTurnRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("conversation_turns")}
|
||||
WHERE actor_key = $1
|
||||
AND project_key = $2
|
||||
AND (
|
||||
LOWER(user_message) LIKE $3
|
||||
OR LOWER(assistant_message) LIKE $3
|
||||
)
|
||||
ORDER BY turn_timestamp DESC
|
||||
`,
|
||||
[context.actorKey, context.projectKey, `%${normalizedQuery}%`],
|
||||
);
|
||||
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
const hits: SessionSearchHit[] = [];
|
||||
for (const row of rows.rows) {
|
||||
const candidates: Array<["user" | "assistant", string]> = [
|
||||
["user", row.user_message],
|
||||
["assistant", row.assistant_message],
|
||||
];
|
||||
for (const [matchedField, text] of candidates) {
|
||||
const score = scoreText(text, normalizedQuery, queryTokens);
|
||||
if (score <= 0) {
|
||||
continue;
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
for (const file of files) {
|
||||
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
||||
if (!transcript) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
transcript.actorKey !== context.actorKey ||
|
||||
transcript.projectKey !== context.projectKey
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const turn of transcript.turns) {
|
||||
const candidates: Array<["user" | "assistant", string]> = [
|
||||
["user", turn.userMessage],
|
||||
["assistant", turn.assistantMessage],
|
||||
];
|
||||
for (const [matchedField, text] of candidates) {
|
||||
const score = scoreText(text, normalizedQuery, queryTokens);
|
||||
if (score <= 0) {
|
||||
continue;
|
||||
}
|
||||
hits.push({
|
||||
matchedField,
|
||||
score,
|
||||
sessionId: transcript.sessionId,
|
||||
snippet: buildSnippet(text, normalizedQuery),
|
||||
timestamp: turn.timestamp,
|
||||
turnId: turn.id,
|
||||
});
|
||||
}
|
||||
hits.push({
|
||||
matchedField,
|
||||
score,
|
||||
sessionId: row.session_id,
|
||||
snippet: buildSnippet(text, normalizedQuery),
|
||||
timestamp: toIsoString(row.turn_timestamp),
|
||||
turnId: row.turn_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits.sort((a, b) => b.score - a.score).slice(0, Math.max(1, maxResults));
|
||||
}
|
||||
|
||||
private async readTranscript(context: SessionHistoryContext) {
|
||||
const result = await this.db.query<ConversationTurnRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("conversation_turns")}
|
||||
WHERE session_id = $1
|
||||
AND actor_key = $2
|
||||
AND project_key = $3
|
||||
ORDER BY turn_index ASC
|
||||
`,
|
||||
[context.sessionId, context.actorKey, context.projectKey],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const clientSessionId = context.clientSessionId?.trim();
|
||||
if (!clientSessionId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
actorKey: context.actorKey,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.sessionId,
|
||||
turns: result.rows.map(mapTurnRow),
|
||||
updatedAt: toIsoString(result.rows[result.rows.length - 1]?.turn_timestamp ?? new Date()),
|
||||
};
|
||||
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const matches: SessionTranscriptRecord[] = [];
|
||||
for (const file of files) {
|
||||
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
||||
if (!transcript) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
transcript.actorKey !== context.actorKey ||
|
||||
transcript.projectKey !== context.projectKey ||
|
||||
transcript.clientSessionId !== clientSessionId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
matches.push(transcript);
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
||||
}
|
||||
|
||||
private filePath(context: SessionHistoryContext) {
|
||||
return join(
|
||||
this.baseDir,
|
||||
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
private async serializeWrite<T>(key: string, task: () => Promise<T>) {
|
||||
const previous = this.writeQueues.get(key) ?? Promise.resolve();
|
||||
const run = previous.catch(() => undefined).then(task);
|
||||
const next = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.writeQueues.set(key, next);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.writeQueues.get(key) === next) {
|
||||
this.writeQueues.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTranscript = (context: SessionHistoryContext): SessionTranscriptRecord => ({
|
||||
actorKey: context.actorKey,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.sessionId,
|
||||
turns: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const mapTurnRow = (row: ConversationTurnRow): SessionTurnRecord => ({
|
||||
id: row.turn_id,
|
||||
assistantMessage: row.assistant_message,
|
||||
timestamp: toIsoString(row.turn_timestamp),
|
||||
toolCallCount: row.tool_call_count,
|
||||
userMessage: row.user_message,
|
||||
});
|
||||
|
||||
const scoreText = (text: string, query: string, queryTokens: string[]) => {
|
||||
const normalized = text.toLowerCase();
|
||||
let score = 0;
|
||||
@@ -322,5 +263,18 @@ const buildSnippet = (text: string, query: string) => {
|
||||
return `${prefix}${snippet}${suffix}`;
|
||||
};
|
||||
|
||||
const toIsoString = (value: Date | string) =>
|
||||
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
const projectTurnsForFork = (
|
||||
turns: SessionTurnRecord[],
|
||||
keepMessageCount: number,
|
||||
): SessionTurnRecord[] => {
|
||||
if (keepMessageCount <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keepTurnCount = Math.floor(keepMessageCount / 2);
|
||||
if (keepTurnCount <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return turns.slice(0, keepTurnCount);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,10 @@ import { LearningStateStore } from "./stateStore.js";
|
||||
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { SkillStore } from "../skills/store.js";
|
||||
import { RuntimeSessionStore } from "../session/runtimeSessionStore.js";
|
||||
import {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../session/toolContextStore.js";
|
||||
import {
|
||||
sanitizePersistentDocument,
|
||||
sanitizePersistentLine,
|
||||
@@ -70,7 +73,7 @@ export class LearningOrchestrator {
|
||||
private readonly activeReviews = new Set<string>();
|
||||
private readonly learningStateStore = new LearningStateStore();
|
||||
private readonly skillStore = new SkillStore();
|
||||
private readonly runtimeSessionStore = new RuntimeSessionStore();
|
||||
private readonly toolContextStore = new ToolSessionContextStore();
|
||||
|
||||
constructor(
|
||||
private readonly runtime: OpencodeRuntimeAdapter,
|
||||
@@ -81,7 +84,7 @@ export class LearningOrchestrator {
|
||||
async initialize() {
|
||||
await Promise.all([
|
||||
this.learningStateStore.initialize(),
|
||||
this.runtimeSessionStore.initialize(),
|
||||
this.toolContextStore.initialize(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -89,6 +92,7 @@ export class LearningOrchestrator {
|
||||
const transcript = await this.historyStore.appendTurn(
|
||||
{
|
||||
actorKey: input.requestContext.actorKey,
|
||||
clientSessionId: input.requestContext.clientSessionId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: input.sessionId,
|
||||
},
|
||||
@@ -138,17 +142,22 @@ export class LearningOrchestrator {
|
||||
let gateSessionId: string | null = null;
|
||||
try {
|
||||
const gateSession = await this.runtime.createSession(
|
||||
`learning-gate-${input.requestContext.sessionId}`,
|
||||
`learning-gate-${input.requestContext.clientSessionId}`,
|
||||
);
|
||||
gateSessionId = gateSession.id;
|
||||
await this.runtimeSessionStore.write({
|
||||
runtimeSessionId: gateSession.id,
|
||||
await this.toolContextStore.write({
|
||||
actorKey: input.requestContext.actorKey,
|
||||
allowLearningWrite: false,
|
||||
sessionId: input.requestContext.sessionId,
|
||||
clientSessionId: `gate-${input.requestContext.clientSessionId}`,
|
||||
learningMode: "review",
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: gateSession.id,
|
||||
sessionScopeKey: buildToolSessionScopeKey(
|
||||
input.requestContext.actorKey,
|
||||
input.requestContext.projectKey,
|
||||
input.requestContext.clientSessionId,
|
||||
),
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
await this.runtime.prompt(
|
||||
@@ -210,7 +219,7 @@ export class LearningOrchestrator {
|
||||
});
|
||||
} finally {
|
||||
if (gateSessionId) {
|
||||
await this.runtimeSessionStore.release(gateSessionId).catch(() => undefined);
|
||||
await this.toolContextStore.remove(gateSessionId).catch(() => undefined);
|
||||
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -228,16 +237,21 @@ export class LearningOrchestrator {
|
||||
turnCount: number;
|
||||
}) {
|
||||
const reviewSession = await this.runtime.createSession(
|
||||
`learning-review-${input.requestContext.sessionId}`,
|
||||
`learning-review-${input.requestContext.clientSessionId}`,
|
||||
);
|
||||
await this.runtimeSessionStore.write({
|
||||
runtimeSessionId: reviewSession.id,
|
||||
await this.toolContextStore.write({
|
||||
actorKey: input.requestContext.actorKey,
|
||||
allowLearningWrite: false,
|
||||
sessionId: input.requestContext.sessionId,
|
||||
clientSessionId: `review-${input.requestContext.clientSessionId}`,
|
||||
learningMode: "review",
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: reviewSession.id,
|
||||
sessionScopeKey: buildToolSessionScopeKey(
|
||||
input.requestContext.actorKey,
|
||||
input.requestContext.projectKey,
|
||||
input.requestContext.clientSessionId,
|
||||
),
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
try {
|
||||
@@ -278,7 +292,7 @@ export class LearningOrchestrator {
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
} finally {
|
||||
await this.runtimeSessionStore.release(reviewSession.id).catch(() => undefined);
|
||||
await this.toolContextStore.remove(reviewSession.id).catch(() => undefined);
|
||||
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-64
@@ -1,6 +1,11 @@
|
||||
import { type QueryResultRow } from "pg";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
readJsonFile,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type LearningSessionState = {
|
||||
lastGatedTurn: number;
|
||||
@@ -10,65 +15,32 @@ export type LearningSessionState = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type LearningStateRow = QueryResultRow & {
|
||||
session_id: string;
|
||||
last_gated_turn: number;
|
||||
last_reviewed_turn: number;
|
||||
pending_review: boolean;
|
||||
updated_at: Date | string;
|
||||
};
|
||||
|
||||
export class LearningStateStore {
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
constructor(private readonly baseDir = config.LEARNING_STATE_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await this.db.initialize();
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async read(sessionId: string): Promise<LearningSessionState> {
|
||||
const result = await this.db.query<LearningStateRow>(
|
||||
`
|
||||
SELECT session_id, last_gated_turn, last_reviewed_turn, pending_review, updated_at
|
||||
FROM ${this.db.table("learning_states")}
|
||||
WHERE session_id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[sessionId],
|
||||
);
|
||||
return (
|
||||
mapLearningStateRow(result.rows[0]) ?? {
|
||||
lastGatedTurn: 0,
|
||||
lastReviewedTurn: 0,
|
||||
pendingReview: false,
|
||||
sessionId,
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
}
|
||||
);
|
||||
const existing = await readJsonFile<LearningSessionState>(this.filePath(sessionId));
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
lastGatedTurn: 0,
|
||||
lastReviewedTurn: 0,
|
||||
pendingReview: false,
|
||||
sessionId,
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async write(state: LearningSessionState) {
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO ${this.db.table("learning_states")} (
|
||||
session_id,
|
||||
last_gated_turn,
|
||||
last_reviewed_turn,
|
||||
pending_review
|
||||
)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (session_id)
|
||||
DO UPDATE SET
|
||||
last_gated_turn = EXCLUDED.last_gated_turn,
|
||||
last_reviewed_turn = EXCLUDED.last_reviewed_turn,
|
||||
pending_review = EXCLUDED.pending_review
|
||||
`,
|
||||
[
|
||||
state.sessionId,
|
||||
state.lastGatedTurn,
|
||||
state.lastReviewedTurn,
|
||||
state.pendingReview,
|
||||
],
|
||||
);
|
||||
await atomicWriteJson(this.filePath(state.sessionId), {
|
||||
...state,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async markPending(sessionId: string, pendingReview: boolean) {
|
||||
@@ -97,17 +69,8 @@ export class LearningStateStore {
|
||||
pendingReview: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mapLearningStateRow = (row?: LearningStateRow | null): LearningSessionState | null => {
|
||||
if (!row) {
|
||||
return null;
|
||||
private filePath(sessionId: string) {
|
||||
return join(this.baseDir, `${sessionId}.json`);
|
||||
}
|
||||
return {
|
||||
sessionId: row.session_id,
|
||||
lastGatedTurn: row.last_gated_turn,
|
||||
lastReviewedTurn: row.last_reviewed_turn,
|
||||
pendingReview: row.pending_review,
|
||||
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+141
-124
@@ -1,9 +1,13 @@
|
||||
import { type QueryResultRow } from "pg";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { sanitizePersistentLine } from "../utils/persistencePolicy.js";
|
||||
import { toStableId } from "../utils/fileStore.js";
|
||||
import {
|
||||
atomicWriteFileWithHistory,
|
||||
ensureDirectory,
|
||||
readTextFile,
|
||||
toStableId,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type MemoryScope = "user" | "workspace";
|
||||
export type MemoryEntrySource = "review" | "tool";
|
||||
@@ -25,17 +29,6 @@ type MemoryContext = {
|
||||
projectKey: string;
|
||||
};
|
||||
|
||||
type MemoryRow = QueryResultRow & {
|
||||
memory_id: string;
|
||||
scope: MemoryScope;
|
||||
scope_key: string;
|
||||
content: string;
|
||||
source: MemoryEntrySource;
|
||||
session_id: string | null;
|
||||
trace_id: string | null;
|
||||
updated_at: Date | string;
|
||||
};
|
||||
|
||||
const SUSPICIOUS_MEMORY_PATTERNS = [
|
||||
/ignore\s+(all|previous|prior|above)\s+instructions/i,
|
||||
/system\s+prompt/i,
|
||||
@@ -44,118 +37,113 @@ const SUSPICIOUS_MEMORY_PATTERNS = [
|
||||
];
|
||||
|
||||
export class MemoryStore {
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
// Memory 文件可能被多次连续追加,串行化可避免并发覆盖掉刚写入的条目。
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly baseDir = config.MEMORY_STORAGE_DIR,
|
||||
private readonly historyDir = join(config.PERSISTENCE_HISTORY_DIR, "memory"),
|
||||
) {}
|
||||
|
||||
async initialize() {
|
||||
await this.db.initialize();
|
||||
await ensureDirectory(this.baseDir);
|
||||
await ensureDirectory(join(this.baseDir, "users"));
|
||||
await ensureDirectory(join(this.baseDir, "workspaces"));
|
||||
// 历史备份与正式数据分目录存放,便于排查和手工恢复。
|
||||
await ensureDirectory(this.historyDir);
|
||||
}
|
||||
|
||||
async upsert(scope: MemoryScope, key: string, draft: MemoryDraft) {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, entry: null as MemoryEntry | null };
|
||||
}
|
||||
const existing = await this.findByContent(scope, key, content);
|
||||
if (existing) {
|
||||
return { changed: false, entry: existing };
|
||||
}
|
||||
const id = toStableId(scope, key, content.toLowerCase());
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO ${this.db.table("memories")} (
|
||||
memory_id,
|
||||
scope,
|
||||
scope_key,
|
||||
content,
|
||||
source,
|
||||
session_id,
|
||||
trace_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
[id, scope, key, content, draft.source, draft.sessionId ?? null, draft.traceId ?? null],
|
||||
);
|
||||
return {
|
||||
changed: true,
|
||||
entry: {
|
||||
id,
|
||||
return this.serializeWrite(async () => {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, entry: null as MemoryEntry | null };
|
||||
}
|
||||
|
||||
const entries = await this.readEntries(scope, key);
|
||||
const existing = entries.find((entry) => entry.content === content);
|
||||
if (existing) {
|
||||
return { changed: false, entry: existing };
|
||||
}
|
||||
|
||||
const entry: MemoryEntry = {
|
||||
content,
|
||||
},
|
||||
};
|
||||
id: toStableId(scope, key, content.toLowerCase()),
|
||||
};
|
||||
entries.unshift(entry);
|
||||
// 每次覆盖 memory 文件前先保留上一版,写入失败时由底层工具恢复。
|
||||
await atomicWriteFileWithHistory(
|
||||
this.filePath(scope, key),
|
||||
renderMemoryMarkdown(scope, entries),
|
||||
{
|
||||
historyDir: this.historyDir,
|
||||
rootDir: this.baseDir,
|
||||
},
|
||||
);
|
||||
return { changed: true, entry };
|
||||
});
|
||||
}
|
||||
|
||||
async list(scope: MemoryScope, key: string) {
|
||||
const rows = await this.db.query<MemoryRow>(
|
||||
`
|
||||
SELECT memory_id, content
|
||||
FROM ${this.db.table("memories")}
|
||||
WHERE scope = $1
|
||||
AND scope_key = $2
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
`,
|
||||
[scope, key],
|
||||
);
|
||||
return rows.rows.map((row) => ({
|
||||
id: row.memory_id,
|
||||
content: row.content,
|
||||
}));
|
||||
return await this.readEntries(scope, key);
|
||||
}
|
||||
|
||||
async replace(scope: MemoryScope, key: string, targetId: string, draft: MemoryDraft) {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, detail: "content rejected by persistence policy" };
|
||||
}
|
||||
const duplicate = await this.findByContent(scope, key, content);
|
||||
if (duplicate && duplicate.id !== targetId.trim()) {
|
||||
return { changed: false, detail: "replacement would duplicate an existing memory" };
|
||||
}
|
||||
const result = await this.db.query(
|
||||
`
|
||||
UPDATE ${this.db.table("memories")}
|
||||
SET
|
||||
content = $4,
|
||||
source = $5,
|
||||
session_id = $6,
|
||||
trace_id = $7
|
||||
WHERE memory_id = $1
|
||||
AND scope = $2
|
||||
AND scope_key = $3
|
||||
`,
|
||||
[
|
||||
targetId.trim(),
|
||||
scope,
|
||||
key,
|
||||
return this.serializeWrite(async () => {
|
||||
const content = normalizeMemoryContent(draft.content);
|
||||
if (!content) {
|
||||
return { changed: false, detail: "content rejected by persistence policy" };
|
||||
}
|
||||
const entries = await this.readEntries(scope, key);
|
||||
const index = entries.findIndex((entry) => entry.id === targetId.trim());
|
||||
if (index === -1) {
|
||||
return { changed: false, detail: "memory entry not found" };
|
||||
}
|
||||
const duplicate = entries.find(
|
||||
(entry, currentIndex) => currentIndex !== index && entry.content === content,
|
||||
);
|
||||
if (duplicate) {
|
||||
return { changed: false, detail: "replacement would duplicate an existing memory" };
|
||||
}
|
||||
entries[index] = {
|
||||
content,
|
||||
draft.source,
|
||||
draft.sessionId ?? null,
|
||||
draft.traceId ?? null,
|
||||
],
|
||||
);
|
||||
return result.rowCount === 0
|
||||
? { changed: false, detail: "memory entry not found" }
|
||||
: { changed: true, detail: "memory replaced" };
|
||||
id: entries[index]?.id ?? toStableId(scope, key, content.toLowerCase()),
|
||||
};
|
||||
await atomicWriteFileWithHistory(
|
||||
this.filePath(scope, key),
|
||||
renderMemoryMarkdown(scope, entries),
|
||||
{
|
||||
historyDir: this.historyDir,
|
||||
rootDir: this.baseDir,
|
||||
},
|
||||
);
|
||||
return { changed: true, detail: "memory replaced" };
|
||||
});
|
||||
}
|
||||
|
||||
async remove(scope: MemoryScope, key: string, targetId: string) {
|
||||
const result = await this.db.query(
|
||||
`
|
||||
DELETE FROM ${this.db.table("memories")}
|
||||
WHERE memory_id = $1
|
||||
AND scope = $2
|
||||
AND scope_key = $3
|
||||
`,
|
||||
[targetId.trim(), scope, key],
|
||||
);
|
||||
return result.rowCount === 0
|
||||
? { changed: false, detail: "memory entry not found" }
|
||||
: { changed: true, detail: "memory removed" };
|
||||
return this.serializeWrite(async () => {
|
||||
const entries = await this.readEntries(scope, key);
|
||||
const next = entries.filter((entry) => entry.id !== targetId.trim());
|
||||
if (next.length === entries.length) {
|
||||
return { changed: false, detail: "memory entry not found" };
|
||||
}
|
||||
await atomicWriteFileWithHistory(
|
||||
this.filePath(scope, key),
|
||||
renderMemoryMarkdown(scope, next),
|
||||
{
|
||||
historyDir: this.historyDir,
|
||||
rootDir: this.baseDir,
|
||||
},
|
||||
);
|
||||
return { changed: true, detail: "memory removed" };
|
||||
});
|
||||
}
|
||||
|
||||
async buildPromptSnapshot(context: MemoryContext) {
|
||||
const [userMemory, workspaceMemory] = await Promise.all([
|
||||
this.list("user", context.actorKey),
|
||||
this.list("workspace", context.projectKey),
|
||||
this.readEntries("user", context.actorKey),
|
||||
this.readEntries("workspace", context.projectKey),
|
||||
]);
|
||||
|
||||
const sections: string[] = [];
|
||||
@@ -192,25 +180,26 @@ export class MemoryStore {
|
||||
: block;
|
||||
}
|
||||
|
||||
private async findByContent(scope: MemoryScope, key: string, content: string) {
|
||||
const result = await this.db.query<MemoryRow>(
|
||||
`
|
||||
SELECT memory_id, content
|
||||
FROM ${this.db.table("memories")}
|
||||
WHERE scope = $1
|
||||
AND scope_key = $2
|
||||
AND content = $3
|
||||
LIMIT 1
|
||||
`,
|
||||
[scope, key, content],
|
||||
private async readEntries(scope: MemoryScope, key: string) {
|
||||
const markdown = await readTextFile(this.filePath(scope, key));
|
||||
if (!markdown) {
|
||||
return [];
|
||||
}
|
||||
return parseMemoryMarkdown(markdown);
|
||||
}
|
||||
|
||||
private filePath(scope: MemoryScope, key: string) {
|
||||
const dir = scope === "user" ? "users" : "workspaces";
|
||||
return join(this.baseDir, dir, `${key}.md`);
|
||||
}
|
||||
|
||||
private async serializeWrite<T>(task: () => Promise<T>) {
|
||||
const run = this.writeQueue.catch(() => undefined).then(task);
|
||||
this.writeQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row
|
||||
? {
|
||||
id: row.memory_id,
|
||||
content: row.content,
|
||||
}
|
||||
: null;
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +213,31 @@ const normalizeMemoryContent = (content: string) => {
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const parseMemoryMarkdown = (content: string): MemoryEntry[] =>
|
||||
content
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("- "))
|
||||
.map((line) => line.slice(2).trim())
|
||||
.map((line) => {
|
||||
const match = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i);
|
||||
if (match) {
|
||||
return {
|
||||
content: normalizeMemoryContent(match[2]),
|
||||
id: match[1],
|
||||
};
|
||||
}
|
||||
const normalized = normalizeMemoryContent(line);
|
||||
return {
|
||||
content: normalized,
|
||||
id: normalized ? toStableId("memory-entry", normalized.toLowerCase()) : "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.content);
|
||||
|
||||
const renderMemoryMarkdown = (scope: MemoryScope, entries: MemoryEntry[]) => {
|
||||
const title = scope === "user" ? "# User Memory" : "# Workspace Memory";
|
||||
const bullets = entries.map((entry) => `- [${entry.id}] ${entry.content}`);
|
||||
return [title, "", ...bullets, ""].join("\n");
|
||||
};
|
||||
|
||||
@@ -16,9 +16,9 @@ type ResolveOptions = {
|
||||
|
||||
type RegisterResultReferenceInput = {
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
payloadPath?: string;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
schemaVersion: number;
|
||||
@@ -47,9 +47,9 @@ export class ResultReferenceResolver {
|
||||
}
|
||||
return this.store.store({
|
||||
actorKey: input.actorKey,
|
||||
clientSessionId: input.clientSessionId,
|
||||
data: normalizedData,
|
||||
kind: input.kind,
|
||||
payloadPath: input.payloadPath,
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: input.schemaVersion,
|
||||
@@ -82,16 +82,10 @@ export class ResultReferenceResolver {
|
||||
}
|
||||
|
||||
return this.register({
|
||||
actorKey: input.actorKey,
|
||||
...input,
|
||||
data: payload,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
payloadPath: filePath,
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: 1,
|
||||
sessionId: input.sessionId,
|
||||
source: input.source,
|
||||
traceId: input.traceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+209
-212
@@ -1,19 +1,20 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { type QueryResultRow } from "pg";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
getFileStat,
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
toProjectKey,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
||||
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||
|
||||
export const RESULT_REFERENCE_KIND = {
|
||||
dynamicHttpResult: "dynamic-http-result",
|
||||
@@ -43,6 +44,7 @@ export type ResultPreview = {
|
||||
export type ResultReferenceRecord = {
|
||||
resultRef: string;
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
createdAt: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
@@ -54,12 +56,11 @@ export type ResultReferenceRecord = {
|
||||
sizeBytes: number;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
payloadPath?: string;
|
||||
objectKey?: string;
|
||||
};
|
||||
|
||||
export type StoreResultInput = {
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
data: unknown;
|
||||
kind: ResultReferenceKind;
|
||||
projectId?: string;
|
||||
@@ -68,13 +69,11 @@ export type StoreResultInput = {
|
||||
sessionId: string;
|
||||
source: ResultReferenceSource;
|
||||
traceId: string;
|
||||
payloadPath?: string;
|
||||
objectKey?: string;
|
||||
};
|
||||
|
||||
export type RetrievalContext = {
|
||||
actorKey: string;
|
||||
sessionId?: string;
|
||||
clientSessionId?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
@@ -85,37 +84,18 @@ export type ResultReferencePeek = {
|
||||
storedAt: string;
|
||||
};
|
||||
|
||||
type ResultReferenceRow = QueryResultRow & {
|
||||
result_ref: string;
|
||||
actor_key: string;
|
||||
session_id: string;
|
||||
created_at: Date | string;
|
||||
kind: ResultReferenceKind;
|
||||
preview: ResultPreview;
|
||||
project_id: string | null;
|
||||
project_key: string;
|
||||
schema_version: number;
|
||||
size_bytes: number;
|
||||
source: ResultReferenceSource;
|
||||
trace_id: string;
|
||||
payload_path: string | null;
|
||||
object_key: string | null;
|
||||
};
|
||||
type PartialRecord = Partial<ResultReferenceRecord> & { data?: unknown };
|
||||
|
||||
export class ResultReferenceStore {
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
private readonly managedPayloadDir: string;
|
||||
|
||||
constructor(
|
||||
private readonly db: AgentDatabase = getAgentDatabase(),
|
||||
private readonly baseDir = config.RESULT_REF_STORAGE_DIR,
|
||||
private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000,
|
||||
) {
|
||||
this.managedPayloadDir = join(this.baseDir, "payloads");
|
||||
}
|
||||
) {}
|
||||
|
||||
async initialize() {
|
||||
await Promise.all([this.db.initialize(), ensureDirectory(this.managedPayloadDir)]);
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
startCleanupLoop() {
|
||||
@@ -139,77 +119,24 @@ export class ResultReferenceStore {
|
||||
|
||||
async store(input: StoreResultInput) {
|
||||
const resultRef = `res-${randomUUID().slice(0, 16)}`;
|
||||
const createdAt = new Date().toISOString();
|
||||
const payloadPath =
|
||||
input.payloadPath ??
|
||||
(input.objectKey
|
||||
? undefined
|
||||
: this.managedPayloadPath(resultRef));
|
||||
|
||||
if (!payloadPath && !input.objectKey) {
|
||||
throw new Error("result ref requires payloadPath or objectKey");
|
||||
}
|
||||
if (!input.payloadPath && payloadPath) {
|
||||
await atomicWriteJson(payloadPath, wrapPayload(input.data, createdAt, input.projectId));
|
||||
}
|
||||
|
||||
const preview = buildPreview(input.data);
|
||||
const sizeBytes = estimateBytes(input.data);
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO ${this.db.table("result_refs")} (
|
||||
result_ref,
|
||||
actor_key,
|
||||
session_id,
|
||||
created_at,
|
||||
kind,
|
||||
preview,
|
||||
project_id,
|
||||
project_key,
|
||||
schema_version,
|
||||
size_bytes,
|
||||
source,
|
||||
trace_id,
|
||||
payload_path,
|
||||
object_key
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
`,
|
||||
[
|
||||
resultRef,
|
||||
input.actorKey,
|
||||
input.sessionId,
|
||||
createdAt,
|
||||
input.kind,
|
||||
JSON.stringify(preview),
|
||||
input.projectId ?? null,
|
||||
input.projectKey,
|
||||
input.schemaVersion,
|
||||
sizeBytes,
|
||||
input.source,
|
||||
input.traceId,
|
||||
payloadPath ?? null,
|
||||
input.objectKey ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
const record: ResultReferenceRecord = {
|
||||
resultRef,
|
||||
actorKey: input.actorKey,
|
||||
createdAt,
|
||||
clientSessionId: input.clientSessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: input.data,
|
||||
kind: input.kind,
|
||||
preview,
|
||||
preview: buildPreview(input.data),
|
||||
projectId: input.projectId,
|
||||
projectKey: input.projectKey,
|
||||
schemaVersion: input.schemaVersion,
|
||||
sessionId: input.sessionId,
|
||||
sizeBytes,
|
||||
sizeBytes: estimateBytes(input.data),
|
||||
source: input.source,
|
||||
traceId: input.traceId,
|
||||
payloadPath,
|
||||
objectKey: input.objectKey,
|
||||
} satisfies ResultReferenceRecord;
|
||||
};
|
||||
await atomicWriteJson(this.filePath(resultRef), record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||
@@ -218,49 +145,26 @@ export class ResultReferenceStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await this.db.query<ResultReferenceRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("result_refs")}
|
||||
WHERE result_ref = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[normalizedResultRef],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
|
||||
const record =
|
||||
normalizeResultReferenceRecord(rawRecord) ??
|
||||
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
if (row.actor_key !== context.actorKey) {
|
||||
if (record.actorKey !== context.actorKey) {
|
||||
return null;
|
||||
}
|
||||
if ((row.project_id ?? "") !== (context.projectId ?? "")) {
|
||||
if ((record.projectId ?? "") !== (context.projectId ?? "")) {
|
||||
return null;
|
||||
}
|
||||
if (context.sessionId && row.session_id !== context.sessionId) {
|
||||
if (
|
||||
context.clientSessionId &&
|
||||
record.clientSessionId !== context.clientSessionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const data = await this.readPayload(row);
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
resultRef: row.result_ref,
|
||||
actorKey: row.actor_key,
|
||||
createdAt: toIsoString(row.created_at),
|
||||
data,
|
||||
kind: row.kind,
|
||||
preview: row.preview,
|
||||
projectId: row.project_id ?? undefined,
|
||||
projectKey: row.project_key,
|
||||
schemaVersion: row.schema_version,
|
||||
sessionId: row.session_id,
|
||||
sizeBytes: row.size_bytes,
|
||||
source: row.source,
|
||||
traceId: row.trace_id,
|
||||
payloadPath: row.payload_path ?? undefined,
|
||||
objectKey: row.object_key ?? undefined,
|
||||
} satisfies ResultReferenceRecord;
|
||||
return record;
|
||||
}
|
||||
|
||||
async peekAuthorized(
|
||||
@@ -280,82 +184,196 @@ export class ResultReferenceStore {
|
||||
}
|
||||
|
||||
async listBySession(sessionId: string) {
|
||||
const result = await this.db.query<ResultReferenceRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("result_refs")}
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
[sessionId],
|
||||
);
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(
|
||||
result.rows.map(async (row) => {
|
||||
const data = await this.readPayload(row);
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
resultRef: row.result_ref,
|
||||
actorKey: row.actor_key,
|
||||
createdAt: toIsoString(row.created_at),
|
||||
data,
|
||||
kind: row.kind,
|
||||
preview: row.preview,
|
||||
projectId: row.project_id ?? undefined,
|
||||
projectKey: row.project_key,
|
||||
schemaVersion: row.schema_version,
|
||||
sessionId: row.session_id,
|
||||
sizeBytes: row.size_bytes,
|
||||
source: row.source,
|
||||
traceId: row.trace_id,
|
||||
payloadPath: row.payload_path ?? undefined,
|
||||
objectKey: row.object_key ?? undefined,
|
||||
} satisfies ResultReferenceRecord;
|
||||
}),
|
||||
files.map(async (filePath) =>
|
||||
normalizeResultReferenceRecord(await readJsonFile<unknown>(filePath)),
|
||||
),
|
||||
);
|
||||
return records.filter(Boolean) as ResultReferenceRecord[];
|
||||
return records
|
||||
.filter((record): record is ResultReferenceRecord => Boolean(record))
|
||||
.filter((record) => record.sessionId === sessionId)
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||
}
|
||||
|
||||
async cleanupExpired() {
|
||||
const result = await this.db.query<ResultReferenceRow>(
|
||||
`
|
||||
DELETE FROM ${this.db.table("result_refs")}
|
||||
WHERE created_at < NOW() - ($1 * INTERVAL '1 millisecond')
|
||||
RETURNING *
|
||||
`,
|
||||
[this.ttlMs],
|
||||
);
|
||||
await Promise.all(
|
||||
result.rows.map(async (row) => {
|
||||
if (row.payload_path && isManagedPayloadPath(row.payload_path, this.managedPayloadDir)) {
|
||||
await removeFileIfExists(row.payload_path);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const now = Date.now();
|
||||
for (const filePath of files) {
|
||||
const stats = await getFileStat(filePath);
|
||||
if (!stats) {
|
||||
continue;
|
||||
}
|
||||
if (now - stats.mtimeMs > this.ttlMs) {
|
||||
await removeFileIfExists(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private managedPayloadPath(resultRef: string) {
|
||||
return join(this.managedPayloadDir, `${resultRef}.json`);
|
||||
}
|
||||
|
||||
private async readPayload(row: ResultReferenceRow) {
|
||||
if (row.payload_path) {
|
||||
return unwrapStoredPayload(await readJsonFile<unknown>(row.payload_path));
|
||||
}
|
||||
if (row.object_key) {
|
||||
logger.warn({ resultRef: row.result_ref, objectKey: row.object_key }, "object storage payloads are not implemented");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
private filePath(resultRef: string) {
|
||||
return join(this.baseDir, `${resultRef}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeResultRef = (value: string) => {
|
||||
const normalized = value.trim();
|
||||
return RESULT_REF_PATTERN.test(normalized) ? normalized : null;
|
||||
export const normalizeResultReferenceRecord = (
|
||||
value: unknown,
|
||||
): ResultReferenceRecord | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const partial = value as PartialRecord;
|
||||
if (
|
||||
!isValidResultRef(partial.resultRef) ||
|
||||
typeof partial.actorKey !== "string" ||
|
||||
typeof partial.clientSessionId !== "string" ||
|
||||
typeof partial.createdAt !== "string" ||
|
||||
!("data" in partial) ||
|
||||
!isResultPreview(partial.preview) ||
|
||||
typeof partial.projectKey !== "string" ||
|
||||
typeof partial.sessionId !== "string" ||
|
||||
typeof partial.sizeBytes !== "number" ||
|
||||
!Number.isFinite(partial.sizeBytes) ||
|
||||
typeof partial.traceId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = normalizeResultReferenceKind(partial.kind);
|
||||
const source = normalizeResultReferenceSource(partial.source);
|
||||
const schemaVersion =
|
||||
typeof partial.schemaVersion === "number" &&
|
||||
Number.isInteger(partial.schemaVersion) &&
|
||||
partial.schemaVersion > 0
|
||||
? partial.schemaVersion
|
||||
: 1;
|
||||
|
||||
if (!kind || !source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
partial.projectId !== undefined &&
|
||||
typeof partial.projectId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resultRef: partial.resultRef,
|
||||
actorKey: partial.actorKey,
|
||||
clientSessionId: partial.clientSessionId,
|
||||
createdAt: partial.createdAt,
|
||||
data: partial.data,
|
||||
kind,
|
||||
preview: partial.preview,
|
||||
projectId: partial.projectId,
|
||||
projectKey: partial.projectKey,
|
||||
schemaVersion,
|
||||
sessionId: partial.sessionId,
|
||||
sizeBytes: partial.sizeBytes,
|
||||
source,
|
||||
traceId: partial.traceId,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeResultReferenceKind = (
|
||||
value: unknown,
|
||||
): ResultReferenceKind | null => {
|
||||
if (value === undefined) {
|
||||
return RESULT_REFERENCE_KIND.dynamicHttpResult;
|
||||
}
|
||||
return Object.values(RESULT_REFERENCE_KIND).includes(
|
||||
value as ResultReferenceKind,
|
||||
)
|
||||
? (value as ResultReferenceKind)
|
||||
: null;
|
||||
};
|
||||
|
||||
const normalizeResultReferenceSource = (
|
||||
value: unknown,
|
||||
): ResultReferenceSource | null => {
|
||||
if (value === undefined) {
|
||||
return RESULT_REFERENCE_SOURCE.legacy;
|
||||
}
|
||||
return Object.values(RESULT_REFERENCE_SOURCE).includes(
|
||||
value as ResultReferenceSource,
|
||||
)
|
||||
? (value as ResultReferenceSource)
|
||||
: null;
|
||||
};
|
||||
|
||||
const isValidResultRef = (value: unknown): value is string =>
|
||||
typeof value === "string" && RESULT_REF_PATTERN.test(value);
|
||||
|
||||
const normalizeResultRef = (value: string) => {
|
||||
const match = value.trim().match(RESULT_REF_FILE_PATTERN);
|
||||
return match?.[1] ?? null;
|
||||
};
|
||||
|
||||
const normalizeLegacyRenderReferenceRecord = (
|
||||
value: unknown,
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
): ResultReferenceRecord | null => {
|
||||
const data = extractLegacyRenderPayload(value);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = isRecord(value) ? value : {};
|
||||
const metadata = isRecord(root.metadata) ? root.metadata : {};
|
||||
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
|
||||
const createdAt =
|
||||
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
|
||||
|
||||
return {
|
||||
resultRef,
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId ?? "",
|
||||
createdAt,
|
||||
data,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
preview: buildPreview(data),
|
||||
projectId,
|
||||
projectKey: toProjectKey(projectId),
|
||||
schemaVersion: 1,
|
||||
sessionId: context.clientSessionId ?? resultRef,
|
||||
sizeBytes: estimateBytes(data),
|
||||
source: RESULT_REFERENCE_SOURCE.legacy,
|
||||
traceId: "legacy-render-ref",
|
||||
};
|
||||
};
|
||||
|
||||
const extractLegacyRenderPayload = (value: unknown) => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = isRecord(value.data) ? value.data : value;
|
||||
if (!isRecord(candidate.node_area_map)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const firstNonEmptyString = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isResultPreview = (value: unknown): value is ResultPreview =>
|
||||
isRecord(value) &&
|
||||
typeof value.count === "number" &&
|
||||
Number.isFinite(value.count) &&
|
||||
Array.isArray(value.fields) &&
|
||||
value.fields.every((field) => typeof field === "string") &&
|
||||
typeof value.summary === "string" &&
|
||||
"sample" in value;
|
||||
|
||||
const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data));
|
||||
|
||||
const buildPreview = (data: unknown): ResultPreview => {
|
||||
@@ -396,26 +414,5 @@ const buildPreview = (data: unknown): ResultPreview => {
|
||||
};
|
||||
};
|
||||
|
||||
const wrapPayload = (data: unknown, createdAt: string, projectId?: string) => ({
|
||||
metadata: {
|
||||
createdAt,
|
||||
...(projectId ? { projectId } : {}),
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const unwrapStoredPayload = (value: unknown) => {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
return "data" in value ? value.data : value;
|
||||
};
|
||||
|
||||
const toIsoString = (value: Date | string) =>
|
||||
value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
|
||||
const isManagedPayloadPath = (payloadPath: string, managedPayloadDir: string) =>
|
||||
resolve(payloadPath).startsWith(resolve(managedPayloadDir));
|
||||
|
||||
+38
-48
@@ -91,8 +91,6 @@ export const buildChatRouter = (
|
||||
session_id: record.sessionId,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
is_streaming: record.isStreaming,
|
||||
streaming_started_at: record.streamingStartedAt,
|
||||
status: record.status,
|
||||
title: record.title,
|
||||
parent_session_id: record.parentSessionId,
|
||||
@@ -116,8 +114,6 @@ export const buildChatRouter = (
|
||||
title: record.title ?? "新对话",
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
is_streaming: record.isStreaming,
|
||||
streaming_started_at: record.streamingStartedAt,
|
||||
status: record.status,
|
||||
parent_session_id: record.parentSessionId,
|
||||
})),
|
||||
@@ -149,15 +145,13 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await conversationStateStore.read(conversation.sessionId);
|
||||
const state = await conversationStateStore.read(conversation.sessionScopeKey);
|
||||
res.json({
|
||||
id: conversation.sessionId,
|
||||
title: conversation.title ?? "新对话",
|
||||
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||
created_at: conversation.createdAt,
|
||||
updated_at: conversation.updatedAt,
|
||||
is_streaming: conversation.isStreaming,
|
||||
streaming_started_at: conversation.streamingStartedAt,
|
||||
status: conversation.status,
|
||||
session_id: conversation.sessionId,
|
||||
messages: state?.messages ?? [],
|
||||
@@ -196,7 +190,7 @@ export const buildChatRouter = (
|
||||
const nextRecord = await conversationStore.touch(record, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
});
|
||||
await conversationStateStore.write(nextRecord.sessionId, {
|
||||
await conversationStateStore.write(nextRecord.sessionScopeKey, {
|
||||
sessionId: nextRecord.sessionId,
|
||||
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
|
||||
messages: parsed.data.messages,
|
||||
@@ -207,8 +201,6 @@ export const buildChatRouter = (
|
||||
title: nextRecord.title ?? "新对话",
|
||||
created_at: nextRecord.createdAt,
|
||||
updated_at: nextRecord.updatedAt,
|
||||
is_streaming: nextRecord.isStreaming,
|
||||
streaming_started_at: nextRecord.streamingStartedAt,
|
||||
status: nextRecord.status,
|
||||
session_id: nextRecord.sessionId,
|
||||
});
|
||||
@@ -239,9 +231,9 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
const nextConversation = await conversationStore.touch(conversation, { title });
|
||||
const state = await conversationStateStore.read(nextConversation.sessionId);
|
||||
const state = await conversationStateStore.read(nextConversation.sessionScopeKey);
|
||||
if (state) {
|
||||
await conversationStateStore.write(nextConversation.sessionId, {
|
||||
await conversationStateStore.write(nextConversation.sessionScopeKey, {
|
||||
...state,
|
||||
isTitleManuallyEdited:
|
||||
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
|
||||
@@ -272,7 +264,7 @@ export const buildChatRouter = (
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
await conversationStateStore.remove(conversation.sessionId);
|
||||
await conversationStateStore.remove(conversation.sessionScopeKey);
|
||||
await conversationStore.remove(conversation);
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -281,7 +273,7 @@ export const buildChatRouter = (
|
||||
const renderRef = req.params.renderRef?.trim();
|
||||
const userId = req.header("x-user-id")?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const sessionId =
|
||||
const clientSessionId =
|
||||
typeof req.query.session_id === "string"
|
||||
? req.query.session_id.trim()
|
||||
: undefined;
|
||||
@@ -304,7 +296,7 @@ export const buildChatRouter = (
|
||||
renderRef,
|
||||
{
|
||||
actorKey: toActorKey(userId),
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
@@ -332,7 +324,7 @@ export const buildChatRouter = (
|
||||
|
||||
try {
|
||||
const binding = await sessionBridge.abort({
|
||||
sessionId: parsed.data.session_id,
|
||||
clientSessionId: parsed.data.session_id,
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
@@ -342,8 +334,8 @@ export const buildChatRouter = (
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sessionId: parsed.data.session_id,
|
||||
runtimeSessionId: binding.runtimeSessionId,
|
||||
clientSessionId: parsed.data.session_id,
|
||||
sessionId: binding.sessionId,
|
||||
},
|
||||
"aborted chat session by client request",
|
||||
);
|
||||
@@ -378,8 +370,8 @@ export const buildChatRouter = (
|
||||
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sourceSessionId = parsed.data.session_id?.trim();
|
||||
const sourceConversation = sourceSessionId
|
||||
const sourceClientSessionId = parsed.data.session_id?.trim();
|
||||
const sourceConversation = sourceClientSessionId
|
||||
? await conversationStore.get(
|
||||
{
|
||||
actorKey,
|
||||
@@ -387,29 +379,31 @@ export const buildChatRouter = (
|
||||
projectKey,
|
||||
userId,
|
||||
},
|
||||
sourceSessionId,
|
||||
sourceClientSessionId,
|
||||
)
|
||||
: null;
|
||||
const { record: targetConversation } = await conversationStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: sourceSessionId,
|
||||
parentSessionId: sourceClientSessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
userId,
|
||||
});
|
||||
const nextSessionId = targetConversation.sessionId;
|
||||
const nextClientSessionId = targetConversation.sessionId;
|
||||
|
||||
if (sourceSessionId && parsed.data.keep_message_count > 0) {
|
||||
if (sourceClientSessionId && parsed.data.keep_message_count > 0) {
|
||||
await sessionHistoryStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceClientSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceSessionId,
|
||||
sessionId: sourceClientSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextClientSessionId,
|
||||
projectKey,
|
||||
sessionId: nextSessionId,
|
||||
sessionId: nextClientSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
@@ -422,8 +416,8 @@ export const buildChatRouter = (
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sourceSessionId: parsed.data.session_id,
|
||||
sessionId: nextSessionId,
|
||||
sourceClientSessionId: parsed.data.session_id,
|
||||
clientSessionId: nextClientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
@@ -432,7 +426,7 @@ export const buildChatRouter = (
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
session_id: nextSessionId,
|
||||
session_id: nextClientSessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
@@ -475,7 +469,7 @@ export const buildChatRouter = (
|
||||
const activeConversation = await conversationStore.touch(conversation);
|
||||
|
||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||
sessionId: activeConversation.sessionId,
|
||||
clientSessionId: activeConversation.sessionId,
|
||||
accessToken,
|
||||
projectId,
|
||||
traceId,
|
||||
@@ -483,16 +477,16 @@ export const buildChatRouter = (
|
||||
});
|
||||
const historyContext = {
|
||||
actorKey: requestContext.actorKey,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: requestContext.sessionId,
|
||||
sessionId: requestContext.clientSessionId,
|
||||
};
|
||||
await conversationStore.markStreaming(activeConversation, binding.runtimeSessionId);
|
||||
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sessionId: requestContext.sessionId,
|
||||
runtimeSessionId: binding.runtimeSessionId,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: binding.sessionId,
|
||||
created: created || conversationCreated,
|
||||
model: parsed.data.model,
|
||||
traceId: requestContext.traceId,
|
||||
@@ -508,7 +502,7 @@ export const buildChatRouter = (
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sessionId = requestContext.sessionId;
|
||||
const clientSessionId = requestContext.clientSessionId;
|
||||
let streamClosed = false;
|
||||
const abortController = new AbortController();
|
||||
const handleClientClose = () => {
|
||||
@@ -531,8 +525,8 @@ export const buildChatRouter = (
|
||||
);
|
||||
const streamResult = await streamPromptResponse({
|
||||
runtime,
|
||||
opencodeSessionId: binding.runtimeSessionId,
|
||||
sessionId,
|
||||
opencodeSessionId: binding.sessionId,
|
||||
clientSessionId,
|
||||
message: preparedMessage,
|
||||
model: parsed.data.model,
|
||||
traceId: requestContext.traceId,
|
||||
@@ -547,7 +541,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
|
||||
if (!streamResult.aborted && !streamResult.failed) {
|
||||
const messages = await runtime.messages(binding.runtimeSessionId, 60);
|
||||
const messages = await runtime.messages(binding.sessionId, 60);
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
@@ -558,7 +552,7 @@ export const buildChatRouter = (
|
||||
activeConversation.sessionId,
|
||||
)) ?? activeConversation;
|
||||
const latestConversationState = await conversationStateStore.read(
|
||||
latestConversation.sessionId,
|
||||
latestConversation.sessionScopeKey,
|
||||
);
|
||||
const existingSessionTitle = latestConversation.title;
|
||||
let sessionTitle = existingSessionTitle;
|
||||
@@ -569,7 +563,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
if (shouldGenerateTitle) {
|
||||
sessionTitle = await generateSessionTitle(runtime, {
|
||||
sessionId: binding.runtimeSessionId,
|
||||
sessionId: binding.sessionId,
|
||||
latestAssistantMessage: assistantText,
|
||||
latestUserMessage: parsed.data.message,
|
||||
fallbackTitle: existingSessionTitle,
|
||||
@@ -588,7 +582,7 @@ export const buildChatRouter = (
|
||||
) {
|
||||
res.write(
|
||||
toSse("session_title", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
title: sessionTitle,
|
||||
}),
|
||||
);
|
||||
@@ -599,23 +593,19 @@ export const buildChatRouter = (
|
||||
assistantMessage: assistantText,
|
||||
model: parsed.data.model,
|
||||
requestContext,
|
||||
sessionId,
|
||||
sessionId: clientSessionId,
|
||||
toolCallCount: streamResult.toolCallCount,
|
||||
userMessage: parsed.data.message,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId },
|
||||
{ err: error, sessionId: clientSessionId },
|
||||
"post-turn learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await sessionBridge.releaseRuntimeSession(
|
||||
sessionId,
|
||||
binding.runtimeSessionId,
|
||||
);
|
||||
await conversationStore.clearStreaming(sessionId, binding.runtimeSessionId);
|
||||
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId);
|
||||
streamClosed = true;
|
||||
req.off("close", handleClientClose);
|
||||
res.off("close", handleClientClose);
|
||||
|
||||
+16
-14
@@ -14,7 +14,7 @@ export type SupportedModel = (typeof supportedModels)[number];
|
||||
type StreamPromptOptions = {
|
||||
runtime: OpencodeRuntimeAdapter;
|
||||
opencodeSessionId: string;
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
message: string;
|
||||
model?: SupportedModel;
|
||||
traceId?: string;
|
||||
@@ -169,7 +169,7 @@ export const collectTextContent = (parts: Part[]) =>
|
||||
const emitFallbackMessage = async (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
opencodeSessionId: string,
|
||||
sessionId: string,
|
||||
clientSessionId: string,
|
||||
write: (event: string, data: Record<string, unknown>) => void,
|
||||
) => {
|
||||
const messages = await runtime.messages(opencodeSessionId);
|
||||
@@ -180,7 +180,7 @@ const emitFallbackMessage = async (
|
||||
const text = collectTextContent(parts);
|
||||
if (text) {
|
||||
write("token", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
@@ -294,7 +294,7 @@ const getToolProgressTitle = (tool: string, status: string) => {
|
||||
export const streamPromptResponse = async ({
|
||||
runtime,
|
||||
opencodeSessionId,
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
message,
|
||||
model,
|
||||
traceId,
|
||||
@@ -333,7 +333,7 @@ export const streamPromptResponse = async ({
|
||||
let failed = false;
|
||||
const debugContext = {
|
||||
opencodeSessionId,
|
||||
sessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
model: model ?? null,
|
||||
@@ -369,7 +369,7 @@ export const streamPromptResponse = async ({
|
||||
|
||||
if (status === "running") {
|
||||
write("progress", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
@@ -385,7 +385,7 @@ export const streamPromptResponse = async ({
|
||||
finalizedProgressIds.add(id);
|
||||
progressStartedAtMap.delete(id);
|
||||
write("progress", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
id,
|
||||
phase,
|
||||
status,
|
||||
@@ -542,6 +542,7 @@ export const streamPromptResponse = async ({
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "skill",
|
||||
sessionId: opencodeSessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: name,
|
||||
@@ -567,7 +568,7 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
emittedText = true;
|
||||
write("token", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
content: event.properties.delta,
|
||||
});
|
||||
} else if (partType === "reasoning") {
|
||||
@@ -600,7 +601,7 @@ export const streamPromptResponse = async ({
|
||||
for (const content of pending) {
|
||||
emittedText = true;
|
||||
write("token", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
@@ -691,7 +692,7 @@ export const streamPromptResponse = async ({
|
||||
{
|
||||
tool: part.tool,
|
||||
sessionId: opencodeSessionId,
|
||||
requestSessionId: sessionId,
|
||||
clientSessionId,
|
||||
},
|
||||
"llm tool request missing reason",
|
||||
);
|
||||
@@ -699,6 +700,7 @@ export const streamPromptResponse = async ({
|
||||
void writeLlmRequestAuditLog({
|
||||
kind: "tool",
|
||||
sessionId: opencodeSessionId,
|
||||
clientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
target: part.tool,
|
||||
@@ -709,7 +711,7 @@ export const streamPromptResponse = async ({
|
||||
logger.warn({ err: error }, "failed to write tool audit log");
|
||||
});
|
||||
write("tool_call", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
tool: part.tool,
|
||||
params: toolParams,
|
||||
reason,
|
||||
@@ -744,7 +746,7 @@ export const streamPromptResponse = async ({
|
||||
: "opencode session error",
|
||||
});
|
||||
write("error", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
message: event.properties.error
|
||||
? getErrorMessage(event.properties.error)
|
||||
: "opencode session error",
|
||||
@@ -801,7 +803,7 @@ export const streamPromptResponse = async ({
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
await emitFallbackMessage(runtime, opencodeSessionId, sessionId, write);
|
||||
await emitFallbackMessage(runtime, opencodeSessionId, clientSessionId, write);
|
||||
}
|
||||
emitProgress({
|
||||
id: "request-received",
|
||||
@@ -820,7 +822,7 @@ export const streamPromptResponse = async ({
|
||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
||||
});
|
||||
write("done", {
|
||||
session_id: sessionId,
|
||||
session_id: clientSessionId,
|
||||
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
logDevelopmentDebug("chat stream completed", {
|
||||
|
||||
+28
-32
@@ -14,7 +14,7 @@ import { ResultReferenceResolver } from "./results/resolver.js";
|
||||
import { ResultReferenceStore } from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { RuntimeSessionStore } from "./session/runtimeSessionStore.js";
|
||||
import { ToolSessionContextStore } from "./session/toolContextStore.js";
|
||||
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
||||
|
||||
const app = express();
|
||||
@@ -23,7 +23,7 @@ const conversationStore = new ConversationStore();
|
||||
const conversationStateStore = new ConversationStateStore();
|
||||
const memoryStore = new MemoryStore();
|
||||
const sessionHistoryStore = new SessionHistoryStore();
|
||||
const runtimeSessionStore = new RuntimeSessionStore();
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const learningOrchestrator = new LearningOrchestrator(
|
||||
opencodeRuntime,
|
||||
memoryStore,
|
||||
@@ -65,26 +65,22 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
||||
const persistedContext = runtimeSessionId
|
||||
? await runtimeSessionStore.read(runtimeSessionId)
|
||||
: null;
|
||||
const runtimeContext = runtimeSessionId
|
||||
? sessionBridge.getActiveSensitiveContext(runtimeSessionId)
|
||||
: null;
|
||||
if (!persistedContext && !runtimeContext) {
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const threadContext = await toolContextStore.read(sessionScopeKey);
|
||||
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
|
||||
if (!threadContext && !runtimeContext) {
|
||||
res.status(404).json({
|
||||
message: "runtime or session context not found",
|
||||
detail: runtimeSessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const context = persistedContext;
|
||||
const context = runtimeContext ?? threadContext;
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "runtime or session context not found",
|
||||
detail: runtimeSessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -97,14 +93,14 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
path: req.body?.path,
|
||||
method: req.body?.method,
|
||||
arguments: req.body?.arguments,
|
||||
body: req.body?.body,
|
||||
},
|
||||
{
|
||||
accessToken: runtimeContext?.accessToken,
|
||||
actorKey: context.actorKey,
|
||||
sessionId: context.sessionId,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.clientSessionId,
|
||||
traceId: context.traceId,
|
||||
},
|
||||
);
|
||||
@@ -124,14 +120,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
|
||||
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: runtimeSessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +140,7 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
resultRef,
|
||||
{
|
||||
actorKey: context.actorKey,
|
||||
sessionId: context.sessionId,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
{
|
||||
@@ -167,14 +163,14 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
|
||||
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: runtimeSessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -186,9 +182,10 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
try {
|
||||
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId: context.sessionId,
|
||||
sessionId: context.clientSessionId,
|
||||
source: "migration",
|
||||
traceId: context.traceId,
|
||||
});
|
||||
@@ -216,14 +213,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSessionId =
|
||||
typeof req.body?.runtimeSessionId === "string" ? req.body.runtimeSessionId.trim() : "";
|
||||
const sessionScopeKey =
|
||||
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
|
||||
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
||||
const context = runtimeSessionId ? await runtimeSessionStore.read(runtimeSessionId) : null;
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: runtimeSessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -267,9 +264,8 @@ const bootstrap = async () => {
|
||||
memoryStore.initialize(),
|
||||
resultReferenceStore.initialize(),
|
||||
sessionHistoryStore.initialize(),
|
||||
runtimeSessionStore.initialize(),
|
||||
toolContextStore.initialize(),
|
||||
]);
|
||||
await conversationStore.resetStreamingSessions();
|
||||
resultReferenceStore.startCleanupLoop();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { type QueryResultRow } from "pg";
|
||||
|
||||
import { AgentDatabase, getAgentDatabase } from "../db/index.js";
|
||||
|
||||
export type RuntimeSessionContext = {
|
||||
runtimeSessionId: string;
|
||||
actorKey: string;
|
||||
allowLearningWrite?: boolean;
|
||||
sessionId: string;
|
||||
learningMode?: "interactive" | "review";
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
traceId: string;
|
||||
releasedAt?: string;
|
||||
};
|
||||
|
||||
type RuntimeSessionRow = QueryResultRow & {
|
||||
runtime_session_id: string;
|
||||
actor_key: string;
|
||||
allow_learning_write: boolean;
|
||||
session_id: string;
|
||||
learning_mode: "interactive" | "review" | null;
|
||||
project_id: string | null;
|
||||
project_key: string;
|
||||
trace_id: string;
|
||||
released_at: Date | string | null;
|
||||
};
|
||||
|
||||
export class RuntimeSessionStore {
|
||||
constructor(private readonly db: AgentDatabase = getAgentDatabase()) {}
|
||||
|
||||
async initialize() {
|
||||
await this.db.initialize();
|
||||
}
|
||||
|
||||
async write(context: RuntimeSessionContext) {
|
||||
const result = await this.db.query<RuntimeSessionRow>(
|
||||
`
|
||||
INSERT INTO ${this.db.table("runtime_sessions")} (
|
||||
runtime_session_id,
|
||||
actor_key,
|
||||
allow_learning_write,
|
||||
session_id,
|
||||
learning_mode,
|
||||
project_id,
|
||||
project_key,
|
||||
trace_id,
|
||||
released_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL)
|
||||
ON CONFLICT (runtime_session_id)
|
||||
DO UPDATE SET
|
||||
actor_key = EXCLUDED.actor_key,
|
||||
allow_learning_write = EXCLUDED.allow_learning_write,
|
||||
session_id = EXCLUDED.session_id,
|
||||
learning_mode = EXCLUDED.learning_mode,
|
||||
project_id = EXCLUDED.project_id,
|
||||
project_key = EXCLUDED.project_key,
|
||||
trace_id = EXCLUDED.trace_id,
|
||||
released_at = NULL
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
context.runtimeSessionId,
|
||||
context.actorKey,
|
||||
context.allowLearningWrite ?? false,
|
||||
context.sessionId,
|
||||
context.learningMode ?? null,
|
||||
context.projectId ?? null,
|
||||
context.projectKey,
|
||||
context.traceId,
|
||||
],
|
||||
);
|
||||
return mapRuntimeSessionRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async read(runtimeSessionId: string, options: { includeReleased?: boolean } = {}) {
|
||||
const result = await this.db.query<RuntimeSessionRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM ${this.db.table("runtime_sessions")}
|
||||
WHERE runtime_session_id = $1
|
||||
AND ($2::boolean OR released_at IS NULL)
|
||||
LIMIT 1
|
||||
`,
|
||||
[runtimeSessionId, options.includeReleased ?? false],
|
||||
);
|
||||
return mapRuntimeSessionRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async release(runtimeSessionId: string) {
|
||||
await this.db.query(
|
||||
`
|
||||
UPDATE ${this.db.table("runtime_sessions")}
|
||||
SET released_at = NOW()
|
||||
WHERE runtime_session_id = $1
|
||||
`,
|
||||
[runtimeSessionId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapRuntimeSessionRow = (
|
||||
row?: RuntimeSessionRow | null,
|
||||
): RuntimeSessionContext | null => {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
runtimeSessionId: row.runtime_session_id,
|
||||
actorKey: row.actor_key,
|
||||
allowLearningWrite: row.allow_learning_write,
|
||||
sessionId: row.session_id,
|
||||
learningMode: row.learning_mode ?? undefined,
|
||||
projectId: row.project_id ?? undefined,
|
||||
projectKey: row.project_key,
|
||||
traceId: row.trace_id,
|
||||
releasedAt:
|
||||
row.released_at instanceof Date
|
||||
? row.released_at.toISOString()
|
||||
: row.released_at ?? undefined,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||
|
||||
export type ToolSessionContext = {
|
||||
actorKey: string;
|
||||
allowLearningWrite?: boolean;
|
||||
clientSessionId: string;
|
||||
learningMode?: "interactive" | "review";
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
sessionScopeKey: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export class ToolSessionContextStore {
|
||||
constructor(private readonly baseDir = config.SESSION_CONTEXT_STORAGE_DIR) {}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async write(context: ToolSessionContext) {
|
||||
await atomicWriteJson(this.filePath(context.sessionId), context);
|
||||
if (context.learningMode === "interactive" && context.sessionScopeKey) {
|
||||
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
|
||||
}
|
||||
}
|
||||
|
||||
async read(sessionId: string) {
|
||||
return await readJsonFile<ToolSessionContext>(this.filePath(sessionId));
|
||||
}
|
||||
|
||||
async remove(sessionId: string) {
|
||||
await removeFileIfExists(this.filePath(sessionId));
|
||||
}
|
||||
|
||||
private filePath(sessionId: string) {
|
||||
return join(this.baseDir, `${sessionId}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
export const buildToolSessionScopeKey = (
|
||||
actorKey: string,
|
||||
projectKey: string,
|
||||
clientSessionId: string,
|
||||
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
|
||||
@@ -8,12 +8,12 @@ export type DynamicHttpInput = {
|
||||
path: string;
|
||||
method?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type SessionToolContext = {
|
||||
accessToken?: string;
|
||||
actorKey: string;
|
||||
clientSessionId: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
@@ -53,13 +53,11 @@ export class DynamicHttpExecutor {
|
||||
if (context.projectId) {
|
||||
headers.set("x-project-id", context.projectId);
|
||||
}
|
||||
const body = buildRequestBody(method, input.body, headers);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS),
|
||||
});
|
||||
const durationMs = Date.now() - startedAt;
|
||||
@@ -130,25 +128,6 @@ const buildQuery = (argumentsObject: Record<string, unknown>) => {
|
||||
return pairs;
|
||||
};
|
||||
|
||||
const buildRequestBody = (
|
||||
method: string,
|
||||
body: unknown,
|
||||
headers: Headers,
|
||||
) => {
|
||||
if (body === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (method === "GET") {
|
||||
throw new Error("GET requests do not support body");
|
||||
}
|
||||
const serialized = JSON.stringify(body);
|
||||
if (serialized === undefined) {
|
||||
throw new Error("body must be JSON-serializable");
|
||||
}
|
||||
headers.set("Content-Type", "application/json");
|
||||
return serialized;
|
||||
};
|
||||
|
||||
const normalizeSuccessResult = async (
|
||||
data: unknown,
|
||||
context: SessionToolContext,
|
||||
@@ -166,6 +145,7 @@ const normalizeSuccessResult = async (
|
||||
// 大结果转成持久化引用,支持 review 和跨重启回读。
|
||||
const record = await resultStore.store({
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
data,
|
||||
kind: RESULT_REFERENCE_KIND.dynamicHttpResult,
|
||||
projectId: context.projectId,
|
||||
|
||||
Reference in New Issue
Block a user