feat(api): expose REST-only agent routes
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
slugify,
|
||||
toStableId,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export type SessionStatus = "active" | "archived";
|
||||
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
|
||||
parentSessionId?: string;
|
||||
};
|
||||
|
||||
export class SessionMetadataOwnershipError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`session metadata ownership mismatch: ${sessionId}`);
|
||||
this.name = "SessionMetadataOwnershipError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionMetadataStore {
|
||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
|
||||
|
||||
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required");
|
||||
}
|
||||
const existing = await readJsonFile<SessionRecord>(
|
||||
this.filePath(sessionId),
|
||||
);
|
||||
const existing = await this.readRecord(sessionId);
|
||||
if (existing) {
|
||||
if (!matchesContext(existing, input)) {
|
||||
throw new SessionMetadataOwnershipError(sessionId);
|
||||
}
|
||||
await atomicWriteJson(this.filePath(sessionId), existing);
|
||||
await removeFileIfExists(this.legacyFilePath(sessionId));
|
||||
return { created: false, record: existing };
|
||||
}
|
||||
|
||||
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
return await readJsonFile<SessionRecord>(
|
||||
this.filePath(normalizedSessionId),
|
||||
);
|
||||
const record = await this.readRecord(normalizedSessionId);
|
||||
return record && matchesContext(record, context) ? record : null;
|
||||
}
|
||||
|
||||
async touch(
|
||||
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
|
||||
this.filePath(record.sessionId),
|
||||
next,
|
||||
);
|
||||
await removeFileIfExists(this.legacyFilePath(record.sessionId));
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
|
||||
const records = await Promise.all(
|
||||
files.map((file) => readJsonFile<SessionRecord>(file)),
|
||||
);
|
||||
return records
|
||||
const uniqueRecords = new Map<string, SessionRecord>();
|
||||
for (const record of records) {
|
||||
if (record && matchesContext(record, context)) {
|
||||
const previous = uniqueRecords.get(record.sessionId);
|
||||
if (!previous || record.updatedAt > previous.updatedAt) {
|
||||
uniqueRecords.set(record.sessionId, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...uniqueRecords.values()]
|
||||
.filter((record): record is SessionRecord => Boolean(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey,
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
async remove(record: SessionRecord) {
|
||||
await removeFileIfExists(
|
||||
this.filePath(record.sessionId),
|
||||
await Promise.all(
|
||||
this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
|
||||
);
|
||||
}
|
||||
|
||||
private filePath(sessionId: string) {
|
||||
return join(
|
||||
this.baseDir,
|
||||
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
private legacyFilePath(sessionId: string) {
|
||||
return join(this.baseDir, `${slugify(sessionId)}.json`);
|
||||
}
|
||||
|
||||
private filePaths(sessionId: string) {
|
||||
return [this.filePath(sessionId), this.legacyFilePath(sessionId)];
|
||||
}
|
||||
|
||||
private async readRecord(sessionId: string) {
|
||||
for (const path of this.filePaths(sessionId)) {
|
||||
const record = await readJsonFile<SessionRecord>(path);
|
||||
if (record?.sessionId === sessionId) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const matchesContext = (
|
||||
record: SessionRecord,
|
||||
context: SessionMetadataContext,
|
||||
) =>
|
||||
record.actorKey === context.actorKey &&
|
||||
record.projectKey === context.projectKey &&
|
||||
(!record.ownerUserId || record.ownerUserId === context.userId?.trim()) &&
|
||||
(!record.projectId || record.projectId === context.projectId);
|
||||
|
||||
const normalizeSessionId = (value?: string) => {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized.slice(0, 128) : undefined;
|
||||
|
||||
Reference in New Issue
Block a user