feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
+108
-24
@@ -1,12 +1,12 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { type SessionWorkspaceStore } from "./workspace-store.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
slugify,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SessionRecord = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: SessionStatus;
|
||||
deletedAt?: string;
|
||||
purpose?: SessionPurpose;
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
@@ -65,13 +66,33 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
|
||||
};
|
||||
|
||||
export class SessionMetadataStore {
|
||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
|
||||
private readonly ensureQueues = new Map<string, Promise<void>>();
|
||||
private index?: Map<string, Map<string, SessionRecord>>;
|
||||
private indexLoading?: Promise<void>;
|
||||
private indexVersion = 0;
|
||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
|
||||
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async ensure(input: EnsureSessionMetadataInput) {
|
||||
return this.serialize(input.sessionId, () => this.ensureRecord(input));
|
||||
}
|
||||
|
||||
private async serialize<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const key = this.filePath(sessionId);
|
||||
const previous = this.ensureQueues.get(key) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(operation);
|
||||
const pending = result.then(() => undefined, () => undefined);
|
||||
this.ensureQueues.set(key, pending);
|
||||
try { return await result; }
|
||||
finally { if (this.ensureQueues.get(key) === pending) this.ensureQueues.delete(key); }
|
||||
}
|
||||
|
||||
private async ensureRecord(input: EnsureSessionMetadataInput) {
|
||||
const sessionId = normalizeSessionId(input.sessionId);
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required");
|
||||
@@ -81,7 +102,7 @@ export class SessionMetadataStore {
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
!matchesContext(existing, input) ||
|
||||
existing.deletedAt || existing.sessionId !== sessionId || !matchesContext(existing, input) ||
|
||||
(input.trigger && existing.triggerId !== input.trigger.triggerId) ||
|
||||
(input.purpose && readPurpose(existing) !== input.purpose) ||
|
||||
(input.taskId && existing.taskId !== input.taskId.trim()) ||
|
||||
@@ -91,10 +112,12 @@ export class SessionMetadataStore {
|
||||
) {
|
||||
throw new SessionContextMismatchError();
|
||||
}
|
||||
if (this.workspaces) await this.workspaces.ensure({ actorKey: existing.actorKey, projectKey: existing.projectKey, sessionId });
|
||||
return { created: false, record: existing };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (this.workspaces) await this.workspaces.ensure({ actorKey: input.actorKey, projectKey: input.projectKey, sessionId });
|
||||
const record: SessionRecord = {
|
||||
sessionId,
|
||||
actorKey: input.actorKey,
|
||||
@@ -122,11 +145,13 @@ export class SessionMetadataStore {
|
||||
await atomicWriteJson(
|
||||
this.filePath(record.sessionId),
|
||||
record,
|
||||
0o600,
|
||||
);
|
||||
this.updateIndex(record);
|
||||
return { created: true, record };
|
||||
}
|
||||
|
||||
async get(context: SessionMetadataContext, sessionId: string) {
|
||||
async get(context: SessionMetadataContext, sessionId: string, options?: { includeDeleted?: boolean }) {
|
||||
const normalizedSessionId = normalizeSessionId(sessionId);
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
@@ -134,39 +159,45 @@ export class SessionMetadataStore {
|
||||
const record = await readJsonFile<SessionRecord>(
|
||||
this.filePath(normalizedSessionId),
|
||||
);
|
||||
return record && matchesContext(record, context) ? record : null;
|
||||
return record && (!record.deletedAt || options?.includeDeleted) && record.sessionId === normalizedSessionId && matchesContext(record, context) ? record : null;
|
||||
}
|
||||
|
||||
// Internal delivery only, after Server has authorized and resolved this run.
|
||||
// Does not authorize ordinary chat access or expose a global session lookup.
|
||||
async getTaskExecutionBinding(projectId: string, taskId: string, runId: string, sessionId: string) {
|
||||
const id = normalizeSessionId(sessionId);
|
||||
if (!id) return null;
|
||||
const record = await readJsonFile<SessionRecord>(this.filePath(id));
|
||||
return record && !record.deletedAt && record.sessionId === id
|
||||
&& record.projectId === projectId && record.taskId === taskId && record.runId === runId
|
||||
&& readPurpose(record) === "task_execution" ? record : null;
|
||||
}
|
||||
|
||||
async touch(
|
||||
record: SessionRecord,
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
|
||||
) {
|
||||
const next: SessionRecord = {
|
||||
...record,
|
||||
...normalizeSessionUpdates(updates),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteJson(
|
||||
this.filePath(record.sessionId),
|
||||
next,
|
||||
);
|
||||
return next;
|
||||
return this.serialize(record.sessionId, async () => {
|
||||
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
|
||||
if (!current) throw new SessionContextMismatchError();
|
||||
const next: SessionRecord = { ...current, ...normalizeSessionUpdates(updates), updatedAt: new Date().toISOString() };
|
||||
await atomicWriteJson(this.filePath(record.sessionId), next, 0o600);
|
||||
this.updateIndex(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async list(
|
||||
context: SessionMetadataContext,
|
||||
filter?: { purpose?: SessionPurpose; taskId?: string },
|
||||
) {
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(
|
||||
files.map((file) => readJsonFile<SessionRecord>(file)),
|
||||
);
|
||||
await this.loadIndex();
|
||||
const records = [...(this.index?.get(this.scopeKey(context))?.values() ?? [])].map((record) => ({ ...record }));
|
||||
return records
|
||||
.filter((record): record is SessionRecord => Boolean(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey,
|
||||
!record.deletedAt && matchesContext(record, context),
|
||||
)
|
||||
.filter((record) => !filter?.purpose || readPurpose(record) === filter.purpose)
|
||||
.filter((record) => !filter?.taskId || record.taskId === filter.taskId.trim())
|
||||
@@ -199,9 +230,62 @@ export class SessionMetadataStore {
|
||||
}
|
||||
|
||||
async remove(record: SessionRecord) {
|
||||
await removeFileIfExists(
|
||||
this.filePath(record.sessionId),
|
||||
);
|
||||
return this.serialize(record.sessionId, async () => {
|
||||
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true });
|
||||
if (!current) throw new SessionContextMismatchError();
|
||||
// Retain only identity and deletion state, so legacy imports and late writes cannot revive it.
|
||||
const deleted: SessionRecord = {
|
||||
sessionId: current.sessionId, actorKey: current.actorKey, projectKey: current.projectKey,
|
||||
ownerUserId: current.ownerUserId, projectId: current.projectId,
|
||||
createdAt: current.createdAt, updatedAt: new Date().toISOString(), status: "archived",
|
||||
deletedAt: current.deletedAt ?? new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteJson(this.filePath(record.sessionId), deleted, 0o600);
|
||||
this.updateIndex(deleted);
|
||||
await this.workspaces?.markDeleted(deleted);
|
||||
});
|
||||
}
|
||||
|
||||
async purge(record: SessionRecord) {
|
||||
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true });
|
||||
if (!current?.deletedAt) throw new SessionContextMismatchError();
|
||||
await this.workspaces?.purge(current);
|
||||
}
|
||||
|
||||
private scopeKey(context: { actorKey: string; projectKey: string }) {
|
||||
return JSON.stringify([context.actorKey, context.projectKey]);
|
||||
}
|
||||
|
||||
private updateIndex(record: SessionRecord) {
|
||||
this.indexVersion++;
|
||||
if (!this.index) return;
|
||||
const key = this.scopeKey(record);
|
||||
const records = this.index.get(key) ?? new Map<string, SessionRecord>();
|
||||
records.set(record.sessionId, { ...record });
|
||||
this.index.set(key, records);
|
||||
}
|
||||
|
||||
private async loadIndex(): Promise<void> {
|
||||
if (this.index) return;
|
||||
if (this.indexLoading) { await this.indexLoading; return this.loadIndex(); }
|
||||
const version = this.indexVersion;
|
||||
const loading = (async () => {
|
||||
const files = await listJsonFiles(this.baseDir);
|
||||
const records = await Promise.all(files.map((file) => readJsonFile<SessionRecord>(file)));
|
||||
const index = new Map<string, Map<string, SessionRecord>>();
|
||||
for (const record of records) {
|
||||
if (!record) continue;
|
||||
const key = this.scopeKey(record);
|
||||
const scoped = index.get(key) ?? new Map<string, SessionRecord>();
|
||||
scoped.set(record.sessionId, record);
|
||||
index.set(key, scoped);
|
||||
}
|
||||
if (version === this.indexVersion) this.index = index;
|
||||
})();
|
||||
this.indexLoading = loading;
|
||||
try { await loading; }
|
||||
finally { if (this.indexLoading === loading) this.indexLoading = undefined; }
|
||||
if (!this.index) await this.loadIndex();
|
||||
}
|
||||
|
||||
private filePath(sessionId: string) {
|
||||
|
||||
Reference in New Issue
Block a user