## Summary Re-lands the completed Fusion board task FX-005 on current upstream `main`, stacked on #2374 (FX-004). - adds a narrowly authorized additive publication path for archived task documents - preserves archived task and mission state and keeps ordinary replacement/deletion writes rejected - exposes retained archived current/revision reads - requires project-scoped revision/hash CAS for publication - maps malformed, unauthorized, missing, inconsistent, and stale states safely - rebases preserved dashboard drafts explicitly after CAS conflicts ## Why Operators need to append a correction or evidence revision to an archived task without unarchiving it or weakening ordinary archived-task immutability. ## Dependency This branch contains #2374 plus the eight FX-005 commits because cross-fork PRs cannot target a fork-only base branch. After #2374 lands, this PR should be rebased or refreshed so its diff collapses to FX-005 only. ## Validation - PostgreSQL task-store and archived-default suites: 33/33 - dashboard route and editor suites: 321/321 - agent document tools: 22/22 - core, dashboard, and engine typechecks pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added optimistic concurrency controls for task document creation and editing using revisions and content hashes. * Added safe, authenticated append-only corrections for documents retained on archived tasks. * Archived documents and revision history remain available for direct reading. * Agent and dashboard tools now report conflicts clearly and support explicit draft rebasing. * **Bug Fixes** * Prevented stale updates from overwriting newer document content. * Preserved archived-task immutability while allowing controlled corrections. * **Documentation** * Updated CLI, dashboard, storage, task-management, and agent guidance for these workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: fusion-merge-train <merge-train@topkoli.local> Co-authored-by: Fusion <noreply@runfusion.ai> Co-authored-by: v <v@v.speedport.ip> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
6.4 KiB
TypeScript
160 lines
6.4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
export const TASK_DOCUMENT_PRECONDITION_FAILED = "TASK_DOCUMENT_PRECONDITION_FAILED" as const;
|
|
export const TASK_DOCUMENT_CONTENT_HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
export const ARCHIVED_TASK_DOCUMENT_ADDITION_BOUNDARY = "\n\n";
|
|
export const ARCHIVED_TASK_DOCUMENT_PUBLICATION_REJECTED = "ARCHIVED_TASK_DOCUMENT_PUBLICATION_REJECTED" as const;
|
|
|
|
export type ArchivedTaskDocumentPublicationRejection =
|
|
| "parent-not-found"
|
|
| "document-not-found"
|
|
| "parent-not-archived"
|
|
| "archived-state-inconsistent"
|
|
| "postgres-required";
|
|
|
|
export interface TaskDocumentPreconditionState {
|
|
projectId: string;
|
|
taskId: string;
|
|
key: string;
|
|
expectedRevision?: number;
|
|
expectedContentHash?: string;
|
|
currentRevision: number | null;
|
|
currentContentHash: string | null;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskDocumentCAS 2026-07-20-11:06:
|
|
* Conditional document publication compares deterministic projections of the exact UTF-8 content. Whitespace and line endings are significant. Revision zero means the document must be absent; positive revisions and every hash expectation require an existing exact match. When both expectations are supplied, both must match. Omitted expectations retain the legacy unconditional write contract.
|
|
*/
|
|
export function taskDocumentContentHash(content: string): string {
|
|
return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
|
|
}
|
|
|
|
export function validateTaskDocumentPreconditions(input: {
|
|
expectedRevision?: number;
|
|
expectedContentHash?: string;
|
|
}): void {
|
|
if (input.expectedRevision !== undefined && (!Number.isInteger(input.expectedRevision) || input.expectedRevision < 0)) {
|
|
throw new TypeError("expectedRevision must be a non-negative integer");
|
|
}
|
|
if (input.expectedContentHash !== undefined && !TASK_DOCUMENT_CONTENT_HASH_PATTERN.test(input.expectedContentHash)) {
|
|
throw new TypeError("expectedContentHash must use the format sha256:<64 lowercase hex characters>");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:ArchivedTaskDocumentPublication 2026-07-20-15:36:
|
|
* Archived publication requires an existing current row, so revision zero and optional CAS are invalid. Existing and appended strings remain byte-significant; the mutation constructs `existing + "\\n\\n" + appendContent` without trimming or newline normalization.
|
|
*/
|
|
export function validateArchivedTaskDocumentAddition(input: {
|
|
appendContent: unknown;
|
|
expectedRevision: unknown;
|
|
expectedContentHash: unknown;
|
|
author: unknown;
|
|
reason: unknown;
|
|
}): asserts input is {
|
|
appendContent: string;
|
|
expectedRevision: number;
|
|
expectedContentHash: string;
|
|
author: string;
|
|
reason: string;
|
|
} {
|
|
if (typeof input.appendContent !== "string" || input.appendContent.length === 0) {
|
|
throw new TypeError("appendContent must be a non-empty string");
|
|
}
|
|
if (!Number.isInteger(input.expectedRevision) || (input.expectedRevision as number) < 1) {
|
|
throw new TypeError("expectedRevision must be a positive integer");
|
|
}
|
|
if (typeof input.expectedContentHash !== "string" || !TASK_DOCUMENT_CONTENT_HASH_PATTERN.test(input.expectedContentHash)) {
|
|
throw new TypeError("expectedContentHash must use the format sha256:<64 lowercase hex characters>");
|
|
}
|
|
if (typeof input.author !== "string" || input.author.trim().length === 0) {
|
|
throw new TypeError("author must be a non-empty string");
|
|
}
|
|
if (typeof input.reason !== "string" || input.reason.trim().length === 0) {
|
|
throw new TypeError("reason must be a non-empty string");
|
|
}
|
|
}
|
|
|
|
export class ArchivedTaskDocumentPublicationRejectedError extends Error {
|
|
readonly code = ARCHIVED_TASK_DOCUMENT_PUBLICATION_REJECTED;
|
|
|
|
constructor(
|
|
readonly reason: ArchivedTaskDocumentPublicationRejection,
|
|
readonly projectId: string,
|
|
readonly taskId: string,
|
|
readonly key: string,
|
|
) {
|
|
super(`Archived task document publication rejected for ${taskId}/${key}: ${reason}`);
|
|
this.name = "ArchivedTaskDocumentPublicationRejectedError";
|
|
}
|
|
|
|
toDetails(): {
|
|
code: typeof ARCHIVED_TASK_DOCUMENT_PUBLICATION_REJECTED;
|
|
reason: ArchivedTaskDocumentPublicationRejection;
|
|
projectId: string;
|
|
taskId: string;
|
|
key: string;
|
|
} {
|
|
return { code: this.code, reason: this.reason, projectId: this.projectId, taskId: this.taskId, key: this.key };
|
|
}
|
|
}
|
|
|
|
export class TaskDocumentPreconditionFailedError extends Error {
|
|
readonly code = TASK_DOCUMENT_PRECONDITION_FAILED;
|
|
readonly projectId: string;
|
|
readonly taskId: string;
|
|
readonly key: string;
|
|
readonly expectedRevision?: number;
|
|
readonly expectedContentHash?: string;
|
|
readonly currentRevision: number | null;
|
|
readonly currentContentHash: string | null;
|
|
|
|
constructor(state: TaskDocumentPreconditionState) {
|
|
super(`Task document precondition failed for ${state.taskId}/${state.key}`);
|
|
this.name = "TaskDocumentPreconditionFailedError";
|
|
this.projectId = state.projectId;
|
|
this.taskId = state.taskId;
|
|
this.key = state.key;
|
|
this.expectedRevision = state.expectedRevision;
|
|
this.expectedContentHash = state.expectedContentHash;
|
|
this.currentRevision = state.currentRevision;
|
|
this.currentContentHash = state.currentContentHash;
|
|
}
|
|
|
|
toDetails(): TaskDocumentPreconditionState & { code: typeof TASK_DOCUMENT_PRECONDITION_FAILED } {
|
|
return {
|
|
code: this.code,
|
|
projectId: this.projectId,
|
|
taskId: this.taskId,
|
|
key: this.key,
|
|
expectedRevision: this.expectedRevision,
|
|
expectedContentHash: this.expectedContentHash,
|
|
currentRevision: this.currentRevision,
|
|
currentContentHash: this.currentContentHash,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function assertTaskDocumentPreconditions(
|
|
identity: Pick<TaskDocumentPreconditionState, "projectId" | "taskId" | "key">,
|
|
expected: Pick<TaskDocumentPreconditionState, "expectedRevision" | "expectedContentHash">,
|
|
current: { revision: number; content: string } | null,
|
|
): void {
|
|
validateTaskDocumentPreconditions(expected);
|
|
const currentRevision = current?.revision ?? null;
|
|
const currentContentHash = current ? taskDocumentContentHash(current.content) : null;
|
|
const revisionMatches = expected.expectedRevision === undefined
|
|
|| (expected.expectedRevision === 0 ? current === null : currentRevision === expected.expectedRevision);
|
|
const hashMatches = expected.expectedContentHash === undefined
|
|
|| (current !== null && currentContentHash === expected.expectedContentHash);
|
|
if (!revisionMatches || !hashMatches) {
|
|
throw new TaskDocumentPreconditionFailedError({
|
|
...identity,
|
|
...expected,
|
|
currentRevision,
|
|
currentContentHash,
|
|
});
|
|
}
|
|
}
|