feat: pending-finish proposals and QQ image input
Proposal semantics: worker results no longer distinguish success/fail; only an explicit finish settles a pending proposal. Pending owner input follows up by resuming the original worker session. Dirty pending blocks start_next globally; clean pending only blocks its owner. QQ adapter now downloads image attachments and passes them as ACP image content blocks; video/file attachments degrade to link text.
This commit is contained in:
+96
-16
@@ -16,18 +16,18 @@ export type ProposalStatus =
|
||||
| "proposed"
|
||||
| "queued"
|
||||
| "working"
|
||||
| "awaiting_user_confirmation"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
| "pending"
|
||||
| "finished";
|
||||
|
||||
export interface ProposalPending {
|
||||
kind: "step" | "success" | "failure";
|
||||
summary: string;
|
||||
question?: string;
|
||||
nextStep?: string;
|
||||
workspaceDirty?: boolean;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export type ProposalFinishKind = "done" | "cancelled";
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -39,6 +39,8 @@ export interface Proposal {
|
||||
workerNativeSessionId?: string;
|
||||
workerProcessGroup?: WorkerProcessGroup;
|
||||
pending?: ProposalPending;
|
||||
finishKind?: ProposalFinishKind;
|
||||
finishNote?: string;
|
||||
lastWorkerSummary?: string;
|
||||
createdAt: number;
|
||||
confirmedAt?: number;
|
||||
@@ -63,6 +65,8 @@ export interface ProposalPatch {
|
||||
workerNativeSessionId?: string;
|
||||
workerProcessGroup?: WorkerProcessGroup;
|
||||
pending?: ProposalPending;
|
||||
finishKind?: ProposalFinishKind;
|
||||
finishNote?: string;
|
||||
lastWorkerSummary?: string;
|
||||
confirmedAt?: number;
|
||||
startedAt?: number;
|
||||
@@ -70,7 +74,7 @@ export interface ProposalPatch {
|
||||
}
|
||||
|
||||
interface StoreData {
|
||||
version: 1;
|
||||
version: 2;
|
||||
botId: string;
|
||||
platform: string;
|
||||
proposals: Record<string, Proposal>;
|
||||
@@ -116,11 +120,20 @@ export class ProposalStore {
|
||||
}
|
||||
try {
|
||||
const raw = await fs.promises.readFile(this.file, "utf8");
|
||||
const parsed = parseStoreData(JSON.parse(raw) as unknown);
|
||||
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
|
||||
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}'`);
|
||||
}
|
||||
this.data = parsed;
|
||||
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();
|
||||
@@ -170,6 +183,9 @@ export class ProposalStore {
|
||||
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();
|
||||
@@ -196,6 +212,19 @@ export class ProposalStore {
|
||||
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();
|
||||
@@ -207,14 +236,15 @@ export class ProposalStore {
|
||||
}
|
||||
|
||||
function emptyData(identity: StoreIdentity): StoreData {
|
||||
return { version: 1, botId: identity.botId, platform: identity.platform, proposals: {} };
|
||||
return { version: 2, botId: identity.botId, platform: identity.platform, proposals: {} };
|
||||
}
|
||||
|
||||
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "awaiting_user_confirmation", "completed", "failed", "cancelled"];
|
||||
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 !== 1 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
|
||||
throw new Error("unsupported proposal state version; expected version 1");
|
||||
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}'`);
|
||||
@@ -223,6 +253,54 @@ function parseStoreData(value: unknown): StoreData {
|
||||
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");
|
||||
@@ -241,13 +319,15 @@ function validateProposal(proposal: Proposal, id: string): void {
|
||||
if (proposal.pending !== undefined) {
|
||||
const pending = proposal.pending;
|
||||
if (!isRecord(pending)
|
||||
|| !["step", "success", "failure"].includes(String(pending.kind))
|
||||
|| typeof pending.summary !== "string"
|
||||
|| (pending.question !== undefined && typeof pending.question !== "string")
|
||||
|| (pending.nextStep !== undefined && typeof pending.nextStep !== "string")) {
|
||||
|| (pending.workspaceDirty !== undefined && typeof pending.workspaceDirty !== "boolean")
|
||||
|| typeof pending.receivedAt !== "number") {
|
||||
invalid("pending");
|
||||
}
|
||||
}
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user