6b506b8c55
Worker results may report image attachments stored inside the workspace; the runtime validates them (containment, png/jpg magic, size) and delivers them with the pending event. Assistants gain a send_image action so users can ask for an image later. QQ uploads via /files with base64 file_data and sends msg_type 7 rich media, sharing the same msg_id/msg_seq counter as text replies; non-image adapters flatten images to text.
404 lines
17 KiB
TypeScript
404 lines
17 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export interface StoreIdentity {
|
|
botId: string;
|
|
platform: string;
|
|
}
|
|
|
|
export interface WorkerProcessGroup {
|
|
pgid: number;
|
|
token: string;
|
|
}
|
|
|
|
export type ProposalStatus =
|
|
| "proposed"
|
|
| "queued"
|
|
| "working"
|
|
| "pending"
|
|
| "finished";
|
|
|
|
export interface ProposalPendingAttachment {
|
|
path: string;
|
|
mimeType?: string;
|
|
}
|
|
|
|
export interface ProposalPending {
|
|
summary: string;
|
|
question?: string;
|
|
workspaceDirty?: boolean;
|
|
receivedAt: number;
|
|
attachments?: ProposalPendingAttachment[];
|
|
droppedAttachments?: string[];
|
|
}
|
|
|
|
export type ProposalFinishKind = "done" | "cancelled";
|
|
|
|
export interface Proposal {
|
|
id: string;
|
|
title: string;
|
|
goal: string;
|
|
steps: string[];
|
|
ownerChatKey: string;
|
|
requesterUserId: string;
|
|
status: ProposalStatus;
|
|
workerNativeSessionId?: string;
|
|
workerProcessGroup?: WorkerProcessGroup;
|
|
pending?: ProposalPending;
|
|
finishKind?: ProposalFinishKind;
|
|
finishNote?: string;
|
|
lastWorkerSummary?: string;
|
|
createdAt: number;
|
|
confirmedAt?: number;
|
|
startedAt?: number;
|
|
updatedAt: number;
|
|
finishedAt?: number;
|
|
}
|
|
|
|
export interface CreateProposalInput {
|
|
title: string;
|
|
goal: string;
|
|
steps: string[];
|
|
ownerChatKey: string;
|
|
requesterUserId: string;
|
|
}
|
|
|
|
export interface ProposalPatch {
|
|
title?: string;
|
|
goal?: string;
|
|
steps?: string[];
|
|
status?: ProposalStatus;
|
|
workerNativeSessionId?: string;
|
|
workerProcessGroup?: WorkerProcessGroup;
|
|
pending?: ProposalPending;
|
|
finishKind?: ProposalFinishKind;
|
|
finishNote?: string;
|
|
lastWorkerSummary?: string;
|
|
confirmedAt?: number;
|
|
startedAt?: number;
|
|
finishedAt?: number;
|
|
}
|
|
|
|
interface StoreData {
|
|
version: 2;
|
|
botId: string;
|
|
platform: string;
|
|
proposals: Record<string, Proposal>;
|
|
}
|
|
|
|
export class ProposalStore {
|
|
private data: StoreData;
|
|
private queue: Promise<void> = Promise.resolve();
|
|
private lockFd?: fs.promises.FileHandle;
|
|
private closed = false;
|
|
|
|
constructor(readonly file: string, readonly identity: StoreIdentity) {
|
|
this.data = emptyData(identity);
|
|
}
|
|
|
|
async open(): Promise<void> {
|
|
const directory = path.dirname(this.file);
|
|
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
const directoryStat = await fs.promises.lstat(directory);
|
|
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`);
|
|
const directoryMode = directoryStat.mode & 0o777;
|
|
if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`);
|
|
if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user");
|
|
const lockFile = `${this.file}.lock`;
|
|
try {
|
|
this.lockFd = await acquireLock(lockFile);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) {
|
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`);
|
|
throw error;
|
|
}
|
|
this.lockFd = await acquireLock(lockFile).catch((retryError) => {
|
|
if ((retryError as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`);
|
|
throw retryError;
|
|
});
|
|
}
|
|
try {
|
|
await this.lockFd.writeFile(`${process.pid}\n`);
|
|
await this.lockFd.sync();
|
|
} catch (error) {
|
|
await this.releaseLock();
|
|
throw error;
|
|
}
|
|
try {
|
|
const raw = await fs.promises.readFile(this.file, "utf8");
|
|
const value = JSON.parse(raw) as unknown;
|
|
if (!isRecord(value) || typeof value.botId !== "string" || typeof value.platform !== "string") {
|
|
throw new Error("unsupported proposal state; expected a versioned store file");
|
|
}
|
|
if (value.botId !== this.identity.botId || value.platform !== this.identity.platform) {
|
|
throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
|
|
}
|
|
if (value.version === 1) {
|
|
await this.backupFile(raw);
|
|
this.data = migrateV1(value);
|
|
await this.persist();
|
|
} else {
|
|
this.data = parseStoreData(value);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
await this.releaseLock();
|
|
throw new Error(`Cannot read proposal state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
get(id: string): Proposal | undefined {
|
|
const value = this.data.proposals[id];
|
|
return value ? structuredClone(value) : undefined;
|
|
}
|
|
|
|
list(filter?: { status?: ProposalStatus }): Proposal[] {
|
|
const proposals = Object.values(this.data.proposals)
|
|
.filter((proposal) => !filter?.status || proposal.status === filter.status)
|
|
.sort((a, b) => (a.confirmedAt ?? a.createdAt) - (b.confirmedAt ?? b.createdAt));
|
|
return structuredClone(proposals);
|
|
}
|
|
|
|
async create(input: CreateProposalInput): Promise<Proposal> {
|
|
if (typeof input.title !== "string" || input.title.length === 0) throw new Error("proposal title must be a non-empty string");
|
|
if (typeof input.goal !== "string" || input.goal.length === 0) throw new Error("proposal goal must be a non-empty string");
|
|
if (!Array.isArray(input.steps) || input.steps.some((step) => typeof step !== "string" || step.length === 0)) {
|
|
throw new Error("proposal steps must be an array of non-empty strings");
|
|
}
|
|
if (typeof input.ownerChatKey !== "string" || input.ownerChatKey.length === 0) throw new Error("proposal ownerChatKey must be a non-empty string");
|
|
if (typeof input.requesterUserId !== "string" || input.requesterUserId.length === 0) throw new Error("proposal requesterUserId must be a non-empty string");
|
|
const now = Date.now();
|
|
const proposal: Proposal = {
|
|
id: crypto.randomUUID(),
|
|
title: input.title,
|
|
goal: input.goal,
|
|
steps: [...input.steps],
|
|
ownerChatKey: input.ownerChatKey,
|
|
requesterUserId: input.requesterUserId,
|
|
status: "proposed",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
this.data.proposals[proposal.id] = proposal;
|
|
await this.persist();
|
|
return structuredClone(proposal);
|
|
}
|
|
|
|
async update(id: string, patch: ProposalPatch): Promise<Proposal> {
|
|
const proposal = this.data.proposals[id];
|
|
if (!proposal) throw new Error(`unknown proposal '${id}'`);
|
|
const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() };
|
|
for (const key of Object.keys(candidate) as (keyof Proposal)[]) {
|
|
if (candidate[key] === undefined) delete candidate[key];
|
|
}
|
|
validateProposal(candidate, id);
|
|
this.data.proposals[id] = candidate;
|
|
await this.persist();
|
|
return structuredClone(candidate);
|
|
}
|
|
|
|
stats(): { proposals: number } {
|
|
return { proposals: Object.keys(this.data.proposals).length };
|
|
}
|
|
|
|
async flush(): Promise<void> { await this.queue; }
|
|
|
|
async close(): Promise<void> {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
try { await this.flush(); } finally { await this.releaseLock(); }
|
|
}
|
|
|
|
private persist(): Promise<void> {
|
|
if (this.closed) return Promise.reject(new Error("Proposal store is closed"));
|
|
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
|
|
const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
|
|
this.queue = operation.catch(() => undefined);
|
|
return operation;
|
|
}
|
|
|
|
private async backupFile(raw: string): Promise<void> {
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const backup = `${this.file}.v1-${stamp}.bak`;
|
|
let handle: fs.promises.FileHandle | undefined;
|
|
try {
|
|
handle = await fs.promises.open(backup, "wx", 0o600);
|
|
await handle.writeFile(raw, "utf8");
|
|
await handle.sync();
|
|
} finally {
|
|
await handle?.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private async releaseLock(): Promise<void> {
|
|
if (!this.lockFd) return;
|
|
await this.lockFd.close();
|
|
this.lockFd = undefined;
|
|
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "ENOENT") throw error;
|
|
});
|
|
}
|
|
}
|
|
|
|
function emptyData(identity: StoreIdentity): StoreData {
|
|
return { version: 2, botId: identity.botId, platform: identity.platform, proposals: {} };
|
|
}
|
|
|
|
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "pending", "finished"];
|
|
const FINISH_KINDS: readonly ProposalFinishKind[] = ["done", "cancelled"];
|
|
|
|
function parseStoreData(value: unknown): StoreData {
|
|
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
|
|
throw new Error("unsupported proposal state version; expected version 2");
|
|
}
|
|
for (const [id, proposal] of Object.entries(value.proposals)) {
|
|
if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`);
|
|
validateProposal(proposal as unknown as Proposal, id);
|
|
}
|
|
return value as unknown as StoreData;
|
|
}
|
|
|
|
interface V1Pending {
|
|
kind?: string;
|
|
summary?: string;
|
|
question?: string;
|
|
}
|
|
|
|
// v1 → v2 migration: pending becomes a single "waiting for the user" state with no success/failure
|
|
// distinction; terminal v1 statuses collapse into finished with a finishKind.
|
|
function migrateV1(value: Record<string, unknown>): StoreData {
|
|
if (!isRecord(value.proposals)) throw new Error("unsupported proposal state version; expected version 2");
|
|
const proposals: Record<string, Proposal> = {};
|
|
for (const [id, raw] of Object.entries(value.proposals)) {
|
|
if (!isRecord(raw) || raw.id !== id) throw new Error(`invalid proposal '${id}'`);
|
|
const source = structuredClone(raw) as unknown as Omit<Proposal, "status" | "pending"> & { status: string; pending?: V1Pending };
|
|
const pending = isRecord(source.pending) ? source.pending as V1Pending : undefined;
|
|
const migrated: Proposal = { ...source, pending: undefined, finishKind: undefined, finishNote: undefined } as Proposal;
|
|
if (source.status === "awaiting_user_confirmation") {
|
|
migrated.status = "pending";
|
|
migrated.pending = {
|
|
summary: typeof pending?.summary === "string" ? pending.summary : (source.lastWorkerSummary || "worker reported a result before migration"),
|
|
receivedAt: typeof source.updatedAt === "number" ? source.updatedAt : Date.now()
|
|
};
|
|
if (pending?.kind === "failure") migrated.pending.workspaceDirty = true;
|
|
if (pending?.kind === "step" && typeof pending.question === "string") migrated.pending.question = pending.question;
|
|
} else if (source.status === "completed") {
|
|
migrated.status = "finished";
|
|
migrated.finishKind = "done";
|
|
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
|
|
} else if (source.status === "failed") {
|
|
migrated.status = "finished";
|
|
migrated.finishKind = "done";
|
|
migrated.finishNote = pending?.summary || source.lastWorkerSummary || "failed before migration";
|
|
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
|
|
} else if (source.status === "cancelled") {
|
|
migrated.status = "finished";
|
|
migrated.finishKind = "cancelled";
|
|
migrated.finishedAt = typeof source.finishedAt === "number" ? source.finishedAt : Date.now();
|
|
} else if (source.status === "proposed" || source.status === "queued" || source.status === "working") {
|
|
migrated.status = source.status;
|
|
} else {
|
|
throw new Error(`invalid proposal '${id}': status`);
|
|
}
|
|
validateProposal(migrated, id);
|
|
proposals[id] = migrated;
|
|
}
|
|
return { version: 2, botId: value.botId as string, platform: value.platform as string, proposals };
|
|
}
|
|
|
|
function validateProposal(proposal: Proposal, id: string): void {
|
|
const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); };
|
|
if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title");
|
|
if (typeof proposal.goal !== "string" || proposal.goal.length === 0) invalid("goal");
|
|
if (!Array.isArray(proposal.steps) || proposal.steps.some((step) => typeof step !== "string" || step.length === 0)) invalid("steps");
|
|
if (typeof proposal.ownerChatKey !== "string" || proposal.ownerChatKey.length === 0) invalid("ownerChatKey");
|
|
if (typeof proposal.requesterUserId !== "string" || proposal.requesterUserId.length === 0) invalid("requesterUserId");
|
|
if (!STATUSES.includes(proposal.status)) invalid("status");
|
|
if (proposal.workerNativeSessionId !== undefined && typeof proposal.workerNativeSessionId !== "string") invalid("workerNativeSessionId");
|
|
if (proposal.workerProcessGroup !== undefined) {
|
|
const group = proposal.workerProcessGroup;
|
|
if (!isRecord(group) || !Number.isSafeInteger(group.pgid) || group.pgid <= 1 || typeof group.token !== "string" || group.token.length === 0) {
|
|
invalid("workerProcessGroup");
|
|
}
|
|
}
|
|
if (proposal.pending !== undefined) {
|
|
const pending = proposal.pending;
|
|
if (!isRecord(pending)
|
|
|| typeof pending.summary !== "string"
|
|
|| (pending.question !== undefined && typeof pending.question !== "string")
|
|
|| (pending.workspaceDirty !== undefined && typeof pending.workspaceDirty !== "boolean")
|
|
|| typeof pending.receivedAt !== "number") {
|
|
invalid("pending");
|
|
}
|
|
if (pending.attachments !== undefined
|
|
&& (!Array.isArray(pending.attachments) || pending.attachments.length > 3
|
|
|| pending.attachments.some((attachment) => !isRecord(attachment)
|
|
|| typeof attachment.path !== "string" || attachment.path.length === 0
|
|
|| (attachment.mimeType !== undefined && typeof attachment.mimeType !== "string")))) {
|
|
invalid("pending.attachments");
|
|
}
|
|
if (pending.droppedAttachments !== undefined
|
|
&& (!Array.isArray(pending.droppedAttachments) || pending.droppedAttachments.some((entry) => typeof entry !== "string"))) {
|
|
invalid("pending.droppedAttachments");
|
|
}
|
|
}
|
|
if (proposal.finishKind !== undefined && !FINISH_KINDS.includes(proposal.finishKind)) invalid("finishKind");
|
|
if (proposal.finishNote !== undefined && typeof proposal.finishNote !== "string") invalid("finishNote");
|
|
if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary");
|
|
if (typeof proposal.createdAt !== "number") invalid("createdAt");
|
|
if (typeof proposal.updatedAt !== "number") invalid("updatedAt");
|
|
if (proposal.confirmedAt !== undefined && typeof proposal.confirmedAt !== "number") invalid("confirmedAt");
|
|
if (proposal.startedAt !== undefined && typeof proposal.startedAt !== "number") invalid("startedAt");
|
|
if (proposal.finishedAt !== undefined && typeof proposal.finishedAt !== "number") invalid("finishedAt");
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
|
|
return fs.promises.open(lockFile, "wx", 0o600);
|
|
}
|
|
|
|
async function removeStaleLock(lockFile: string): Promise<boolean> {
|
|
let handle: fs.promises.FileHandle | undefined;
|
|
try {
|
|
handle = await fs.promises.open(lockFile, "r");
|
|
const pid = Number((await handle.readFile("utf8")).trim());
|
|
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
|
|
const opened = await handle.stat();
|
|
const current = await fs.promises.lstat(lockFile);
|
|
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
|
|
await fs.promises.unlink(lockFile);
|
|
return true;
|
|
} catch { return false; }
|
|
finally { await handle?.close().catch(() => undefined); }
|
|
}
|
|
|
|
function processExists(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; }
|
|
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
}
|
|
|
|
async function atomicWrite(file: string, content: string): Promise<void> {
|
|
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
let handle: fs.promises.FileHandle | undefined;
|
|
try {
|
|
handle = await fs.promises.open(temp, "wx", 0o600);
|
|
await handle.writeFile(content, "utf8");
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await fs.promises.rename(temp, file);
|
|
await fs.promises.chmod(file, 0o600);
|
|
const dir = await fs.promises.open(path.dirname(file), "r");
|
|
try { await dir.sync(); } finally { await dir.close(); }
|
|
} catch (error) {
|
|
if (handle) await handle.close().catch(() => undefined);
|
|
await fs.promises.unlink(temp).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|