feat(core): support additive archived task documents (#2375)
## 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>
This commit is contained in:
7
.changeset/fx-004-task-document-cas.md
Normal file
7
.changeset/fx-004-task-document-cas.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add conditional task-document writes that reject stale publishers without changing revision history.
|
||||
category: feature
|
||||
dev: Runtime tools and dashboard clients can compare expected revision and exact-content SHA-256 hash.
|
||||
7
.changeset/fx-005-archived-document-publication.md
Normal file
7
.changeset/fx-005-archived-document-publication.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add authenticated append-only corrections for documents retained on archived tasks.
|
||||
category: feature
|
||||
dev: Adds project-scoped revision/hash CAS publication and archived direct document reads.
|
||||
@@ -40,7 +40,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--
|
||||
- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, forwarding both requested skill names and resolved plugin body directories so skills such as `ce-debug` are available in chat when the contributing plugin is enabled for the requesting project. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills.
|
||||
- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them.
|
||||
- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command.
|
||||
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write`, `fn_task_document_read`, and `fn_task_logs_read`; because neither lane has an ambient task, each tool requires an explicit `task_id`. `fn_task_logs_read` pages the persisted full agent log for failure analysis.
|
||||
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write`, `fn_task_document_read`, and `fn_task_logs_read`; because neither lane has an ambient task, each tool requires an explicit `task_id`. Document writers may pass `expected_revision` and/or `expected_content_hash` after a read for safe cross-task CAS publication; stale writes return typed conflict state and are never auto-retried. `fn_task_logs_read` pages the persisted full agent log for failure analysis.
|
||||
- Dashboard chat and room responders share a safe coordination/productivity toolset across pi and Grok CLI runtimes: board reads, task creation, delegation, agent listing/configuration, web fetch, and goal/memory/research retrieval. Destructive agent-lifecycle tools and memory append remain excluded because chat has no action-gate context.
|
||||
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. Lanes without an ambient task, including dashboard chat/planning and published/pi extension calls outside a task, must pass an explicit `task_id`; task-bound executor paths may default to the current task.
|
||||
- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency.
|
||||
|
||||
@@ -30,6 +30,55 @@ The published `@runfusion/fusion` CLI bundle also exposes the pi extension tool
|
||||
|
||||
Agents should still use `fn_workflow_select` only when the user explicitly requested that workflow or when assigning a workflow to a task they created; they must not reroute arbitrary existing tasks just because another workflow appears more suitable. Prompt-injectable lanes strip workflow approval-bypass flags during `fn_workflow_create` / `fn_workflow_update`; executor-owner paths are the only authoring path that may preserve those flags.
|
||||
|
||||
## Runtime task-document publication
|
||||
|
||||
`fn_task_document_write` is an agent-extension/runtime tool, not an `fn task` binary subcommand. Task-bound lanes supply `key`, `content`, optional `author`, and optional `expected_revision` / `expected_content_hash`; dashboard chat and planning use the same fields plus required `task_id` for explicit cross-task publication.
|
||||
|
||||
For safe publication, first call `fn_task_document_read`, then write with the returned revision and hash:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "FX-002",
|
||||
"key": "evidence",
|
||||
"content": "rebased evidence",
|
||||
"expected_revision": 3,
|
||||
"expected_content_hash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
```
|
||||
|
||||
Revision zero means create only if absent. On success the tool returns the new revision and content hash. A stale expectation returns an error result with code `TASK_DOCUMENT_PRECONDITION_FAILED` and current revision/hash; re-read, reconcile the newer content, and submit a deliberate rebased write. The tool never retries or overwrites automatically. Omitting both expectations retains the legacy unconditional contract. These ordinary tools reject archived parents; there is no `allowArchived` tool parameter.
|
||||
|
||||
### Operator API: append to a retained archived document
|
||||
|
||||
Archived correction publication is an authenticated HTTP API, not an `fn` binary subcommand or agent tool. It requires active daemon bearer authentication; Fusion launched with `--no-auth` returns `403`. First read the exact current revision/hash, then submit only the suffix:
|
||||
|
||||
```bash
|
||||
BASE=http://127.0.0.1:4040/api
|
||||
TASK=FX-DISPOSABLE
|
||||
KEY=docs
|
||||
TOKEN="$FUSION_DAEMON_TOKEN"
|
||||
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" \
|
||||
"$BASE/tasks/$TASK/documents/$KEY" > /tmp/fusion-current-document.json
|
||||
|
||||
REVISION=$(jq -r .revision /tmp/fusion-current-document.json)
|
||||
CONTENT_HASH=$(jq -r .contentHash /tmp/fusion-current-document.json)
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$BASE/tasks/$TASK/documents/$KEY/archived-publications" \
|
||||
--data "$(jq -n \
|
||||
--arg appendContent 'Correction text' \
|
||||
--arg expectedContentHash "$CONTENT_HASH" \
|
||||
--arg author 'operator' \
|
||||
--arg reason 'Correct retained evidence' \
|
||||
--argjson expectedRevision "$REVISION" \
|
||||
'{appendContent, expectedRevision, expectedContentHash, author, reason}')"
|
||||
```
|
||||
|
||||
Fusion constructs `existing content + "\n\n" + appendContent`; callers cannot send replacement `content` or metadata. Responses are `201` on committed append, `400` for malformed/unknown fields, `403` when the privileged capability is unavailable, `404` for a missing archived parent/document, and `409` for non-archived/inconsistent state or stale CAS. On `409 TASK_DOCUMENT_PRECONDITION_FAILED`, re-read current content/revision/hash, verify whether the correction is still needed, and submit a newly rebased append; never retry the stale body unchanged. In multi-project operation, use the same project selector as other task APIs so every read and publication resolves within one project.
|
||||
|
||||
## Workflow commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,6 +15,12 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
|
||||
|
||||
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, both the Settings update-success state and dashboard update banner offer a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), either action remains disabled and explains that Fusion must be restarted manually.
|
||||
|
||||
### Supervised source-checkout rebuilds
|
||||
|
||||
For a capability that has just merged, rebuild only after its commit is present in the fixed source checkout reported by `GET /api/system` as `sourceWorkspaceRoot`. Confirm `rebuildSupported` and supervision, then send authenticated `POST /api/system/rebuild` with `{ "scope": "app", "restart": true }`. Poll `GET /api/system/rebuild/current` until the build succeeds and restart is scheduled; expect the connection to drop, reconnect to the supervised process, and verify `GET /api/health` before capability probes.
|
||||
|
||||
Never copy worktree files into the reported checkout, rebuild an unmerged branch, kill port 4040, use `nohup`, or replace the daemon with a raw detached process. An implementation task running inside Fusion cannot continue after stopping its own host, so post-merge restart and live verification belong to a separate operator-run/dependent task with the deployed commit, source workspace, health response, and probe evidence recorded.
|
||||
|
||||
## Settings discovery
|
||||
|
||||
<!-- FNXC:SettingsSearchDocs 2026-07-04-00:00: Settings search is section-discovery, not a global command palette. Document that it filters visible Settings sections by section names and setting keywords while preserving feature-gated hidden sections. -->
|
||||
@@ -976,6 +982,7 @@ Features:
|
||||
- Error state: a failed artifact list request uses the shared `Failed to load artifacts: <error>` panel with a **Retry** action that re-runs the artifact fetch
|
||||
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button
|
||||
- Highlight text in raw or rendered project-file previews or the selected Task Document's right pane, choose **Add comment**, and send the source path/key, selected snippet, and your comment to the **New Task** dialog
|
||||
- Task Detail creates documents with an absence precondition and edits using the revision/hash loaded with the draft. The global **Artifacts → Task Documents** editor uses the same conditional save. If another writer wins first, Fusion keeps the editor open and preserves the exact draft on desktop and mobile, refreshes the visible current revision, and asks the operator to review/rebase; it never silently retries or overwrites the newer document.
|
||||
|
||||
Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. Artifact list live-refresh does not depend on that best-effort message; it listens to the registry registration event.
|
||||
|
||||
|
||||
@@ -87,13 +87,34 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi
|
||||
- For forensic reads, soft-deleted parents remain accessible through `readTaskFromDb(id, { includeDeleted: true })`.
|
||||
- Agent-facing tool layer (FN-7661): the `fn_task_archive` and `fn_task_delete` pi/CLI tools (`packages/cli/src/extension.ts`) both accept an optional `removeLineageReferences` boolean and forward it to `store.archiveTask` / `store.deleteTask`, so an agent that hits `TaskHasLineageChildrenError` can retry with `{ removeLineageReferences: true }` to clear the block — matching the recovery path the error message already advertises.
|
||||
|
||||
### Documents under soft-deleted tasks (FN-5140)
|
||||
### Documents under soft-deleted tasks (FN-5140, FX-005)
|
||||
|
||||
- Soft-deleting a task preserves its `task_documents` and `task_document_revisions` rows; document storage is not hard-deleted as part of `TaskStore.deleteTask`.
|
||||
- Normal live-reader APIs must hide those rows by enforcing the parent-task active filter through `ACTIVE_TASKS_WHERE`: `getAllDocuments`, `getTaskDocuments`, `getTaskDocument`, and `getTaskDocumentRevisions` all treat a soft-deleted parent as out of scope for ordinary reads.
|
||||
- The HTTP surface inherits the same contract: `GET /api/documents` excludes documents whose parent task is soft-deleted, while per-task document GET routes behave like "task not found" (`[]` for list/revisions and `404 Document not found` for the single-document read).
|
||||
- No public forensic flag is exposed on document read methods or routes. Forensic access remains an internal/operator concern via `readTaskFromDb(id, { includeDeleted: true })` plus direct SQL against the preserved document tables.
|
||||
- Write semantics stay intentionally asymmetric: `upsertTaskDocument` still refuses soft-deleted parents, while `deleteTaskDocument` remains allowed so forensic cleanup can scrub preserved document rows when needed.
|
||||
- Soft-deleting a task preserves its project-scoped `task_documents` and `task_document_revisions` rows; document storage is not hard-deleted as part of `TaskStore.deleteTask`.
|
||||
- Editable registries remain live-only: `getAllDocuments`, `getTaskDocuments`, `GET /api/documents`, and `GET /api/tasks/:id/documents` hide rows whose parent is archived or soft-deleted. Archived documents therefore do not reappear in dashboard desktop/mobile editors.
|
||||
- Direct named evidence reads include retained archived rows: `getTaskDocument` / `GET /api/tasks/:id/documents/:key` return the current document, and `getTaskDocumentRevisions` / `GET .../:key/revisions` return immutable history. Missing parents/keys remain `404` for current and `[]` for history; every predicate includes `project_id`.
|
||||
- Ordinary writes remain forbidden: `upsertTaskDocument`, `deleteTaskDocument`, comments, artifacts, task moves/updates, and agent `fn_task_document_write` tools cannot mutate an archived parent.
|
||||
|
||||
A single PostgreSQL-only exception, `publishArchivedTaskDocumentAddition` and `POST /api/tasks/:id/documents/:key/archived-publications`, appends an operator correction. It requires an existing project-scoped task tombstone with `column=archived` and non-null `deleted_at`, the matching `archive.archived_tasks` snapshot, and an existing current document. The request supplies non-empty `appendContent`, `author`, and `reason`, plus mandatory positive `expectedRevision` and canonical `expectedContentHash`; replacement `content`, metadata, and bypass fields are rejected.
|
||||
|
||||
Under one transaction Fusion locks the composite parent and current document, checks both FX-004 expectations, archives the exact previous current row, and writes `priorContent + "\\n\\n" + appendContent` without trimming or normalizing either content string. Creation identity and metadata remain intact, revision advances exactly once, and one concurrent publisher wins. A stale or duplicate retry returns `TASK_DOCUMENT_PRECONDITION_FAILED` with safe revision/hash details and creates no row or side effect.
|
||||
|
||||
The transaction changes only `task_documents`, `task_document_revisions`, and one `task-document:archived-addition-published` run-audit row. Audit metadata is ids/outcomes-only (`projectId`, `key`, previous/new revision, `reasonProvided`, `outcome`) and stores neither reason nor document content. Task timestamps/state, archive snapshots, mission/slice/feature/link state, comments, artifacts, citations, task events, workflow state, and scheduler wakeups are unchanged.
|
||||
|
||||
The HTTP publication route is available only when daemon bearer authentication is active and is never auth-exempt. `--no-auth` fails closed with `403`; malformed input is `400`, missing parent/document is `404`, non-archived or inconsistent retained state is `409`, and stale CAS is structured `409`. Server bearer middleware rejects absent/invalid credentials before the route. The API returns `201` only after the transaction commits.
|
||||
|
||||
### Conditional task-document writes
|
||||
|
||||
Current `TaskDocument` responses include `contentHash`, the SHA-256 digest of the exact UTF-8 content formatted as `sha256:<64 lowercase hex>`. Whitespace and line endings are significant; Fusion does not normalize either before hashing.
|
||||
|
||||
`TaskDocumentCreateInput`, `PUT /api/tasks/:id/documents/:key`, and the runtime document tools accept optional compare-and-swap expectations:
|
||||
|
||||
- omitted expectations preserve legacy unconditional writes;
|
||||
- `expectedRevision: 0` requires the document not to exist;
|
||||
- a positive `expectedRevision` requires an existing equal revision;
|
||||
- `expectedContentHash` requires an existing document with an equal canonical hash;
|
||||
- when both are present, both must match. Negative/fractional revisions and non-canonical hashes are validation errors.
|
||||
|
||||
The PostgreSQL writer locks the active `(project_id, task_id)` parent row before reading `(project_id, task_id, key)`. The comparison, exact prior-snapshot archive, and current-row replacement occur in one transaction. Thus concurrent creates or updates from the same baseline have exactly one conditional winner. A stale writer receives `TASK_DOCUMENT_PRECONDITION_FAILED` with safe identity, supplied expectations, and current revision/hash (or `null` for absence); it creates no revision, current mutation, task event, citation scan, or success response. Document content is never included in conflict details.
|
||||
|
||||
### Artifact registry (FN-6777)
|
||||
|
||||
|
||||
@@ -648,10 +648,13 @@ Behavior:
|
||||
- Dashboard delete confirmations for live tasks include an **Archive Instead** action so users can preserve history without soft-deleting the task.
|
||||
- Archived tasks can also be deleted from the dashboard/API/CLI. Deleting an archived task removes the archived snapshot from lists and search, but first materializes the normal soft-delete tombstone so the task ID remains reserved unless the operator explicitly chooses allow-resurrection behavior.
|
||||
- Cleanup mode can persist compact metadata and remove the task directory
|
||||
- Archived tasks are read-only for task log/document writes:
|
||||
- Archived tasks remain read-only for ordinary task log/document writes:
|
||||
- `logEntry()` throws `Task <id> is archived — logging is read-only`
|
||||
- `upsertTaskDocument()` throws `Task <id> is archived — documents are read-only`
|
||||
- `upsertTaskDocument()` and `deleteTaskDocument()` reject archived parents
|
||||
- `fn_task_log` returns `ERROR: Cannot log to archived task — this task is read-only`
|
||||
- task-bound and chat/planning `fn_task_document_write` continue to use ordinary upsert and cannot publish archived corrections
|
||||
- Direct reads of a retained named document and its revisions remain available for historical evidence, while list/global document registries stay live-only.
|
||||
- The sole immutability exception is authenticated operator HTTP `POST /api/tasks/:id/documents/:key/archived-publications`. It can only append `"\n\n" + appendContent` after matching mandatory revision/hash CAS against a consistent PostgreSQL tombstone plus archive snapshot. It cannot replace content or metadata, restore/move/update the task, change archive/mission/link state, emit citations/task events, or wake execution. Fusion launched with `--no-auth` rejects this capability.
|
||||
|
||||
### Cleanup behavior
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ This reference documents tools injected by the engine at runtime for specific ag
|
||||
|---|---|---|---|
|
||||
| `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) |
|
||||
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
|
||||
| `fn_task_document_write` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string); chat/planning also require `task_id` (string) |
|
||||
| `fn_task_document_read` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Read one task document or list all | `key?` (string); chat/planning also require `task_id` (string) |
|
||||
| `fn_task_document_write` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Save/update a named **live-task** document revision, optionally with CAS; archived parents remain read-only | `key` (string), `content` (string), `author?` (string), `expected_revision?` (non-negative integer), `expected_content_hash?` (`sha256:<64 lowercase hex>`); chat/planning also require `task_id` (string) |
|
||||
| `fn_task_document_read` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Read one named live or retained archived document; list mode remains live-only | `key?` (string); chat/planning also require `task_id` (string) |
|
||||
|
||||
For cross-task publication, read first and pass both returned values when practical: `{ "task_id": "FX-002", "key": "evidence", "content": "rebased evidence", "expected_revision": 3, "expected_content_hash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }`. Revision zero is create-if-absent. A stale write is an error result with `TASK_DOCUMENT_PRECONDITION_FAILED` and current revision/hash; re-read and explicitly rebase rather than retrying unchanged. Omitted expectations preserve unconditional compatibility.
|
||||
|
||||
Archived publication is deliberately absent from every runtime tool schema: there is no append, `allowArchived`, force, or replacement path in `fn_task_document_write`. Operators use the daemon-bearer-authenticated `POST /api/tasks/:id/documents/:key/archived-publications` API; `--no-auth` fails closed. Agent tools may read a retained archived document only when an explicit key is supplied, while keyless list mode continues to hide archived registries.
|
||||
| `fn_task_prompt_write` | plan/spec review (Plan Review reviewer) | Replace the task's authoritative PROMPT.md with revised plan/spec content during Plan Review/spec repair; routed through TaskStore so PROMPT.md validation and task.json sync stay the single persistence path. Provide the complete final PROMPT.md content; do not implement product code from plan review | `content` (string) |
|
||||
| `fn_goal_list` | triage, executor, heartbeat | List goals with concise citation-ready snippets and active-goal warning details | `status?` (`active` \| `archived` \| `all`) |
|
||||
| `fn_goal_show` | triage, executor, heartbeat | Show one goal's full detail on demand, including the full description body | `id` (string) |
|
||||
|
||||
@@ -60,6 +60,8 @@ pgDescribe("TaskStore PostgreSQL safe-default removal", () => {
|
||||
await store.archiveTask(task.id, { cleanup: false });
|
||||
|
||||
await expect(store.logEntry(task.id, "must reject")).rejects.toThrow(/archived.*read-only/);
|
||||
await expect(store.moveTask(task.id, "todo")).rejects.toThrow(/archived|soft-deleted|not found/);
|
||||
await expect(store.updateTask(task.id, { priority: "high" })).rejects.toThrow(/archived|soft-deleted|not found/);
|
||||
await expect(store.addComment(task.id, "must reject", "user")).rejects.toThrow(/archived.*read-only/);
|
||||
await expect(store.updateTaskComment(task.id, commentId!, "must reject")).rejects.toThrow(/archived.*read-only/);
|
||||
await expect(store.deleteTaskComment(task.id, commentId!)).rejects.toThrow(/archived.*read-only/);
|
||||
@@ -76,6 +78,28 @@ pgDescribe("TaskStore PostgreSQL safe-default removal", () => {
|
||||
})).rejects.toThrow(/archived.*read-only/);
|
||||
expect(await store.getTaskDocuments(task.id)).toEqual([]);
|
||||
expect(await store.getArtifacts(task.id)).toEqual([]);
|
||||
|
||||
const retained = await store.getTaskDocument(task.id, "spec");
|
||||
expect(retained).toMatchObject({ content: "before archive", revision: 1 });
|
||||
expect(await store.getTaskDocumentRevisions(task.id, "spec")).toEqual([]);
|
||||
const published = await store.publishArchivedTaskDocumentAddition(task.id, {
|
||||
key: "spec",
|
||||
appendContent: "operator correction",
|
||||
expectedRevision: retained!.revision,
|
||||
expectedContentHash: retained!.contentHash,
|
||||
author: "operator",
|
||||
reason: "Correct retained evidence",
|
||||
});
|
||||
expect(published.document).toMatchObject({
|
||||
content: "before archive\n\noperator correction",
|
||||
revision: 2,
|
||||
author: "operator",
|
||||
});
|
||||
expect(await store.getTaskDocumentRevisions(task.id, "spec")).toMatchObject([
|
||||
{ content: "before archive", revision: 1 },
|
||||
]);
|
||||
expect(await store.getTaskDocuments(task.id)).toEqual([]);
|
||||
await expect(store.upsertTaskDocument(task.id, { key: "spec", content: "still rejected" })).rejects.toThrow(/archived.*read-only/);
|
||||
});
|
||||
|
||||
it("runs plugin schema initialization through the PostgreSQL executor without opening SQLite", async () => {
|
||||
|
||||
@@ -31,6 +31,11 @@ import type { ResolvedBackend } from "../../postgres/backend-resolver.js";
|
||||
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import { insertTaskRow, softDeleteTaskRow } from "../../task-store/async-persistence.js";
|
||||
import {
|
||||
ArchivedTaskDocumentPublicationRejectedError,
|
||||
TaskDocumentPreconditionFailedError,
|
||||
taskDocumentContentHash,
|
||||
} from "../../task-document-concurrency.js";
|
||||
import {
|
||||
upsertArchivedTaskEntry,
|
||||
findArchivedTaskEntry,
|
||||
@@ -69,6 +74,8 @@ import {
|
||||
} from "../../task-store/async-audit.js";
|
||||
import {
|
||||
getTaskDocument,
|
||||
getTaskDocumentRevisions,
|
||||
publishArchivedTaskDocumentAddition,
|
||||
upsertTaskDocument,
|
||||
listTaskDocuments,
|
||||
insertArtifactRow,
|
||||
@@ -347,6 +354,103 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
expect(docs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enforces task-document CAS atomically for creates and updates", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-DOC-CAS"), { lineageId: null });
|
||||
|
||||
expect(taskDocumentContentHash("line 1\r\nline 2")).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(taskDocumentContentHash("line 1\r\nline 2")).not.toBe(taskDocumentContentHash("line 1\nline 2"));
|
||||
await expect(upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "invalid",
|
||||
content: "x",
|
||||
expectedRevision: -1,
|
||||
})).rejects.toThrow(/non-negative integer/);
|
||||
await expect(upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "invalid",
|
||||
content: "x",
|
||||
expectedContentHash: "sha256:ABC",
|
||||
})).rejects.toThrow(/64 lowercase hex/);
|
||||
|
||||
await expect(upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "missing", content: "x", expectedRevision: 1,
|
||||
})).rejects.toBeInstanceOf(TaskDocumentPreconditionFailedError);
|
||||
await expect(upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "missing", content: "x", expectedContentHash: taskDocumentContentHash("x"),
|
||||
})).rejects.toBeInstanceOf(TaskDocumentPreconditionFailedError);
|
||||
|
||||
const createRace = await Promise.allSettled([
|
||||
upsertTaskDocument(ctx.layer, "KB-DOC-CAS", { key: "evidence", content: "create-a", expectedRevision: 0 }),
|
||||
upsertTaskDocument(ctx.layer, "KB-DOC-CAS", { key: "evidence", content: "create-b", expectedRevision: 0 }),
|
||||
]);
|
||||
expect(createRace.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
const createLoser = createRace.find((result) => result.status === "rejected");
|
||||
expect(createLoser).toMatchObject({ reason: expect.any(TaskDocumentPreconditionFailedError) });
|
||||
|
||||
const created = await getTaskDocument(ctx.layer.db, "KB-DOC-CAS", "evidence", TEST_PROJECT_ID);
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.contentHash).toBe(taskDocumentContentHash(created!.content));
|
||||
let history = await ctx.layer.db.select().from(schema.project.taskDocumentRevisions).where(eq(schema.project.taskDocumentRevisions.taskId, "KB-DOC-CAS"));
|
||||
expect(history).toHaveLength(0);
|
||||
|
||||
const baseRevision = created!.revision;
|
||||
const baseHash = created!.contentHash;
|
||||
const updateRace = await Promise.allSettled([
|
||||
upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "winner-a", expectedRevision: baseRevision, expectedContentHash: baseHash,
|
||||
}),
|
||||
upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "winner-b", expectedRevision: baseRevision, expectedContentHash: baseHash,
|
||||
}),
|
||||
]);
|
||||
expect(updateRace.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
const updateLoser = updateRace.find((result) => result.status === "rejected") as PromiseRejectedResult;
|
||||
expect(updateLoser.reason).toBeInstanceOf(TaskDocumentPreconditionFailedError);
|
||||
expect((updateLoser.reason as TaskDocumentPreconditionFailedError).toDetails()).toMatchObject({
|
||||
code: "TASK_DOCUMENT_PRECONDITION_FAILED",
|
||||
projectId: TEST_PROJECT_ID,
|
||||
taskId: "KB-DOC-CAS",
|
||||
key: "evidence",
|
||||
expectedRevision: baseRevision,
|
||||
expectedContentHash: baseHash,
|
||||
currentRevision: baseRevision + 1,
|
||||
});
|
||||
expect((updateLoser.reason as TaskDocumentPreconditionFailedError).toDetails()).not.toHaveProperty("content");
|
||||
|
||||
const current = await getTaskDocument(ctx.layer.db, "KB-DOC-CAS", "evidence", TEST_PROJECT_ID);
|
||||
expect(current?.revision).toBe(baseRevision + 1);
|
||||
expect(["winner-a", "winner-b"]).toContain(current?.content);
|
||||
history = await ctx.layer.db.select().from(schema.project.taskDocumentRevisions).where(eq(schema.project.taskDocumentRevisions.taskId, "KB-DOC-CAS"));
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({ revision: baseRevision, content: created!.content });
|
||||
|
||||
await expect(upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "stale-revision", expectedRevision: baseRevision,
|
||||
})).rejects.toBeInstanceOf(TaskDocumentPreconditionFailedError);
|
||||
await expect(upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "stale-hash", expectedContentHash: baseHash,
|
||||
})).rejects.toBeInstanceOf(TaskDocumentPreconditionFailedError);
|
||||
let unchangedHistory = await ctx.layer.db.select().from(schema.project.taskDocumentRevisions).where(eq(schema.project.taskDocumentRevisions.taskId, "KB-DOC-CAS"));
|
||||
expect(unchangedHistory).toHaveLength(1);
|
||||
|
||||
const revisionOnly = await upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "revision-only", expectedRevision: current!.revision,
|
||||
});
|
||||
const hashOnly = await upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "hash-only", expectedContentHash: revisionOnly.contentHash,
|
||||
});
|
||||
const identicalRace = await Promise.allSettled([
|
||||
upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "same-content", expectedRevision: hashOnly.revision, expectedContentHash: hashOnly.contentHash,
|
||||
}),
|
||||
upsertTaskDocument(ctx.layer, "KB-DOC-CAS", {
|
||||
key: "evidence", content: "same-content", expectedRevision: hashOnly.revision, expectedContentHash: hashOnly.contentHash,
|
||||
}),
|
||||
]);
|
||||
expect(identicalRace.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
unchangedHistory = await ctx.layer.db.select().from(schema.project.taskDocumentRevisions).where(eq(schema.project.taskDocumentRevisions.taskId, "KB-DOC-CAS"));
|
||||
expect(unchangedHistory).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("artifacts round-trip on active tasks (register + read)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-ART-RT"), { lineageId: null });
|
||||
@@ -386,6 +490,124 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
).rejects.toThrow(/archived|not found/);
|
||||
});
|
||||
|
||||
it("reads retained archived documents and serializes exactly one additive CAS publisher", async () => {
|
||||
ctx = await setupCtx();
|
||||
const taskId = "KB-ARCH-PUBLISH";
|
||||
const task = makeMinimalTask(taskId);
|
||||
await insertTaskRow(ctx.layer, task, { lineageId: null });
|
||||
await upsertTaskDocument(ctx.layer, taskId, { key: "docs", content: "original", author: "author-1" });
|
||||
const prior = await upsertTaskDocument(ctx.layer, taskId, {
|
||||
key: "docs",
|
||||
content: "original\r\ncurrent ",
|
||||
author: "author-2",
|
||||
metadata: { retained: true },
|
||||
});
|
||||
const archivedAt = new Date().toISOString();
|
||||
await softDeleteTaskRow(ctx.layer, taskId, archivedAt);
|
||||
await upsertArchivedTaskEntry(ctx.layer.db, {
|
||||
id: taskId,
|
||||
title: "Archived publisher",
|
||||
description: "test task",
|
||||
archivedAt,
|
||||
createdAt: String(task.createdAt),
|
||||
updatedAt: String(task.updatedAt),
|
||||
}, TEST_PROJECT_ID);
|
||||
|
||||
const taskBefore = await ctx.layer.db.select().from(schema.project.tasks).where(eq(schema.project.tasks.id, taskId));
|
||||
const archiveBefore = await ctx.layer.db.select().from(schema.archive.archivedTasks).where(eq(schema.archive.archivedTasks.id, taskId));
|
||||
expect(await getTaskDocument(ctx.layer.db, taskId, "docs", TEST_PROJECT_ID)).toMatchObject({
|
||||
content: prior.content,
|
||||
revision: prior.revision,
|
||||
});
|
||||
expect(await getTaskDocumentRevisions(ctx.layer.db, taskId, "docs", TEST_PROJECT_ID)).toMatchObject([
|
||||
{ content: "original", revision: 1 },
|
||||
]);
|
||||
expect(await listTaskDocuments(ctx.layer.db, taskId, TEST_PROJECT_ID)).toEqual([]);
|
||||
expect(await getTaskDocument(ctx.layer.db, taskId, "docs", "project-other")).toBeNull();
|
||||
expect(await getTaskDocumentRevisions(ctx.layer.db, taskId, "docs", "project-other")).toEqual([]);
|
||||
|
||||
const input = {
|
||||
key: "docs",
|
||||
appendContent: "correction\nbytes",
|
||||
expectedRevision: prior.revision,
|
||||
expectedContentHash: prior.contentHash,
|
||||
author: "operator",
|
||||
reason: "Correct retained evidence",
|
||||
};
|
||||
const race = await Promise.allSettled([
|
||||
publishArchivedTaskDocumentAddition(ctx.layer, taskId, input),
|
||||
publishArchivedTaskDocumentAddition(ctx.layer, taskId, input),
|
||||
]);
|
||||
const winners = race.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof publishArchivedTaskDocumentAddition>>> => result.status === "fulfilled");
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(race.find((result) => result.status === "rejected")).toMatchObject({
|
||||
reason: expect.any(TaskDocumentPreconditionFailedError),
|
||||
});
|
||||
const expectedContent = prior.content + "\n\n" + input.appendContent;
|
||||
expect(winners[0]?.value).toMatchObject({
|
||||
document: { content: expectedContent, revision: prior.revision + 1, author: "operator", metadata: { retained: true } },
|
||||
previousRevision: prior.revision,
|
||||
previousContentHash: prior.contentHash,
|
||||
appendedContentHash: taskDocumentContentHash(expectedContent),
|
||||
});
|
||||
|
||||
const history = await getTaskDocumentRevisions(ctx.layer.db, taskId, "docs", TEST_PROJECT_ID);
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ content: prior.content, revision: prior.revision, author: prior.author, metadata: { retained: true } }),
|
||||
expect.objectContaining({ content: "original", revision: 1 }),
|
||||
]));
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, taskId, input)).rejects.toBeInstanceOf(TaskDocumentPreconditionFailedError);
|
||||
expect(await getTaskDocumentRevisions(ctx.layer.db, taskId, "docs", TEST_PROJECT_ID)).toHaveLength(2);
|
||||
expect(await ctx.layer.db.select().from(schema.project.tasks).where(eq(schema.project.tasks.id, taskId))).toEqual(taskBefore);
|
||||
expect(await ctx.layer.db.select().from(schema.archive.archivedTasks).where(eq(schema.archive.archivedTasks.id, taskId))).toEqual(archiveBefore);
|
||||
|
||||
const audit = await queryRunAuditEvents(ctx.layer.db, { taskId });
|
||||
const publicationEvents = audit.filter((event) => event.mutationType === "task-document:archived-addition-published");
|
||||
expect(publicationEvents).toHaveLength(1);
|
||||
expect(publicationEvents[0]?.metadata).toMatchObject({
|
||||
projectId: TEST_PROJECT_ID,
|
||||
key: "docs",
|
||||
previousRevision: prior.revision,
|
||||
revision: prior.revision + 1,
|
||||
reasonProvided: true,
|
||||
outcome: "published",
|
||||
});
|
||||
expect(JSON.stringify(publicationEvents[0])).not.toContain(input.reason);
|
||||
expect(JSON.stringify(publicationEvents[0])).not.toContain(input.appendContent);
|
||||
});
|
||||
|
||||
it("rejects malformed, live, missing, and inconsistent archived publication parents", async () => {
|
||||
ctx = await setupCtx();
|
||||
const taskId = "KB-ARCH-REJECT";
|
||||
const task = makeMinimalTask(taskId);
|
||||
await insertTaskRow(ctx.layer, task, { lineageId: null });
|
||||
const document = await upsertTaskDocument(ctx.layer, taskId, { key: "docs", content: "base" });
|
||||
const valid = {
|
||||
key: "docs",
|
||||
appendContent: "addition",
|
||||
expectedRevision: document.revision,
|
||||
expectedContentHash: document.contentHash,
|
||||
author: "operator",
|
||||
reason: "reason",
|
||||
};
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, taskId, valid)).rejects.toMatchObject({
|
||||
reason: "parent-not-archived",
|
||||
});
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, "KB-MISSING", valid)).rejects.toMatchObject({
|
||||
reason: "parent-not-found",
|
||||
});
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, taskId, { ...valid, appendContent: "" })).rejects.toThrow(/non-empty/);
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, taskId, { ...valid, expectedRevision: 0 })).rejects.toThrow(/positive integer/);
|
||||
await softDeleteTaskRow(ctx.layer, taskId, new Date().toISOString());
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, taskId, valid)).rejects.toBeInstanceOf(ArchivedTaskDocumentPublicationRejectedError);
|
||||
await expect(publishArchivedTaskDocumentAddition(ctx.layer, taskId, valid)).rejects.toMatchObject({
|
||||
reason: "archived-state-inconsistent",
|
||||
});
|
||||
expect(await getTaskDocument(ctx.layer.db, taskId, "missing", TEST_PROJECT_ID)).toBeNull();
|
||||
expect(await getTaskDocumentRevisions(ctx.layer.db, "KB-MISSING", "docs", TEST_PROJECT_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Audit mutations and run-audit events commit/roll back together ──
|
||||
|
||||
it("activity log entries round-trip (record + query)", async () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { and, eq, isNull, ne, sql } from "drizzle-orm";
|
||||
import * as schema from "./postgres/schema/index.js";
|
||||
import { type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrThreadState, PrThreadOutcome, PluginActivation, PluginActivationInput } from "./types.js";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrThreadState, PrThreadOutcome, PluginActivation, PluginActivationInput } from "./types.js";
|
||||
|
||||
|
||||
export type OverlapBlockerRepairReason =
|
||||
@@ -127,7 +127,7 @@ import { refineTaskImpl, updateTaskDependenciesImpl } from "./task-store/update-
|
||||
import { createWorkflowStepImpl, updateWorkflowStepImpl, updateWorkflowDefinitionImpl, deleteWorkflowDefinitionImpl, setDefaultWorkflowIdImpl, selectTaskWorkflowImpl } from "./task-store/workflow-ops.js";
|
||||
import { initImpl, setupActivityLogListenersImpl, reconcileOrphanedTaskDirsImpl, watchImpl, checkForChangesImpl, migrateAgentLogEntriesImpl, migrateMovedSettingsImpl, recoverStaleTransitionPendingImpl, migrateLegacyWorkflowStepsImpl, emitTaskLifecycleEventSafelyImpl } from "./task-store/lifecycle-ops.js";
|
||||
import { updateStepImpl, acquireMergeQueueLeaseImpl, mergeTaskImpl } from "./task-store/merge-queue-ops.js";
|
||||
import { addCommentImpl, upsertTaskDocumentImpl } from "./task-store/comments-ops.js";
|
||||
import { addCommentImpl, publishArchivedTaskDocumentAdditionImpl, upsertTaskDocumentImpl } from "./task-store/comments-ops.js";
|
||||
import { deleteTaskImpl, deleteTaskIfImpl, archiveTaskImpl, type DeleteTaskIfResult } from "./task-store/archive-lifecycle.js";
|
||||
import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js";
|
||||
import { createTaskBackendImpl, _createTaskInternalBackendImpl, createTaskImpl, createTaskWithReservedIdImpl, _createTaskInternalImpl, _maybeAutoArchiveSameAgentDuplicateImpl } from "./task-store/task-creation.js";
|
||||
@@ -2152,6 +2152,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
async upsertTaskDocument(taskId: string, input: TaskDocumentCreateInput): Promise<TaskDocument> {
|
||||
return upsertTaskDocumentImpl(this, taskId, input);
|
||||
}
|
||||
async publishArchivedTaskDocumentAddition(
|
||||
taskId: string,
|
||||
input: ArchivedTaskDocumentAdditionInput,
|
||||
): Promise<ArchivedTaskDocumentAdditionResult> {
|
||||
return publishArchivedTaskDocumentAdditionImpl(this, taskId, input);
|
||||
}
|
||||
|
||||
/** List archived revisions for a task document, newest first. */
|
||||
async getTaskDocumentRevisions( taskId: string, key: string, options?: { limit?: number }, ): Promise<TaskDocumentRevision[]> {
|
||||
|
||||
159
packages/core/src/task-document-concurrency.ts
Normal file
159
packages/core/src/task-document-concurrency.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,22 @@
|
||||
import { and, desc, eq, ilike, isNull, or } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as schema from "../postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
|
||||
import { recordRunAuditEventWithinTransaction, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js";
|
||||
import { ACTIVE_TASK_FILTER } from "./async-persistence.js";
|
||||
import { projectPartition } from "./async-lifecycle.js";
|
||||
import {
|
||||
ARCHIVED_TASK_DOCUMENT_ADDITION_BOUNDARY,
|
||||
ArchivedTaskDocumentPublicationRejectedError,
|
||||
assertTaskDocumentPreconditions,
|
||||
taskDocumentContentHash,
|
||||
validateArchivedTaskDocumentAddition,
|
||||
} from "../task-document-concurrency.js";
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactCreateInput,
|
||||
ArtifactWithTask,
|
||||
ArchivedTaskDocumentAdditionInput,
|
||||
ArchivedTaskDocumentAdditionResult,
|
||||
TaskDocument,
|
||||
TaskDocumentCreateInput,
|
||||
TaskDocumentWithTask,
|
||||
@@ -60,6 +69,7 @@ function rowToTaskDocument(row: TaskDocumentRow): TaskDocument {
|
||||
key: row.key,
|
||||
content: row.content,
|
||||
revision: row.revision,
|
||||
contentHash: taskDocumentContentHash(row.content),
|
||||
author: row.author,
|
||||
metadata: metadata ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
@@ -137,10 +147,9 @@ export async function getLiveTaskColumn(
|
||||
|
||||
/**
|
||||
* FNXC:TaskStoreCommentsAttachments 2026-06-24-09:40:
|
||||
* Read a task document by (taskId, key). Returns `null` if not found or if the
|
||||
* parent task is archived/soft-deleted (documents are read-only on archived
|
||||
* tasks and hidden from live views). This is the async equivalent of
|
||||
* `getTaskDocument`.
|
||||
* Read a task document by (taskId, key). Direct named reads include retained
|
||||
* archived documents; missing parents and keys return `null`. Editable list
|
||||
* surfaces remain live-only through `listTaskDocuments`.
|
||||
*/
|
||||
export async function getTaskDocument(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
@@ -148,15 +157,15 @@ export async function getTaskDocument(
|
||||
key: string,
|
||||
projectId?: string,
|
||||
): Promise<TaskDocument | null> {
|
||||
// Gate on the parent task being live.
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId);
|
||||
if (column === null || column === "archived") return null;
|
||||
if (column === null) return null;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.taskDocuments)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.project.taskDocuments.projectId, projectPartition(projectId)),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, key),
|
||||
),
|
||||
@@ -186,24 +195,36 @@ export async function upsertTaskDocument(
|
||||
input: TaskDocumentCreateInput,
|
||||
): Promise<TaskDocument> {
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
// Gate: reject writes against archived/soft-deleted/absent tasks.
|
||||
const column = await getLiveTaskColumn(tx, taskId, layer.projectId);
|
||||
if (column === "archived") {
|
||||
const projectId = projectPartition(layer.projectId);
|
||||
/*
|
||||
FNXC:TaskDocumentCAS 2026-07-20-11:06:
|
||||
Every writer locks the active project's parent task row before reading a document. This serializes both existing-row updates and absent-row creates for every (project_id, task_id, key), so a precondition check, prior-snapshot archive, and replacement are one PostgreSQL transaction. A mismatch throws before history/current mutation; the facade consequently emits no task update and performs no citation scan.
|
||||
*/
|
||||
const taskRows = await tx
|
||||
.select({ column: schema.project.tasks.column, deletedAt: schema.project.tasks.deletedAt })
|
||||
.from(schema.project.tasks)
|
||||
.where(and(
|
||||
eq(schema.project.tasks.projectId, projectId),
|
||||
eq(schema.project.tasks.id, taskId),
|
||||
))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
const task = taskRows[0];
|
||||
if (task?.column === "archived" || task?.deletedAt != null) {
|
||||
throw new Error(`Task ${taskId} is archived — documents are read-only`);
|
||||
}
|
||||
if (column === null) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
if (!task) throw new Error(`Task ${taskId} not found`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const author = input.author ?? "user";
|
||||
|
||||
// Read the existing document (if any).
|
||||
// Read after taking the parent lock, then compare before any mutation.
|
||||
const existingRows = await tx
|
||||
.select()
|
||||
.from(schema.project.taskDocuments)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.project.taskDocuments.projectId, projectId),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, input.key),
|
||||
),
|
||||
@@ -211,9 +232,16 @@ export async function upsertTaskDocument(
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as TaskDocumentRow | undefined;
|
||||
|
||||
assertTaskDocumentPreconditions(
|
||||
{ projectId, taskId, key: input.key },
|
||||
{ expectedRevision: input.expectedRevision, expectedContentHash: input.expectedContentHash },
|
||||
existing ? { revision: existing.revision, content: existing.content } : null,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Archive the previous revision.
|
||||
await tx.insert(schema.project.taskDocumentRevisions).values({
|
||||
projectId,
|
||||
taskId,
|
||||
key: input.key,
|
||||
content: existing.content,
|
||||
@@ -235,6 +263,7 @@ export async function upsertTaskDocument(
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.project.taskDocuments.projectId, projectId),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, input.key),
|
||||
),
|
||||
@@ -242,6 +271,7 @@ export async function upsertTaskDocument(
|
||||
} else {
|
||||
// Insert a new document.
|
||||
await tx.insert(schema.project.taskDocuments).values({
|
||||
projectId,
|
||||
id: randomUUID(),
|
||||
taskId,
|
||||
key: input.key,
|
||||
@@ -260,6 +290,7 @@ export async function upsertTaskDocument(
|
||||
.from(schema.project.taskDocuments)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.project.taskDocuments.projectId, projectId),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, input.key),
|
||||
),
|
||||
@@ -273,6 +304,127 @@ export async function upsertTaskDocument(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArchivedTaskDocumentPublication 2026-07-20-15:36:
|
||||
* This is the sole PostgreSQL mutation allowed for a retained archived document. It locks the project-scoped parent and current document, requires the tombstone and cold archive snapshot to agree, checks both FX-004 CAS values before writing, archives the exact prior current row, and appends a fixed two-newline boundary plus caller bytes. Audit commits in the same transaction and stores no correction or reason prose. No task, archive, mission, citation, event, or scheduler row is touched.
|
||||
*/
|
||||
export async function publishArchivedTaskDocumentAddition(
|
||||
layer: AsyncDataLayer,
|
||||
taskId: string,
|
||||
input: ArchivedTaskDocumentAdditionInput,
|
||||
): Promise<ArchivedTaskDocumentAdditionResult> {
|
||||
validateArchivedTaskDocumentAddition(input);
|
||||
return layer.transactionImmediate(async (tx) => {
|
||||
const projectId = projectPartition(layer.projectId);
|
||||
const taskRows = await tx
|
||||
.select({ column: schema.project.tasks.column, deletedAt: schema.project.tasks.deletedAt })
|
||||
.from(schema.project.tasks)
|
||||
.where(and(
|
||||
eq(schema.project.tasks.projectId, projectId),
|
||||
eq(schema.project.tasks.id, taskId),
|
||||
))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
const task = taskRows[0];
|
||||
if (!task) {
|
||||
throw new ArchivedTaskDocumentPublicationRejectedError("parent-not-found", projectId, taskId, input.key);
|
||||
}
|
||||
if (task.column !== "archived" && task.deletedAt == null) {
|
||||
throw new ArchivedTaskDocumentPublicationRejectedError("parent-not-archived", projectId, taskId, input.key);
|
||||
}
|
||||
|
||||
const archiveRows = await tx
|
||||
.select({ id: schema.archive.archivedTasks.id })
|
||||
.from(schema.archive.archivedTasks)
|
||||
.where(and(
|
||||
eq(schema.archive.archivedTasks.projectId, projectId),
|
||||
eq(schema.archive.archivedTasks.id, taskId),
|
||||
))
|
||||
.limit(1)
|
||||
.for("key share");
|
||||
if (task.column !== "archived" || task.deletedAt == null || !archiveRows[0]) {
|
||||
throw new ArchivedTaskDocumentPublicationRejectedError("archived-state-inconsistent", projectId, taskId, input.key);
|
||||
}
|
||||
|
||||
const existingRows = await tx
|
||||
.select()
|
||||
.from(schema.project.taskDocuments)
|
||||
.where(and(
|
||||
eq(schema.project.taskDocuments.projectId, projectId),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, input.key),
|
||||
))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
const existing = existingRows[0] as TaskDocumentRow | undefined;
|
||||
if (!existing) {
|
||||
throw new ArchivedTaskDocumentPublicationRejectedError("document-not-found", projectId, taskId, input.key);
|
||||
}
|
||||
|
||||
assertTaskDocumentPreconditions(
|
||||
{ projectId, taskId, key: input.key },
|
||||
{ expectedRevision: input.expectedRevision, expectedContentHash: input.expectedContentHash },
|
||||
{ revision: existing.revision, content: existing.content },
|
||||
);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const content = existing.content + ARCHIVED_TASK_DOCUMENT_ADDITION_BOUNDARY + input.appendContent;
|
||||
const nextRevision = existing.revision + 1;
|
||||
await tx.insert(schema.project.taskDocumentRevisions).values({
|
||||
projectId,
|
||||
taskId,
|
||||
key: input.key,
|
||||
content: existing.content,
|
||||
revision: existing.revision,
|
||||
author: existing.author,
|
||||
metadata: existing.metadata ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
await tx
|
||||
.update(schema.project.taskDocuments)
|
||||
.set({ content, revision: nextRevision, author: input.author, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.project.taskDocuments.projectId, projectId),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, input.key),
|
||||
));
|
||||
await recordRunAuditEventWithinTransaction(tx, {
|
||||
taskId,
|
||||
agentId: input.author,
|
||||
runId: `archived-document-publication:${randomUUID()}`,
|
||||
domain: "database",
|
||||
mutationType: "task-document:archived-addition-published",
|
||||
target: `${taskId}:${input.key}`,
|
||||
metadata: {
|
||||
projectId,
|
||||
key: input.key,
|
||||
previousRevision: existing.revision,
|
||||
revision: nextRevision,
|
||||
reasonProvided: true,
|
||||
outcome: "published",
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(schema.project.taskDocuments)
|
||||
.where(and(
|
||||
eq(schema.project.taskDocuments.projectId, projectId),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
eq(schema.project.taskDocuments.key, input.key),
|
||||
))
|
||||
.limit(1);
|
||||
const row = rows[0] as TaskDocumentRow | undefined;
|
||||
if (!row) throw new Error(`Failed to publish archived document addition for ${taskId}/${input.key}`);
|
||||
return {
|
||||
document: rowToTaskDocument(row),
|
||||
previousRevision: existing.revision,
|
||||
previousContentHash: taskDocumentContentHash(existing.content),
|
||||
appendedContentHash: taskDocumentContentHash(content),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all documents for a LIVE parent task (archived/soft-deleted parents
|
||||
* return an empty list). This is the async equivalent of the sync
|
||||
@@ -289,13 +441,16 @@ export async function listTaskDocuments(
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.taskDocuments)
|
||||
.where(eq(schema.project.taskDocuments.taskId, taskId));
|
||||
.where(and(
|
||||
eq(schema.project.taskDocuments.projectId, projectPartition(projectId)),
|
||||
eq(schema.project.taskDocuments.taskId, taskId),
|
||||
));
|
||||
return (rows as TaskDocumentRow[]).map((row) => rowToTaskDocument(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* List archived revisions for a task document, newest first. Only returns
|
||||
* revisions for a LIVE parent task.
|
||||
* List archived revisions for a task document, newest first. Direct history
|
||||
* reads include retained archived parents while missing parents remain empty.
|
||||
*/
|
||||
export async function getTaskDocumentRevisions(
|
||||
db: AsyncDataLayer["db"] | DbTransaction,
|
||||
@@ -304,13 +459,14 @@ export async function getTaskDocumentRevisions(
|
||||
projectId?: string,
|
||||
): Promise<TaskDocumentRevisionRow[]> {
|
||||
const column = await getLiveTaskColumn(db, taskId, projectId);
|
||||
if (column === null || column === "archived") return [];
|
||||
if (column === null) return [];
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.project.taskDocumentRevisions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.project.taskDocumentRevisions.projectId, projectPartition(projectId)),
|
||||
eq(schema.project.taskDocumentRevisions.taskId, taskId),
|
||||
eq(schema.project.taskDocumentRevisions.key, key),
|
||||
),
|
||||
|
||||
@@ -11,12 +11,13 @@ import {randomUUID} from "node:crypto";
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {join} from "node:path";
|
||||
import {existsSync} from "node:fs";
|
||||
import type {Task, Column, TaskDocument, TaskDocumentCreateInput, TaskLogEntry, RunMutationContext} from "../types.js";
|
||||
import type {ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, Task, Column, TaskDocument, TaskDocumentCreateInput, TaskLogEntry, RunMutationContext} from "../types.js";
|
||||
import {validateDocumentKey} from "../types.js";
|
||||
import {ArchivedTaskDocumentPublicationRejectedError, validateArchivedTaskDocumentAddition, validateTaskDocumentPreconditions} from "../task-document-concurrency.js";
|
||||
import "../builtin-traits.js";
|
||||
import {toJsonNullable} from "../db.js";
|
||||
import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub} from "../task-store/comments.js";
|
||||
import {getLiveTaskColumn, upsertTaskDocument as upsertTaskDocumentAsync} from "../task-store/async-comments-attachments.js";
|
||||
import {getLiveTaskColumn, publishArchivedTaskDocumentAddition as publishArchivedTaskDocumentAdditionAsync, upsertTaskDocument as upsertTaskDocumentAsync} from "../task-store/async-comments-attachments.js";
|
||||
import type {TaskDocumentRow} from "../task-store/row-types.js";
|
||||
|
||||
export async function addCommentImpl(store: TaskStore, id: string, text: string, author: string = "user", options?: { skipRefinement?: boolean; source?: "user" | "agent" | "github-review" | "github-review-comment"; externalId?: string; reviewState?: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED"; }, runContext?: RunMutationContext,): Promise<Task> {
|
||||
@@ -200,6 +201,32 @@ export async function addCommentImpl(store: TaskStore, id: string, text: string,
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function publishArchivedTaskDocumentAdditionImpl(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
input: ArchivedTaskDocumentAdditionInput,
|
||||
): Promise<ArchivedTaskDocumentAdditionResult> {
|
||||
try {
|
||||
validateDocumentKey(input.key);
|
||||
} catch {
|
||||
throw new Error(`Invalid document key: "${input.key}". Must be 1-64 alphanumeric characters, hyphens, or underscores.`);
|
||||
}
|
||||
validateArchivedTaskDocumentAddition(input);
|
||||
if (!store.backendMode || !store.asyncLayer) {
|
||||
throw new ArchivedTaskDocumentPublicationRejectedError(
|
||||
"postgres-required",
|
||||
store.asyncLayer?.projectId ?? "__legacy_unscoped__",
|
||||
taskId,
|
||||
input.key,
|
||||
);
|
||||
}
|
||||
/*
|
||||
FNXC:ArchivedTaskDocumentPublication 2026-07-20-15:36:
|
||||
The dedicated facade deliberately returns the atomic PostgreSQL result directly. Unlike ordinary upsert it emits no task event and performs no citation scan, keeping archived parent, workflow, mission, and scheduler state inert.
|
||||
*/
|
||||
return publishArchivedTaskDocumentAdditionAsync(store.asyncLayer, taskId, input);
|
||||
}
|
||||
|
||||
export async function upsertTaskDocumentImpl(store: TaskStore, taskId: string, input: TaskDocumentCreateInput): Promise<TaskDocument> {
|
||||
try {
|
||||
validateDocumentKey(input.key);
|
||||
@@ -209,6 +236,8 @@ export async function upsertTaskDocumentImpl(store: TaskStore, taskId: string, i
|
||||
);
|
||||
}
|
||||
|
||||
validateTaskDocumentPreconditions(input);
|
||||
|
||||
// FNXC:RuntimeWorkflowAsync 2026-06-24-17:00:
|
||||
// Backend mode: delegate the core upsert (revision archive + update) to
|
||||
// upsertTaskDocumentAsync. The citation scanning and task:updated emission
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
Task,
|
||||
TaskAttachment,
|
||||
} from "../types.js";
|
||||
import { taskDocumentContentHash } from "../task-document-concurrency.js";
|
||||
import type {
|
||||
ArtifactRow,
|
||||
BranchGroupRow,
|
||||
@@ -444,6 +445,7 @@ export function rowToTaskDocument(row: TaskDocumentRow): import("../types.js").T
|
||||
key: row.key,
|
||||
content: row.content,
|
||||
revision: row.revision,
|
||||
contentHash: taskDocumentContentHash(row.content),
|
||||
author: row.author,
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata),
|
||||
createdAt: row.createdAt,
|
||||
|
||||
@@ -587,6 +587,8 @@ import type {
|
||||
TaskDocument,
|
||||
TaskDocumentRevision,
|
||||
TaskDocumentCreateInput,
|
||||
ArchivedTaskDocumentAdditionInput,
|
||||
ArchivedTaskDocumentAdditionResult,
|
||||
TaskDocumentWithTask,
|
||||
Artifact,
|
||||
ArtifactCreateInput,
|
||||
@@ -608,6 +610,8 @@ export type {
|
||||
TaskDocument,
|
||||
TaskDocumentRevision,
|
||||
TaskDocumentCreateInput,
|
||||
ArchivedTaskDocumentAdditionInput,
|
||||
ArchivedTaskDocumentAdditionResult,
|
||||
TaskDocumentWithTask,
|
||||
Artifact,
|
||||
ArtifactCreateInput,
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface TaskDocument {
|
||||
content: string;
|
||||
/** Monotonically increasing revision number (starts at 1) */
|
||||
revision: number;
|
||||
/** SHA-256 of exact UTF-8 content, formatted `sha256:<64 lowercase hex>`. */
|
||||
contentHash: string;
|
||||
/** Who created/last-edited this revision: "user" | "agent" | "system" */
|
||||
author: string;
|
||||
/** Optional extensible metadata (JSON object) */
|
||||
@@ -54,6 +56,36 @@ export interface TaskDocumentCreateInput {
|
||||
author?: string;
|
||||
/** Optional extensible metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** CAS expectation. Zero requires absence; positive values require an existing matching revision. */
|
||||
expectedRevision?: number;
|
||||
/** CAS expectation requiring an existing document with this canonical SHA-256 content hash. */
|
||||
expectedContentHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArchivedTaskDocumentPublication 2026-07-20-15:36:
|
||||
* Archived evidence can only gain an operator-attributed correction through a dedicated additive contract. The caller supplies no replacement content or parent metadata, and both exact-current CAS expectations plus a non-empty audit reason are mandatory. This contract is intentionally absent from agent document-write tools.
|
||||
*/
|
||||
export interface ArchivedTaskDocumentAdditionInput {
|
||||
/** Existing document key. Must match the ordinary task-document key grammar. */
|
||||
key: string;
|
||||
/** Non-empty bytes appended after the canonical archived-addition boundary. */
|
||||
appendContent: string;
|
||||
/** Existing positive revision that must still be current under the transaction lock. */
|
||||
expectedRevision: number;
|
||||
/** Canonical SHA-256 hash of the exact current UTF-8 content. */
|
||||
expectedContentHash: string;
|
||||
/** Non-empty operator attribution persisted on the new current revision. */
|
||||
author: string;
|
||||
/** Non-empty operator justification used only in ids/outcomes-only audit metadata. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ArchivedTaskDocumentAdditionResult {
|
||||
document: TaskDocument;
|
||||
previousRevision: number;
|
||||
previousContentHash: string;
|
||||
appendedContentHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -278,7 +278,12 @@ export function putTaskDocument(
|
||||
taskId: string,
|
||||
key: string,
|
||||
content: string,
|
||||
opts?: { author?: string; metadata?: Record<string, unknown> },
|
||||
opts?: {
|
||||
author?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
expectedRevision?: number;
|
||||
expectedContentHash?: string;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<TaskDocument> {
|
||||
return api<TaskDocument>(withProjectId(`/tasks/${taskId}/documents/${encodeURIComponent(key)}`, projectId), {
|
||||
@@ -287,6 +292,8 @@ export function putTaskDocument(
|
||||
content,
|
||||
author: opts?.author,
|
||||
metadata: opts?.metadata,
|
||||
expectedRevision: opts?.expectedRevision,
|
||||
expectedContentHash: opts?.expectedContentHash,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { artifactMediaUrlWithToken, fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { artifactMediaUrlWithToken, fetchArtifact, fetchTaskDetail, fetchTaskDocument, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { useArtifacts } from "../hooks/useArtifacts";
|
||||
import { useDocuments } from "../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
||||
@@ -246,8 +246,17 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
FNXC:DocumentsView 2026-07-11-13:40:
|
||||
Operator requirement: task documents in the Artifacts view must be editable in place with the same CodeMirror FileEditor used for workspace files and artifact docs — the FN-7811 read-only pane is not enough. Editing state is scoped to the selected document ID so switching documents, tabs, or projects can never save a draft against the wrong document; the draft lives here (not in FileEditor) so Save can PUT it via putTaskDocument and refresh the SWR document list.
|
||||
*/
|
||||
/*
|
||||
FNXC:TaskDocumentCAS 2026-07-20-11:06:
|
||||
The global Artifacts editor pins the selected task document's loaded revision/hash for the lifetime of its draft. Conflict refreshes may reveal the newer revision, but must leave the shared FileEditor open with the user's draft on desktop and mobile and must not auto-retry.
|
||||
|
||||
FNXC:TaskDocumentCAS 2026-07-20-15:42:
|
||||
A conflict must require an explicit rebase before another Save. Capture the freshly fetched revision/hash separately, keep the draft byte-for-byte unchanged, and only advance the write baseline when the operator chooses Rebase; this prevents both deterministic stale retries and silent overwrites.
|
||||
*/
|
||||
const [editingTaskDocumentId, setEditingTaskDocumentId] = useState<string | null>(null);
|
||||
const [taskDocDraft, setTaskDocDraft] = useState("");
|
||||
const [taskDocPrecondition, setTaskDocPrecondition] = useState<{ revision: number; contentHash: string } | null>(null);
|
||||
const [pendingTaskDocRebase, setPendingTaskDocRebase] = useState<{ revision: number; contentHash: string } | null>(null);
|
||||
const [taskDocSaving, setTaskDocSaving] = useState(false);
|
||||
const [artifactDocContent, setArtifactDocContent] = useState<string | null>(null);
|
||||
const [artifactDocLoading, setArtifactDocLoading] = useState(false);
|
||||
@@ -324,6 +333,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setTaskDocMarkdownStates(new Map());
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
setTaskDocSaving(false);
|
||||
setArtifactDocContent(null);
|
||||
setArtifactDocLoading(false);
|
||||
@@ -421,6 +432,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setSelectedTaskItem(null);
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
setArtifactDocContent(null);
|
||||
setArtifactDocLoading(false);
|
||||
setArtifactDocError(null);
|
||||
@@ -468,6 +481,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setSelectedTaskItem(null);
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
setArtifactDocContent(null);
|
||||
setArtifactDocLoading(false);
|
||||
setArtifactDocError(null);
|
||||
@@ -575,6 +590,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setSelectedTaskItem({ kind: "document", id: docId });
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
setArtifactDocContent(null);
|
||||
setArtifactDocLoading(false);
|
||||
setArtifactDocError(null);
|
||||
@@ -584,6 +601,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setSelectedTaskItem({ kind: "artifact", id: artifactId });
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
setArtifactDocContent(null);
|
||||
setArtifactDocError(null);
|
||||
setRenderArtifactMarkdown(true);
|
||||
@@ -593,6 +612,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setSelectedTaskItem(null);
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
setArtifactDocContent(null);
|
||||
setArtifactDocLoading(false);
|
||||
setArtifactDocError(null);
|
||||
@@ -611,28 +632,54 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
if (!selectedTaskDocument) return;
|
||||
setTaskDocDraft(selectedTaskDocument.content);
|
||||
setEditingTaskDocumentId(selectedTaskDocument.id);
|
||||
setTaskDocPrecondition({ revision: selectedTaskDocument.revision, contentHash: selectedTaskDocument.contentHash });
|
||||
setPendingTaskDocRebase(null);
|
||||
}, [selectedTaskDocument]);
|
||||
|
||||
const handleCancelTaskDocEdit = useCallback(() => {
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
}, []);
|
||||
|
||||
const handleRebaseTaskDocEdit = useCallback(() => {
|
||||
if (!pendingTaskDocRebase) return;
|
||||
setTaskDocPrecondition(pendingTaskDocRebase);
|
||||
setPendingTaskDocRebase(null);
|
||||
}, [pendingTaskDocRebase]);
|
||||
|
||||
const handleSaveTaskDocEdit = useCallback(async () => {
|
||||
if (!selectedTaskDocument) return;
|
||||
if (!selectedTaskDocument || !taskDocPrecondition || pendingTaskDocRebase) return;
|
||||
setTaskDocSaving(true);
|
||||
try {
|
||||
await putTaskDocument(selectedTaskDocument.taskId, selectedTaskDocument.key, taskDocDraft, {}, projectId);
|
||||
await putTaskDocument(selectedTaskDocument.taskId, selectedTaskDocument.key, taskDocDraft, {
|
||||
expectedRevision: taskDocPrecondition.revision,
|
||||
expectedContentHash: taskDocPrecondition.contentHash,
|
||||
}, projectId);
|
||||
await refreshDocuments();
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocPrecondition(null);
|
||||
setPendingTaskDocRebase(null);
|
||||
addToast(t("documents.taskDocumentSaved", "Document saved"), "success");
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : String(err), "error");
|
||||
if (typeof err === "object" && err !== null && "status" in err && err.status === 409) {
|
||||
try {
|
||||
const latest = await fetchTaskDocument(selectedTaskDocument.taskId, selectedTaskDocument.key, projectId);
|
||||
await refreshDocuments();
|
||||
setPendingTaskDocRebase({ revision: latest.revision, contentHash: latest.contentHash });
|
||||
addToast(t("documents.taskDocumentStale", "This document changed since you opened it. Your draft is preserved; review the latest revision, then choose Rebase draft before saving."), "error");
|
||||
} catch (refreshError) {
|
||||
addToast(refreshError instanceof Error ? refreshError.message : String(refreshError), "error");
|
||||
}
|
||||
} else {
|
||||
addToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
} finally {
|
||||
setTaskDocSaving(false);
|
||||
}
|
||||
}, [selectedTaskDocument, taskDocDraft, projectId, refreshDocuments, addToast, t]);
|
||||
}, [selectedTaskDocument, taskDocDraft, taskDocPrecondition, pendingTaskDocRebase, projectId, refreshDocuments, addToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTaskArtifact || getArtifactCategory(selectedTaskArtifact) !== "doc") {
|
||||
@@ -1135,7 +1182,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
<button className="btn btn-sm" onClick={handleCancelTaskDocEdit} disabled={taskDocSaving}>
|
||||
{t("documents.cancelEdit", "Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleSaveTaskDocEdit()} disabled={taskDocSaving}>
|
||||
{pendingTaskDocRebase && (
|
||||
<button className="btn btn-sm" onClick={handleRebaseTaskDocEdit} disabled={taskDocSaving}>
|
||||
{t("documents.rebaseTaskDocument", "Rebase draft")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleSaveTaskDocEdit()} disabled={taskDocSaving || pendingTaskDocRebase !== null}>
|
||||
{taskDocSaving ? t("documents.saving", "Saving…") : t("documents.saveTaskDocument", "Save")}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -132,8 +132,17 @@ export function TaskDocumentsTab({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedDocKeys, setExpandedDocKeys] = useState<Set<string>>(() => new Set());
|
||||
const [revisionContentByKey, setRevisionContentByKey] = useState<Record<string, string>>({});
|
||||
/*
|
||||
FNXC:TaskDocumentCAS 2026-07-20-11:06:
|
||||
Task Detail captures the loaded revision/hash when editing begins and never replaces that baseline behind the draft. A 409 refreshes visible server state while retaining the editor and exact user draft on desktop and mobile; Save never silently retries a stale overwrite. New-document saves use revision zero so duplicate-key races are visible conflicts.
|
||||
|
||||
FNXC:TaskDocumentCAS 2026-07-20-15:42:
|
||||
After a conflict, keep the refreshed revision/hash pending until the operator explicitly rebases the preserved draft. Disable Save during that decision so repeated clicks cannot resubmit the stale baseline or silently adopt a newer baseline.
|
||||
*/
|
||||
const [editingDocKey, setEditingDocKey] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [editPrecondition, setEditPrecondition] = useState<{ revision: number; contentHash: string } | null>(null);
|
||||
const [pendingEditRebase, setPendingEditRebase] = useState<{ revision: number; contentHash: string } | null>(null);
|
||||
const [showHistory, setShowHistory] = useState<string | null>(null);
|
||||
const [revisions, setRevisions] = useState<TaskDocumentRevision[]>([]);
|
||||
const [loadingRevisions, setLoadingRevisions] = useState(false);
|
||||
@@ -178,8 +187,10 @@ export function TaskDocumentsTab({
|
||||
return next;
|
||||
});
|
||||
setRevisionContentByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => nextKeys.has(key))));
|
||||
return docs;
|
||||
} catch (error) {
|
||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToLoad", "Failed to load documents"), "error");
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -214,6 +225,8 @@ export function TaskDocumentsTab({
|
||||
if (editingDocKey === doc.key) {
|
||||
setEditingDocKey(null);
|
||||
setEditContent("");
|
||||
setEditPrecondition(null);
|
||||
setPendingEditRebase(null);
|
||||
}
|
||||
if (showHistory === doc.key) {
|
||||
setShowHistory(null);
|
||||
@@ -243,20 +256,35 @@ export function TaskDocumentsTab({
|
||||
function handleStartEdit(doc: TaskDocument) {
|
||||
setEditingDocKey(doc.key);
|
||||
setEditContent(revisionContentByKey[doc.key] ?? doc.content);
|
||||
setEditPrecondition({ revision: doc.revision, contentHash: doc.contentHash });
|
||||
setPendingEditRebase(null);
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
setEditingDocKey(null);
|
||||
setEditContent("");
|
||||
setEditPrecondition(null);
|
||||
setPendingEditRebase(null);
|
||||
}
|
||||
|
||||
function handleRebaseEdit() {
|
||||
if (!pendingEditRebase) return;
|
||||
setEditPrecondition(pendingEditRebase);
|
||||
setPendingEditRebase(null);
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editingDocKey || !editContent.trim()) return;
|
||||
if (!editingDocKey || !editContent.trim() || !editPrecondition || pendingEditRebase) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await putTaskDocument(taskId, editingDocKey, editContent, {}, projectId);
|
||||
await putTaskDocument(taskId, editingDocKey, editContent, {
|
||||
expectedRevision: editPrecondition.revision,
|
||||
expectedContentHash: editPrecondition.contentHash,
|
||||
}, projectId);
|
||||
setEditingDocKey(null);
|
||||
setEditContent("");
|
||||
setEditPrecondition(null);
|
||||
setPendingEditRebase(null);
|
||||
setRevisionContentByKey((current) => {
|
||||
const next = { ...current };
|
||||
delete next[editingDocKey];
|
||||
@@ -265,7 +293,16 @@ export function TaskDocumentsTab({
|
||||
await loadDocuments();
|
||||
addToast(t("taskDocuments.saved", "Document saved"), "success");
|
||||
} catch (error) {
|
||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToSave", "Failed to save document"), "error");
|
||||
if (typeof error === "object" && error !== null && "status" in error && error.status === 409) {
|
||||
const latestDocuments = await loadDocuments();
|
||||
const latest = latestDocuments?.find((doc) => doc.key === editingDocKey);
|
||||
if (latest) {
|
||||
setPendingEditRebase({ revision: latest.revision, contentHash: latest.contentHash });
|
||||
addToast(t("taskDocuments.staleCopy", "This document changed since you opened it. Your draft is preserved; review the latest revision, then choose Rebase draft before saving."), "error");
|
||||
}
|
||||
} else {
|
||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToSave", "Failed to save document"), "error");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -291,14 +328,19 @@ export function TaskDocumentsTab({
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await putTaskDocument(taskId, key, content, {}, projectId);
|
||||
await putTaskDocument(taskId, key, content, { expectedRevision: 0 }, projectId);
|
||||
setShowCreateForm(false);
|
||||
setNewDocKey("");
|
||||
setNewDocContent("");
|
||||
await loadDocuments();
|
||||
addToast(t("taskDocuments.created", "Document created"), "success");
|
||||
} catch (error) {
|
||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToCreate", "Failed to create document"), "error");
|
||||
if (typeof error === "object" && error !== null && "status" in error && error.status === 409) {
|
||||
await loadDocuments();
|
||||
addToast(t("taskDocuments.keyAlreadyCreated", "A document with this key was created elsewhere. Your draft is preserved; choose a new key or rebase onto the current document."), "error");
|
||||
} else {
|
||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToCreate", "Failed to create document"), "error");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -611,10 +653,15 @@ export function TaskDocumentsTab({
|
||||
<button className="btn btn-sm" onClick={handleCancelEdit} disabled={saving}>
|
||||
{t("taskDocuments.cancel", "Cancel")}
|
||||
</button>
|
||||
{pendingEditRebase && (
|
||||
<button className="btn btn-sm" onClick={handleRebaseEdit} disabled={saving}>
|
||||
{t("taskDocuments.rebaseDraft", "Rebase draft")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleSaveEdit()}
|
||||
disabled={saving || !editContent.trim()}
|
||||
disabled={saving || !editContent.trim() || pendingEditRebase !== null}
|
||||
>
|
||||
{saving ? t("taskDocuments.saving", "Saving…") : t("taskDocuments.save", "Save")}
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import { DocumentsView } from "../DocumentsView";
|
||||
import { fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, updateArtifact } from "../../api";
|
||||
import { fetchArtifact, fetchTaskDetail, fetchTaskDocument, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, updateArtifact } from "../../api";
|
||||
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||
import { useDocuments } from "../../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
|
||||
@@ -12,6 +12,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAllDocuments: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
fetchTaskDocument: vi.fn(),
|
||||
fetchArtifacts: vi.fn(),
|
||||
fetchArtifact: vi.fn(),
|
||||
updateArtifact: vi.fn(),
|
||||
@@ -48,6 +49,7 @@ const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
||||
const mockFetchTaskDocument = vi.mocked(fetchTaskDocument);
|
||||
const mockFetchArtifact = vi.mocked(fetchArtifact);
|
||||
const mockUpdateArtifact = vi.mocked(updateArtifact);
|
||||
const mockPutTaskDocument = vi.mocked(putTaskDocument);
|
||||
@@ -81,6 +83,7 @@ const mockTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "plan",
|
||||
content: "Alpha document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T12:00:00.000Z",
|
||||
@@ -93,6 +96,7 @@ const mockTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "notes",
|
||||
content: "Beta document content",
|
||||
revision: 2,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T09:00:00.000Z",
|
||||
updatedAt: "2026-04-19T11:00:00.000Z",
|
||||
@@ -108,6 +112,7 @@ const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "plan",
|
||||
content: "Done document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T16:00:00.000Z",
|
||||
@@ -120,6 +125,7 @@ const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "notes",
|
||||
content: "Todo document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T15:00:00.000Z",
|
||||
@@ -132,6 +138,7 @@ const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "summary",
|
||||
content: "Archived document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T14:00:00.000Z",
|
||||
@@ -144,6 +151,7 @@ const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "handoff",
|
||||
content: "Custom column document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T13:00:00.000Z",
|
||||
@@ -156,6 +164,7 @@ const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "legacy",
|
||||
content: "Legacy document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T12:00:00.000Z",
|
||||
@@ -167,6 +176,7 @@ const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
|
||||
key: "notes",
|
||||
content: "Second done document content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T11:00:00.000Z",
|
||||
@@ -418,6 +428,7 @@ describe("DocumentsView", () => {
|
||||
size: 18,
|
||||
});
|
||||
mockFetchTaskDetail.mockResolvedValue({ id: "KB-001" } as TaskDetail);
|
||||
mockFetchTaskDocument.mockResolvedValue(mockTaskDocuments[0]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -573,6 +584,7 @@ describe("DocumentsView", () => {
|
||||
key: index === 0 ? "plan" : "notes",
|
||||
content: `Document content ${taskNumber}`,
|
||||
revision: index + 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: `2026-04-19T10:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
updatedAt: `2026-04-19T12:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
@@ -586,6 +598,7 @@ describe("DocumentsView", () => {
|
||||
key: "notes",
|
||||
content: "Second document content",
|
||||
revision: 2,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T09:00:00.000Z",
|
||||
updatedAt: "2026-04-19T11:59:00.000Z",
|
||||
@@ -1385,7 +1398,10 @@ describe("DocumentsView", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPutTaskDocument).toHaveBeenCalledWith("KB-001", "plan", "Updated document content", {}, undefined);
|
||||
expect(mockPutTaskDocument).toHaveBeenCalledWith("KB-001", "plan", "Updated document content", {
|
||||
expectedRevision: 1,
|
||||
expectedContentHash: `sha256:${"a".repeat(64)}`,
|
||||
}, undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("file editor")).not.toBeInTheDocument();
|
||||
@@ -1393,6 +1409,38 @@ describe("DocumentsView", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Document saved", "success");
|
||||
});
|
||||
|
||||
it("explicitly rebases a preserved conflict draft before saving with the refreshed baseline", async () => {
|
||||
const refreshed = {
|
||||
...mockTaskDocuments[0],
|
||||
content: "Concurrent server content",
|
||||
revision: 2,
|
||||
contentHash: `sha256:${"b".repeat(64)}`,
|
||||
};
|
||||
mockFetchTaskDocument.mockResolvedValue(refreshed);
|
||||
mockPutTaskDocument
|
||||
.mockRejectedValueOnce(Object.assign(new Error("stale"), { status: 409 }))
|
||||
.mockResolvedValueOnce({ ...refreshed, content: "Preserved stale draft", revision: 3 });
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit task document/i }));
|
||||
fireEvent.change(screen.getByLabelText("file editor"), { target: { value: "Preserved stale draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
const rebaseButton = await screen.findByRole("button", { name: "Rebase draft" });
|
||||
expect(screen.getByLabelText("file editor")).toHaveValue("Preserved stale draft");
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.click(rebaseButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockPutTaskDocument).toHaveBeenNthCalledWith(2, "KB-001", "plan", "Preserved stale draft", {
|
||||
expectedRevision: 2,
|
||||
expectedContentHash: `sha256:${"b".repeat(64)}`,
|
||||
}, undefined));
|
||||
await waitFor(() => expect(screen.queryByLabelText("file editor")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("cancels task document editing without saving and suppresses select-to-comment while editing", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import type { ArtifactWithTask, TaskDocument } from "@fusion/core";
|
||||
import { TaskDocumentsTab } from "../TaskDocumentsTab";
|
||||
import { artifactMediaUrlWithToken, fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api";
|
||||
import { artifactMediaUrlWithToken, fetchTaskDocuments, fetchTaskDocumentRevisions, putTaskDocument } from "../../api";
|
||||
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -25,6 +25,7 @@ vi.mock("../../hooks/useArtifacts", () => ({
|
||||
const mockFetchTaskDocuments = vi.mocked(fetchTaskDocuments);
|
||||
const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
|
||||
const mockArtifactMediaUrlWithToken = vi.mocked(artifactMediaUrlWithToken);
|
||||
const mockPutTaskDocument = vi.mocked(putTaskDocument);
|
||||
const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||
|
||||
function getDocumentCard(key: string): HTMLElement {
|
||||
@@ -86,6 +87,7 @@ const mockDocuments: TaskDocument[] = [
|
||||
key: "plan",
|
||||
content: "This is the **plan** content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T12:00:00.000Z",
|
||||
@@ -96,6 +98,7 @@ const mockDocuments: TaskDocument[] = [
|
||||
key: "notes",
|
||||
content: "# Notes\n\n- Item 1\n- Item 2",
|
||||
revision: 2,
|
||||
contentHash: `sha256:${"b".repeat(64)}`,
|
||||
author: "user",
|
||||
createdAt: "2026-04-19T09:00:00.000Z",
|
||||
updatedAt: "2026-04-19T11:00:00.000Z",
|
||||
@@ -119,6 +122,40 @@ describe("TaskDocumentsTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("explicitly rebases a preserved conflict draft before saving with the refreshed baseline", async () => {
|
||||
const refreshedDocuments = mockDocuments.map((doc) => doc.key === "plan" ? {
|
||||
...doc,
|
||||
content: "Concurrent server content",
|
||||
revision: 2,
|
||||
contentHash: `sha256:${"c".repeat(64)}`,
|
||||
} : doc);
|
||||
mockFetchTaskDocuments
|
||||
.mockResolvedValueOnce(mockDocuments)
|
||||
.mockResolvedValue(refreshedDocuments);
|
||||
mockPutTaskDocument
|
||||
.mockRejectedValueOnce(Object.assign(new Error("stale"), { status: 409 }))
|
||||
.mockResolvedValueOnce(refreshedDocuments[0]);
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" canEdit />);
|
||||
|
||||
const card = await waitFor(() => getDocumentCard("plan"));
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Edit" }));
|
||||
const editor = within(card).getByRole("textbox");
|
||||
fireEvent.change(editor, { target: { value: "My preserved draft" } });
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Save" }));
|
||||
|
||||
const rebaseButton = await within(card).findByRole("button", { name: "Rebase draft" });
|
||||
expect(editor).toHaveValue("My preserved draft");
|
||||
expect(within(card).getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.click(rebaseButton);
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockPutTaskDocument).toHaveBeenNthCalledWith(2, "KB-001", "plan", "My preserved draft", {
|
||||
expectedRevision: 2,
|
||||
expectedContentHash: `sha256:${"c".repeat(64)}`,
|
||||
}, "project-1"));
|
||||
await waitFor(() => expect(within(card).queryByRole("textbox")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders the renamed Artifacts heading with document list", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-s
|
||||
import * as updateCheckModule from "../update-check.js";
|
||||
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
|
||||
import { parseGitHubCopilotDeviceCode } from "../routes/register-auth-routes.js";
|
||||
import { createAuthMiddleware } from "../auth-middleware.js";
|
||||
|
||||
// Mock @fusion/core for gh CLI auth checks
|
||||
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
@@ -177,7 +178,7 @@ vi.mock("@fusion/engine", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated, probeGitCliStatus } from "@fusion/core";
|
||||
import { AgentStore, ArchivedTaskDocumentPublicationRejectedError, Database, RoutineStore, TaskDocumentPreconditionFailedError, isGhAvailable, isGhAuthenticated, probeGitCliStatus } from "@fusion/core";
|
||||
import { createFnAgent } from "@fusion/engine";
|
||||
|
||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||
@@ -229,6 +230,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
|
||||
getAllDocuments: vi.fn().mockResolvedValue([]),
|
||||
upsertTaskDocument: vi.fn(),
|
||||
publishArchivedTaskDocumentAddition: vi.fn(),
|
||||
deleteTaskDocument: vi.fn().mockResolvedValue(undefined),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updatePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -4143,6 +4145,17 @@ describe("Pause/Unpause endpoints", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
const OPERATOR_TOKEN = "fx-005-operator-token";
|
||||
function buildPrivilegedApp(mode: "authenticated" | "no-auth" = "authenticated") {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
if (mode === "authenticated") app.use(createAuthMiddleware(OPERATOR_TOKEN));
|
||||
app.use("/api", createApiRoutes(store, mode === "authenticated"
|
||||
? { daemon: { token: OPERATOR_TOKEN } }
|
||||
: { noAuth: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /tasks/:id/documents", () => {
|
||||
it("returns empty array when the store hides documents for a soft-deleted parent", async () => {
|
||||
(store.getTaskDocuments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
@@ -4222,6 +4235,60 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(store.upsertTaskDocument).toHaveBeenCalledWith("KB-001", { key: "plan", content: "My plan", author: "user", metadata: undefined });
|
||||
});
|
||||
|
||||
it("forwards valid document preconditions", async () => {
|
||||
const hash = `sha256:${"a".repeat(64)}`;
|
||||
(store.upsertTaskDocument as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "d1", taskId: "KB-001", key: "plan", content: "Updated", revision: 2,
|
||||
contentHash: hash, author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
const res = await REQUEST(buildApp(), "PUT", "/api/tasks/KB-001/documents/plan", JSON.stringify({
|
||||
content: "Updated", expectedRevision: 1, expectedContentHash: hash,
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.upsertTaskDocument).toHaveBeenCalledWith("KB-001", expect.objectContaining({
|
||||
expectedRevision: 1, expectedContentHash: hash,
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns structured 409 details for a stale document writer", async () => {
|
||||
const expectedHash = `sha256:${"a".repeat(64)}`;
|
||||
const currentHash = `sha256:${"b".repeat(64)}`;
|
||||
(store.upsertTaskDocument as ReturnType<typeof vi.fn>).mockRejectedValue(new TaskDocumentPreconditionFailedError({
|
||||
projectId: "project-1", taskId: "KB-001", key: "plan", expectedRevision: 1,
|
||||
expectedContentHash: expectedHash, currentRevision: 2, currentContentHash: currentHash,
|
||||
}));
|
||||
const res = await REQUEST(buildApp(), "PUT", "/api/tasks/KB-001/documents/plan", JSON.stringify({
|
||||
content: "Stale", expectedRevision: 1, expectedContentHash: expectedHash,
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details).toEqual(expect.objectContaining({
|
||||
code: "TASK_DOCUMENT_PRECONDITION_FAILED", currentRevision: 2, currentContentHash: currentHash,
|
||||
}));
|
||||
expect(res.body.details).not.toHaveProperty("content");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ expectedRevision: -1 }, "non-negative integer"],
|
||||
[{ expectedRevision: 1.5 }, "non-negative integer"],
|
||||
[{ expectedContentHash: "sha256:ABC" }, "64 lowercase hex"],
|
||||
])("returns 400 for malformed preconditions %#", async (precondition, message) => {
|
||||
const res = await REQUEST(buildApp(), "PUT", "/api/tasks/KB-001/documents/plan", JSON.stringify({
|
||||
content: "Updated", ...precondition,
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(message);
|
||||
expect(store.upsertTaskDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ordinary replacement writes rejected for archived tasks", async () => {
|
||||
(store.upsertTaskDocument as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Task KB-001 is archived — documents are read-only"));
|
||||
const res = await REQUEST(buildApp(), "PUT", "/api/tasks/KB-001/documents/plan", JSON.stringify({
|
||||
content: "replacement",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(500);
|
||||
expect(store.publishArchivedTaskDocumentAddition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates existing document with 200", async () => {
|
||||
const updatedDoc = { id: "d1", taskId: "KB-001", key: "plan", content: "Updated plan", revision: 2, author: "user", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" };
|
||||
(store.upsertTaskDocument as ReturnType<typeof vi.fn>).mockResolvedValue(updatedDoc);
|
||||
@@ -4299,6 +4366,101 @@ describe("Pause/Unpause endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/documents/:key/archived-publications", () => {
|
||||
const hash = `sha256:${"a".repeat(64)}`;
|
||||
const body = {
|
||||
appendContent: "Correction bytes",
|
||||
expectedRevision: 2,
|
||||
expectedContentHash: hash,
|
||||
author: "operator",
|
||||
reason: "Correct retained evidence",
|
||||
};
|
||||
const requestPublication = (app: express.Express, payload: Record<string, unknown> = body, token = OPERATOR_TOKEN) => REQUEST(
|
||||
app,
|
||||
"POST",
|
||||
"/api/tasks/KB-001/documents/docs/archived-publications",
|
||||
JSON.stringify(payload),
|
||||
{ "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
);
|
||||
|
||||
it("fails closed when daemon authentication is disabled", async () => {
|
||||
const res = await requestPublication(buildPrivilegedApp("no-auth"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(store.publishArchivedTaskDocumentAddition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inherits daemon bearer rejection before invoking the store", async () => {
|
||||
const res = await requestPublication(buildPrivilegedApp(), body, "wrong-token");
|
||||
expect(res.status).toBe(401);
|
||||
expect(store.publishArchivedTaskDocumentAddition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes an authenticated additive correction with project-scoped context", async () => {
|
||||
const result = {
|
||||
document: { id: "d1", taskId: "KB-001", key: "docs", content: "base\n\nCorrection bytes", revision: 3, contentHash: `sha256:${"b".repeat(64)}`, author: "operator" },
|
||||
previousRevision: 2,
|
||||
previousContentHash: hash,
|
||||
appendedContentHash: `sha256:${"b".repeat(64)}`,
|
||||
};
|
||||
(store.publishArchivedTaskDocumentAddition as ReturnType<typeof vi.fn>).mockResolvedValue(result);
|
||||
const res = await requestPublication(buildPrivilegedApp());
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual(result);
|
||||
expect(store.publishArchivedTaskDocumentAddition).toHaveBeenCalledWith("KB-001", {
|
||||
key: "docs",
|
||||
...body,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ ...body, appendContent: "" }, "appendContent must be a non-empty string"],
|
||||
[{ ...body, expectedRevision: 0 }, "expectedRevision must be a positive integer"],
|
||||
[{ ...body, expectedRevision: 1.5 }, "expectedRevision must be a positive integer"],
|
||||
[{ ...body, expectedContentHash: "sha256:ABC" }, "64 lowercase hex"],
|
||||
[{ ...body, author: "" }, "author must be a non-empty string"],
|
||||
[{ ...body, reason: "" }, "reason must be a non-empty string"],
|
||||
[{ ...body, content: "replacement" }, "Unknown archived publication field"],
|
||||
[{ ...body, allowArchived: true }, "Unknown archived publication field"],
|
||||
])("returns 400 without forwarding malformed publication %#", async (payload, message) => {
|
||||
const res = await requestPublication(buildPrivilegedApp(), payload);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(message);
|
||||
expect(store.publishArchivedTaskDocumentAddition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps stale CAS to a safe structured 409", async () => {
|
||||
(store.publishArchivedTaskDocumentAddition as ReturnType<typeof vi.fn>).mockRejectedValue(new TaskDocumentPreconditionFailedError({
|
||||
projectId: "project-a",
|
||||
taskId: "KB-001",
|
||||
key: "docs",
|
||||
expectedRevision: 2,
|
||||
expectedContentHash: hash,
|
||||
currentRevision: 3,
|
||||
currentContentHash: `sha256:${"c".repeat(64)}`,
|
||||
}));
|
||||
const res = await requestPublication(buildPrivilegedApp());
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details).toMatchObject({ code: "TASK_DOCUMENT_PRECONDITION_FAILED", currentRevision: 3 });
|
||||
expect(JSON.stringify(res.body)).not.toContain(body.appendContent);
|
||||
expect(JSON.stringify(res.body)).not.toContain(body.reason);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["parent-not-found", 404],
|
||||
["document-not-found", 404],
|
||||
["parent-not-archived", 409],
|
||||
["archived-state-inconsistent", 409],
|
||||
] as const)("maps %s to %i", async (reason, status) => {
|
||||
(store.publishArchivedTaskDocumentAddition as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new ArchivedTaskDocumentPublicationRejectedError(reason, "project-a", "KB-001", "docs"),
|
||||
);
|
||||
const res = await requestPublication(buildPrivilegedApp());
|
||||
expect(res.status).toBe(status);
|
||||
expect(res.body.details).toMatchObject({ code: "ARCHIVED_TASK_DOCUMENT_PUBLICATION_REJECTED", reason });
|
||||
expect(JSON.stringify(res.body)).not.toContain(body.appendContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /tasks/:id/documents/:key", () => {
|
||||
it("returns 204 on success", async () => {
|
||||
(store.deleteTaskDocument as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
@@ -4307,6 +4469,13 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(store.deleteTaskDocument).toHaveBeenCalledWith("KB-001", "plan");
|
||||
});
|
||||
|
||||
it("keeps ordinary deletes rejected for archived tasks", async () => {
|
||||
(store.deleteTaskDocument as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Task KB-001 is archived — documents are read-only"));
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/documents/plan");
|
||||
expect(res.status).toBe(500);
|
||||
expect(store.publishArchivedTaskDocumentAddition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when document not found", async () => {
|
||||
(store.deleteTaskDocument as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Document not found"));
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/documents/missing");
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
resolveReboundTarget,
|
||||
resolveColumnFlags,
|
||||
TransitionRejectionError,
|
||||
ArchivedTaskDocumentPublicationRejectedError,
|
||||
TaskDocumentPreconditionFailedError,
|
||||
validateArchivedTaskDocumentAddition,
|
||||
validateTaskDocumentPreconditions,
|
||||
getPlannerInterventionTimeline,
|
||||
isBuiltinWorkflowId,
|
||||
type NearDuplicateCandidate,
|
||||
@@ -85,6 +89,7 @@ import { computePlanApprovalFingerprint, isWorkspaceTask, type RunAuditEventInpu
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
import { deriveAutoTaskBranch, derivePerTaskBranch, getBranchSelectionMode, resolveBranchSelection } from "./branch-selection.js";
|
||||
import { isDaemonAuthActive } from "../auth-middleware.js";
|
||||
|
||||
const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi;
|
||||
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
@@ -3937,7 +3942,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw badRequest("Invalid document key. Must be 1-64 alphanumeric characters, hyphens, or underscores.");
|
||||
}
|
||||
|
||||
const { content, author, metadata } = req.body;
|
||||
const { content, author, metadata, expectedRevision, expectedContentHash } = req.body;
|
||||
|
||||
// Validate content
|
||||
if (content === undefined || content === null) {
|
||||
@@ -3950,6 +3955,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw badRequest("content must be between 1 and 100000 characters");
|
||||
}
|
||||
|
||||
try {
|
||||
validateTaskDocumentPreconditions({ expectedRevision, expectedContentHash });
|
||||
} catch (error) {
|
||||
throw badRequest(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
// Validate author (optional, defaults to "user")
|
||||
if (author !== undefined && typeof author !== "string") {
|
||||
throw badRequest("author must be a string");
|
||||
@@ -3965,6 +3976,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
content,
|
||||
author: author?.trim() || "user",
|
||||
metadata: metadata as Record<string, unknown> | undefined,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
...(expectedContentHash !== undefined ? { expectedContentHash } : {}),
|
||||
});
|
||||
|
||||
// Return 201 for new documents (revision === 1), 200 for updates
|
||||
@@ -3974,12 +3987,80 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof TaskDocumentPreconditionFailedError) {
|
||||
throw new ApiError(409, err.message, { ...err.toDetails() });
|
||||
}
|
||||
const errorWithCode = err as NodeJS.ErrnoException;
|
||||
const status = errorWithCode.code === "ENOENT" ? 404 : 500;
|
||||
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* FNXC:ArchivedTaskDocumentPublication 2026-07-20-15:36:
|
||||
* Archived corrections are an operator-only daemon API, not an ordinary editor or agent write. The server-level bearer middleware authenticates requests when daemon auth is active; this route additionally fails closed when Fusion was launched with `--no-auth` or without a daemon token. Only the additive contract is accepted, and conflict details expose hashes/revisions but never document, reason, or credential bytes.
|
||||
*/
|
||||
router.post("/tasks/:id/documents/:key/archived-publications", async (req, res) => {
|
||||
if (!isDaemonAuthActive(options)) {
|
||||
throw new ApiError(403, "Archived document publication requires active daemon bearer authentication");
|
||||
}
|
||||
try {
|
||||
if (!DOCUMENT_KEY_REGEX.test(req.params.key)) {
|
||||
throw badRequest("Invalid document key. Must be 1-64 alphanumeric characters, hyphens, or underscores.");
|
||||
}
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw badRequest("request body must be an object");
|
||||
}
|
||||
const allowedFields = new Set(["appendContent", "expectedRevision", "expectedContentHash", "author", "reason"]);
|
||||
const unknownFields = Object.keys(body).filter((field) => !allowedFields.has(field));
|
||||
if (unknownFields.length > 0) {
|
||||
throw badRequest(`Unknown archived publication field: ${unknownFields[0]}`);
|
||||
}
|
||||
if (typeof body.appendContent === "string" && body.appendContent.length > 100000) {
|
||||
throw badRequest("appendContent must be between 1 and 100000 characters");
|
||||
}
|
||||
if (typeof body.author === "string" && body.author.length > 200) {
|
||||
throw badRequest("author must be at most 200 characters");
|
||||
}
|
||||
if (typeof body.reason === "string" && body.reason.length > 2000) {
|
||||
throw badRequest("reason must be at most 2000 characters");
|
||||
}
|
||||
const publication = {
|
||||
appendContent: body.appendContent,
|
||||
expectedRevision: body.expectedRevision,
|
||||
expectedContentHash: body.expectedContentHash,
|
||||
author: body.author,
|
||||
reason: body.reason,
|
||||
};
|
||||
try {
|
||||
validateArchivedTaskDocumentAddition(publication);
|
||||
} catch (error) {
|
||||
throw badRequest(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const result = await scopedStore.publishArchivedTaskDocumentAddition(req.params.id, {
|
||||
key: req.params.key,
|
||||
appendContent: publication.appendContent,
|
||||
expectedRevision: publication.expectedRevision,
|
||||
expectedContentHash: publication.expectedContentHash,
|
||||
author: publication.author.trim(),
|
||||
reason: publication.reason.trim(),
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof TaskDocumentPreconditionFailedError) {
|
||||
throw new ApiError(409, err.message, { ...err.toDetails() });
|
||||
}
|
||||
if (err instanceof ArchivedTaskDocumentPublicationRejectedError) {
|
||||
const status = err.reason === "parent-not-found" || err.reason === "document-not-found" ? 404 : 409;
|
||||
throw new ApiError(status, err.message, { ...err.toDetails() });
|
||||
}
|
||||
throw new ApiError(500, "Archived document publication failed");
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /tasks/:id/documents/:key — Delete a document and all its revisions
|
||||
router.delete("/tasks/:id/documents/:key", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskDocument, TaskStore } from "@fusion/core";
|
||||
import { TaskDocumentPreconditionFailedError, type TaskDocument, type TaskStore } from "@fusion/core";
|
||||
import {
|
||||
createChatTaskDocumentTools,
|
||||
createTaskDocumentReadTool,
|
||||
@@ -23,6 +23,7 @@ function createMockDocument(overrides: Partial<TaskDocument> = {}): TaskDocument
|
||||
key: "plan",
|
||||
content: "Initial plan content",
|
||||
revision: 1,
|
||||
contentHash: `sha256:${"a".repeat(64)}`,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-08T12:00:00.000Z",
|
||||
updatedAt: "2026-04-08T12:00:00.000Z",
|
||||
@@ -90,6 +91,33 @@ describe("task_document_write tool", () => {
|
||||
expect(getText(result)).toContain("revision 3");
|
||||
});
|
||||
|
||||
it("forwards combined CAS expectations and returns revision/hash details", async () => {
|
||||
const { store, upsertTaskDocument } = createMockStore();
|
||||
const hash = `sha256:${"a".repeat(64)}`;
|
||||
upsertTaskDocument.mockResolvedValue(createMockDocument({ revision: 4, contentHash: hash }));
|
||||
const result = await runTool(createTaskDocumentWriteTool(store, TASK_ID), "call-cas", {
|
||||
key: "plan", content: "rebased", expected_revision: 3, expected_content_hash: hash,
|
||||
});
|
||||
expect(upsertTaskDocument).toHaveBeenCalledWith(TASK_ID, {
|
||||
key: "plan", content: "rebased", author: "agent", expectedRevision: 3, expectedContentHash: hash,
|
||||
});
|
||||
expect(result.details).toEqual({ key: "plan", revision: 4, contentHash: hash });
|
||||
});
|
||||
|
||||
it("returns a typed error result for stale task-bound publication", async () => {
|
||||
const { store, upsertTaskDocument } = createMockStore();
|
||||
upsertTaskDocument.mockRejectedValue(new TaskDocumentPreconditionFailedError({
|
||||
projectId: "p1", taskId: TASK_ID, key: "plan", expectedRevision: 1,
|
||||
currentRevision: 2, currentContentHash: `sha256:${"b".repeat(64)}`,
|
||||
}));
|
||||
const result = await runTool(createTaskDocumentWriteTool(store, TASK_ID), "call-stale", {
|
||||
key: "plan", content: "stale", expected_revision: 1,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details).toEqual(expect.objectContaining({ code: "TASK_DOCUMENT_PRECONDITION_FAILED", currentRevision: 2 }));
|
||||
expect(getText(result)).toContain("re-read");
|
||||
});
|
||||
|
||||
it("defaults author to agent when not provided", async () => {
|
||||
const { store, upsertTaskDocument } = createMockStore();
|
||||
upsertTaskDocument.mockResolvedValue(createMockDocument({ key: "notes", revision: 2 }));
|
||||
@@ -189,7 +217,7 @@ describe("task_document_read tool", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reads a specific document by key and returns content", async () => {
|
||||
it("reads a retained archived document directly by key and returns content", async () => {
|
||||
const { store, getTaskDocument } = createMockStore();
|
||||
getTaskDocument.mockResolvedValue(
|
||||
createMockDocument({ key: "plan", content: "Detailed execution checklist", revision: 4 }),
|
||||
@@ -231,7 +259,7 @@ describe("task_document_read tool", () => {
|
||||
expect(getText(result)).toContain("- research (revision 1, updated 2026-04-08T12:30:00.000Z)");
|
||||
});
|
||||
|
||||
it("returns a no-documents message when list is empty", async () => {
|
||||
it("keeps the archived document registry hidden when list is empty", async () => {
|
||||
const { store, getTaskDocuments } = createMockStore();
|
||||
getTaskDocuments.mockResolvedValue([]);
|
||||
|
||||
@@ -265,13 +293,15 @@ describe("chat task document tools", () => {
|
||||
return tool!;
|
||||
}
|
||||
|
||||
it("exposes canonical document tool names for chat agents", () => {
|
||||
it("exposes canonical document tools without an archived publication capability", () => {
|
||||
const { store } = createMockStore();
|
||||
const tools = createChatTaskDocumentTools(store);
|
||||
|
||||
expect(createChatTaskDocumentTools(store).map((tool) => tool.name)).toEqual([
|
||||
expect(tools.map((tool) => tool.name)).toEqual([
|
||||
"fn_task_document_write",
|
||||
"fn_task_document_read",
|
||||
]);
|
||||
expect(JSON.stringify(tools.map((tool) => tool.parameters))).not.toMatch(/archived.publication|append_content|allow_archived/i);
|
||||
});
|
||||
|
||||
it("writes a document to the explicit task_id", async () => {
|
||||
@@ -295,7 +325,32 @@ describe("chat task document tools", () => {
|
||||
expect(getText(result)).toContain("revision 5");
|
||||
});
|
||||
|
||||
it("reads a document from the explicit task_id", async () => {
|
||||
it("forwards hash-only CAS for explicit cross-task publication", async () => {
|
||||
const { store, upsertTaskDocument } = createMockStore();
|
||||
const hash = `sha256:${"c".repeat(64)}`;
|
||||
upsertTaskDocument.mockResolvedValue(createMockDocument({ taskId: "FN-2020", contentHash: hash, revision: 6 }));
|
||||
await runTool(findChatTool("fn_task_document_write", store), "call-chat-cas", {
|
||||
task_id: "FN-2020", key: "plan", content: "rebased", expected_content_hash: hash,
|
||||
});
|
||||
expect(upsertTaskDocument).toHaveBeenCalledWith("FN-2020", {
|
||||
key: "plan", content: "rebased", author: "agent", expectedContentHash: hash,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns typed stale details for explicit cross-task publication", async () => {
|
||||
const { store, upsertTaskDocument } = createMockStore();
|
||||
upsertTaskDocument.mockRejectedValue(new TaskDocumentPreconditionFailedError({
|
||||
projectId: "p1", taskId: "FN-2020", key: "plan", expectedContentHash: `sha256:${"a".repeat(64)}`,
|
||||
currentRevision: null, currentContentHash: null,
|
||||
}));
|
||||
const result = await runTool(findChatTool("fn_task_document_write", store), "call-chat-stale", {
|
||||
task_id: "FN-2020", key: "plan", content: "stale", expected_content_hash: `sha256:${"a".repeat(64)}`,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details).toEqual(expect.objectContaining({ code: "TASK_DOCUMENT_PRECONDITION_FAILED", taskId: "FN-2020" }));
|
||||
});
|
||||
|
||||
it("reads a retained archived document from the explicit task_id", async () => {
|
||||
const { store, getTaskDocument } = createMockStore();
|
||||
getTaskDocument.mockResolvedValue(createMockDocument({ taskId: "FN-2021", key: "notes", content: "Chat notes" }));
|
||||
|
||||
@@ -318,7 +373,7 @@ describe("chat task document tools", () => {
|
||||
expect(getText(result)).toContain("Document \"missing\" not found.");
|
||||
});
|
||||
|
||||
it("lists documents for the explicit task_id when key is omitted", async () => {
|
||||
it("continues to use the live-only registry when an explicit key is omitted", async () => {
|
||||
const { store, getTaskDocuments } = createMockStore();
|
||||
getTaskDocuments.mockResolvedValue([
|
||||
createMockDocument({ taskId: "FN-2023", key: "plan", revision: 1 }),
|
||||
|
||||
@@ -118,6 +118,8 @@ export const taskDocumentWriteParams = Type.Object({
|
||||
}),
|
||||
content: Type.String({ description: "Document content to store" }),
|
||||
author: Type.Optional(Type.String({ description: "Who is writing (default: 'agent')" })),
|
||||
expected_revision: Type.Optional(Type.Integer({ minimum: 0, description: "CAS precondition: 0 requires absence; a positive value must match the current revision." })),
|
||||
expected_content_hash: Type.Optional(Type.String({ pattern: "^sha256:[0-9a-f]{64}$", description: "CAS precondition: current exact-content SHA-256 (`sha256:<64 lowercase hex>`) must match." })),
|
||||
});
|
||||
|
||||
export const taskDocumentReadParams = Type.Object({
|
||||
@@ -150,6 +152,8 @@ export const chatTaskDocumentWriteParams = Type.Object({
|
||||
}),
|
||||
content: Type.String({ description: "Document content to store" }),
|
||||
author: Type.Optional(Type.String({ description: "Who is writing (default: 'agent')" })),
|
||||
expected_revision: Type.Optional(Type.Integer({ minimum: 0, description: "CAS precondition: 0 requires absence; a positive value must match the current revision." })),
|
||||
expected_content_hash: Type.Optional(Type.String({ pattern: "^sha256:[0-9a-f]{64}$", description: "CAS precondition: current exact-content SHA-256 (`sha256:<64 lowercase hex>`) must match." })),
|
||||
});
|
||||
|
||||
export const chatTaskDocumentReadParams = Type.Object({
|
||||
@@ -1626,6 +1630,32 @@ export function createChatTaskLogsReadTool(store: TaskStore): ToolDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskDocumentCAS 2026-07-20-11:06:
|
||||
Task-bound and explicit cross-task publishers share one read-then-CAS contract. They forward optional snake_case expectations without inventing defaults, return revision/hash on success, and expose stale state as a typed error result. Agents must re-read and explicitly rebase; the tool never retries or converts a conflict into success text.
|
||||
*/
|
||||
function taskDocumentWriteResult(document: TaskDocument) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Saved document "${document.key}" (revision ${document.revision}, ${document.contentHash}).` }],
|
||||
details: { key: document.key, revision: document.revision, contentHash: document.contentHash },
|
||||
};
|
||||
}
|
||||
|
||||
function taskDocumentWriteError(error: unknown, key: string, taskId?: string) {
|
||||
if (error instanceof fusionCore.TaskDocumentPreconditionFailedError) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Document "${key}" changed; re-read it and explicitly rebase before writing.` }],
|
||||
details: { ...error.toDetails() },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `ERROR: Failed to save document "${key}"${taskId ? ` for task ${taskId}` : ""}: ${message}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `fn_task_document_write` tool that stores a named task document.
|
||||
*
|
||||
@@ -1638,34 +1668,22 @@ export function createTaskDocumentWriteTool(store: TaskStore, taskId: string): T
|
||||
name: "fn_task_document_write",
|
||||
label: "Write Document",
|
||||
description:
|
||||
"Save a named document for this task (for example plan, notes, or research). " +
|
||||
"Each write creates a new revision so you can update documents over time.",
|
||||
"Save a named document for this task. Read first, then pass expected_revision and/or expected_content_hash for safe CAS publication; stale writes fail and require an explicit rebase.",
|
||||
parameters: taskDocumentWriteParams,
|
||||
execute: async (_id: string, params: Static<typeof taskDocumentWriteParams>) => {
|
||||
const input: TaskDocumentCreateInput = {
|
||||
key: params.key,
|
||||
content: params.content,
|
||||
author: params.author || "agent",
|
||||
...(params.expected_revision !== undefined ? { expectedRevision: params.expected_revision } : {}),
|
||||
...(params.expected_content_hash !== undefined ? { expectedContentHash: params.expected_content_hash } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const document: TaskDocument = await store.upsertTaskDocument(taskId, input);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Saved document "${document.key}" (revision ${document.revision}).`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `ERROR: Failed to save document "${params.key}": ${err.message}`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
return taskDocumentWriteResult(document);
|
||||
} catch (error: unknown) {
|
||||
return taskDocumentWriteError(error, params.key);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1810,34 +1828,22 @@ export function createChatTaskDocumentTools(store: TaskStore): ToolDefinition[]
|
||||
name: "fn_task_document_write",
|
||||
label: "Write Document",
|
||||
description:
|
||||
"Save a named document for a task (for example plan, notes, or research). " +
|
||||
"Each write creates a new revision so you can update documents over time. Requires task_id.",
|
||||
"Save a named document for an explicit task. Read first, then pass expected_revision and/or expected_content_hash for safe CAS publication; stale writes fail and require an explicit rebase. Requires task_id.",
|
||||
parameters: chatTaskDocumentWriteParams,
|
||||
execute: async (_id: string, params: Static<typeof chatTaskDocumentWriteParams>) => {
|
||||
const input: TaskDocumentCreateInput = {
|
||||
key: params.key,
|
||||
content: params.content,
|
||||
author: params.author || "agent",
|
||||
...(params.expected_revision !== undefined ? { expectedRevision: params.expected_revision } : {}),
|
||||
...(params.expected_content_hash !== undefined ? { expectedContentHash: params.expected_content_hash } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const document: TaskDocument = await store.upsertTaskDocument(params.task_id, input);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Saved document "${document.key}" (revision ${document.revision}).`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `ERROR: Failed to save document "${params.key}" for task ${params.task_id}: ${err.message}`,
|
||||
}],
|
||||
details: {},
|
||||
};
|
||||
return taskDocumentWriteResult(document);
|
||||
} catch (error: unknown) {
|
||||
return taskDocumentWriteError(error, params.key, params.task_id);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user