@danypops/papyrus 0.46.2 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/cli/task-command.ts +49 -8
- package/src/cli.ts +10 -8
- package/src/constants.ts +3 -1
- package/src/db.ts +44 -0
- package/src/domain-service-shared.ts +2 -3
- package/src/handlers/tasks.ts +131 -55
- package/src/id-migration.ts +2 -0
- package/src/index.ts +9 -1
- package/src/modules/tasks.ts +33 -10
- package/src/service.ts +4 -1
- package/src/stores/sqlite-task-mutation-request-store.ts +98 -0
- package/src/stores/task-mutation-request-store.ts +89 -0
- package/src/task/task-service.ts +402 -55
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ Task project names are explicit registrations, never ambient-directory guesses.
|
|
|
46
46
|
|
|
47
47
|
`tasks.create` accepts an optional `idempotency_key`. Replays with the same caller, canonical project root, key, and payload return the original response without another mutation; conflicting payload reuse is rejected. Keys are retained for seven days, isolated across callers and projects, and then expire. Retry only when reusing the exact key and payload; an unkeyed create remains unsafe to replay after an ambiguous transport failure.
|
|
48
48
|
|
|
49
|
+
Task lifecycle mutations (`start`, `submit`, `reject`, `retry`, `cancel`, `reopen`, `complete`, `pause`, and `unpause`) are destination-state idempotent: repeating an already-achieved transition is a successful `changed: false` no-op and creates no duplicate history. Supply an `idempotency_key` for every mutation whose response could be lost. After an unknown outcome, call `tasks.show` and `tasks.mutation_status` with the original key, then replay only that exact operation/key if needed—never invent a new key from stale state. Completed receipts are retained for seven days; concurrent duplicate completion calls share one gate run. A genuinely incompatible transition returns typed `invalid-transition` details with current/intended status, allowed actions, and recovery guidance.
|
|
50
|
+
|
|
49
51
|
Task lease responses are name-first: `tasks.claim`, `tasks.heartbeat_lease`, and `tasks.lease` return the reusable artifact alias as `taskName` plus `taskTitle`, not the backend UUID. Use `taskName` for later Task operations; retain the lease token for heartbeat or release.
|
|
50
52
|
|
|
51
53
|
### Context Mesh persistence model
|
package/package.json
CHANGED
package/src/cli/task-command.ts
CHANGED
|
@@ -102,10 +102,16 @@ const focusedCommand = buildCommand({
|
|
|
102
102
|
|
|
103
103
|
function buildPauseUnpauseCommand(action: "pause" | "unpause") {
|
|
104
104
|
return buildCommand({
|
|
105
|
-
func: async function (this: TaskContext, flags: { sessionId?: string; sessionSecret?: string }) {
|
|
105
|
+
func: async function (this: TaskContext, flags: { sessionId?: string; sessionSecret?: string; idempotencyKey?: string }) {
|
|
106
106
|
const focus = await this.client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(
|
|
107
107
|
`tasks.${action}` as OperationName,
|
|
108
|
-
{
|
|
108
|
+
{
|
|
109
|
+
actor: "user",
|
|
110
|
+
source: "cli",
|
|
111
|
+
session_id: flags.sessionId,
|
|
112
|
+
session_secret: flags.sessionSecret,
|
|
113
|
+
...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
|
|
114
|
+
},
|
|
109
115
|
);
|
|
110
116
|
render.call(this, focus, `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`);
|
|
111
117
|
},
|
|
@@ -119,6 +125,13 @@ function buildPauseUnpauseCommand(action: "pause" | "unpause") {
|
|
|
119
125
|
placeholder: "secret",
|
|
120
126
|
optional: true,
|
|
121
127
|
},
|
|
128
|
+
idempotencyKey: {
|
|
129
|
+
brief: "Retry key for this exact mutation",
|
|
130
|
+
kind: "parsed",
|
|
131
|
+
parse: String,
|
|
132
|
+
placeholder: "key",
|
|
133
|
+
optional: true,
|
|
134
|
+
},
|
|
122
135
|
},
|
|
123
136
|
},
|
|
124
137
|
docs: { brief: `${action === "pause" ? "Pause" : "Resume"} the active Task Focus` },
|
|
@@ -307,6 +320,21 @@ const eventFeedCommand = buildCommand({
|
|
|
307
320
|
docs: { brief: "Feed of raw Task lifecycle events across every task" },
|
|
308
321
|
});
|
|
309
322
|
|
|
323
|
+
const mutationStatusCommand = buildCommand({
|
|
324
|
+
func: async function (this: TaskContext, _flags: Record<string, never>, idempotencyKey: string) {
|
|
325
|
+
const receipt = await this.client.call<Record<string, unknown>, { receiptId: string; operation: string; state: string }>(
|
|
326
|
+
"tasks.mutation_status",
|
|
327
|
+
{ idempotency_key: idempotencyKey },
|
|
328
|
+
);
|
|
329
|
+
render.call(this, receipt, `${receipt.operation}: ${receipt.state} (${receipt.receiptId})`);
|
|
330
|
+
},
|
|
331
|
+
parameters: {
|
|
332
|
+
flags: {},
|
|
333
|
+
positional: { kind: "tuple", parameters: [{ brief: "Original idempotency key", parse: String, placeholder: "key" }] },
|
|
334
|
+
},
|
|
335
|
+
docs: { brief: "Resolve an unknown Task lifecycle mutation outcome" },
|
|
336
|
+
});
|
|
337
|
+
|
|
310
338
|
// -- CRUD -----------------------------------------------------------------------------------
|
|
311
339
|
|
|
312
340
|
const createCommand = buildCommand({
|
|
@@ -788,12 +816,13 @@ const planCommand = buildCommand({
|
|
|
788
816
|
});
|
|
789
817
|
|
|
790
818
|
const completeCommand = buildCommand({
|
|
791
|
-
func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
|
|
819
|
+
func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
|
|
792
820
|
const completion = await this.client.call<Record<string, unknown>, CliCompletion>("tasks.complete", {
|
|
793
821
|
id,
|
|
794
822
|
actor: "user",
|
|
795
823
|
source: "cli",
|
|
796
824
|
session_id: flags.sessionId,
|
|
825
|
+
...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
|
|
797
826
|
});
|
|
798
827
|
const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
|
|
799
828
|
if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
|
|
@@ -806,24 +835,31 @@ const completeCommand = buildCommand({
|
|
|
806
835
|
render.call(this, completion, lines.join("\n"));
|
|
807
836
|
},
|
|
808
837
|
parameters: {
|
|
809
|
-
flags: {
|
|
838
|
+
flags: {
|
|
839
|
+
sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
|
|
840
|
+
idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
|
|
841
|
+
},
|
|
810
842
|
positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
|
|
811
843
|
},
|
|
812
844
|
docs: { brief: "Run gates and checklist review, then complete" },
|
|
813
845
|
});
|
|
814
846
|
|
|
815
847
|
const startCommand = buildCommand({
|
|
816
|
-
func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
|
|
848
|
+
func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
|
|
817
849
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("tasks.start", {
|
|
818
850
|
id,
|
|
819
851
|
actor: "user",
|
|
820
852
|
source: "cli",
|
|
821
853
|
session_id: flags.sessionId,
|
|
854
|
+
...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
|
|
822
855
|
});
|
|
823
856
|
render.call(this, artifact, `Started: ${artifactLabel(artifact)}`);
|
|
824
857
|
},
|
|
825
858
|
parameters: {
|
|
826
|
-
flags: {
|
|
859
|
+
flags: {
|
|
860
|
+
sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
|
|
861
|
+
idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
|
|
862
|
+
},
|
|
827
863
|
positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
|
|
828
864
|
},
|
|
829
865
|
docs: { brief: "Lifecycle transition: todo -> in-progress" },
|
|
@@ -831,17 +867,21 @@ const startCommand = buildCommand({
|
|
|
831
867
|
|
|
832
868
|
function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel" | "reopen", brief: string) {
|
|
833
869
|
return buildCommand({
|
|
834
|
-
func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
|
|
870
|
+
func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
|
|
835
871
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>(`tasks.${action}` as OperationName, {
|
|
836
872
|
id,
|
|
837
873
|
actor: "user",
|
|
838
874
|
source: "cli",
|
|
839
875
|
session_id: flags.sessionId,
|
|
876
|
+
...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
|
|
840
877
|
});
|
|
841
878
|
render.call(this, artifact, `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`);
|
|
842
879
|
},
|
|
843
880
|
parameters: {
|
|
844
|
-
flags: {
|
|
881
|
+
flags: {
|
|
882
|
+
sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
|
|
883
|
+
idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
|
|
884
|
+
},
|
|
845
885
|
positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
|
|
846
886
|
},
|
|
847
887
|
docs: { brief },
|
|
@@ -920,6 +960,7 @@ const app = buildApplication(
|
|
|
920
960
|
lease: leaseCommand,
|
|
921
961
|
"reap-stale-leases": reapStaleLeasesCommand,
|
|
922
962
|
"event-feed": eventFeedCommand,
|
|
963
|
+
"mutation-status": mutationStatusCommand,
|
|
923
964
|
create: createCommand,
|
|
924
965
|
list: listCommand,
|
|
925
966
|
show: showCommand,
|
package/src/cli.ts
CHANGED
|
@@ -162,8 +162,8 @@ const USAGE = `Usage:
|
|
|
162
162
|
papyrus tasks graph [--session-id <id>] [--json]
|
|
163
163
|
papyrus tasks active [--session-id <id>] [--json]
|
|
164
164
|
papyrus tasks focused [--session-id <id>] [--json]
|
|
165
|
-
papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
|
|
166
|
-
papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
|
|
165
|
+
papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--idempotency-key <key>] [--json]
|
|
166
|
+
papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--idempotency-key <key>] [--json]
|
|
167
167
|
papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
|
|
168
168
|
papyrus tasks reap-stale-focus [--json]
|
|
169
169
|
papyrus tasks claim <id> --owner <owner> [--ttl-ms <ms>] [--note <text>] [--json]
|
|
@@ -172,17 +172,19 @@ const USAGE = `Usage:
|
|
|
172
172
|
papyrus tasks lease <id> [--json]
|
|
173
173
|
papyrus tasks reap-stale-leases [--json]
|
|
174
174
|
papyrus tasks event-feed [--cursor <n>] [--limit <n>] [--event-types-json <json>] [--json]
|
|
175
|
+
papyrus tasks mutation-status <idempotency-key> [--json]
|
|
175
176
|
papyrus tasks history <id> [--json]
|
|
176
177
|
papyrus tasks scope [project|all|graph <root-id>] [--json]
|
|
177
178
|
papyrus tasks assign-project <id> [project-root] [--json]
|
|
178
179
|
papyrus tasks focus <id> [--session-id <id>] [--session-secret <secret>] [--json]
|
|
179
180
|
papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
|
|
180
|
-
papyrus tasks complete <id> [--session-id <id>] [--json]
|
|
181
|
-
papyrus tasks start <id> [--session-id <id>] [--json]
|
|
182
|
-
papyrus tasks submit <id> [--session-id <id>] [--json]
|
|
183
|
-
papyrus tasks reject <id> [--session-id <id>] [--json]
|
|
184
|
-
papyrus tasks retry <id> [--session-id <id>] [--json]
|
|
185
|
-
papyrus tasks cancel <id> [--session-id <id>] [--json]
|
|
181
|
+
papyrus tasks complete <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
182
|
+
papyrus tasks start <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
183
|
+
papyrus tasks submit <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
184
|
+
papyrus tasks reject <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
185
|
+
papyrus tasks retry <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
186
|
+
papyrus tasks cancel <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
187
|
+
papyrus tasks reopen <id> [--session-id <id>] [--idempotency-key <key>] [--json]
|
|
186
188
|
papyrus tasks cancel-subtree <id> [--session-id <id>] [--json]
|
|
187
189
|
papyrus tasks depend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
|
|
188
190
|
papyrus tasks undepend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
|
package/src/constants.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
9
9
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
10
10
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
11
11
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
12
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
12
|
+
export const SQLITE_SCHEMA_VERSION = 27;
|
|
13
13
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
14
14
|
|
|
15
15
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -267,6 +267,8 @@ export const TASK_PROJECT_ALIAS_MAX_COUNT = 20;
|
|
|
267
267
|
export const TASK_PROJECT_LIST_MAX_RESULTS = 100;
|
|
268
268
|
export const TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
|
|
269
269
|
export const TASK_CREATE_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
270
|
+
export const TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
|
|
271
|
+
export const TASK_MUTATION_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
270
272
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
271
273
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
272
274
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
package/src/db.ts
CHANGED
|
@@ -176,6 +176,24 @@ CREATE TABLE IF NOT EXISTS task_create_requests (
|
|
|
176
176
|
PRIMARY KEY (request_scope, idempotency_key)
|
|
177
177
|
);
|
|
178
178
|
CREATE INDEX IF NOT EXISTS task_create_requests_expiry_idx ON task_create_requests(expires_at);
|
|
179
|
+
CREATE TABLE IF NOT EXISTS task_mutation_requests (
|
|
180
|
+
request_scope TEXT NOT NULL,
|
|
181
|
+
idempotency_key TEXT NOT NULL,
|
|
182
|
+
receipt_id TEXT NOT NULL UNIQUE,
|
|
183
|
+
task_id TEXT REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
184
|
+
operation TEXT NOT NULL,
|
|
185
|
+
request_hash TEXT NOT NULL,
|
|
186
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
|
187
|
+
response_json TEXT,
|
|
188
|
+
created_at TEXT NOT NULL,
|
|
189
|
+
updated_at TEXT NOT NULL,
|
|
190
|
+
expires_at TEXT NOT NULL,
|
|
191
|
+
PRIMARY KEY (request_scope, idempotency_key),
|
|
192
|
+
CHECK ((state = 'pending' AND response_json IS NULL) OR (state = 'completed' AND response_json IS NOT NULL))
|
|
193
|
+
);
|
|
194
|
+
CREATE INDEX IF NOT EXISTS task_mutation_requests_expiry_idx ON task_mutation_requests(expires_at);
|
|
195
|
+
CREATE UNIQUE INDEX IF NOT EXISTS task_mutation_requests_pending_task_operation_idx
|
|
196
|
+
ON task_mutation_requests(task_id, operation) WHERE state = 'pending' AND task_id IS NOT NULL;
|
|
179
197
|
CREATE TABLE IF NOT EXISTS artifact_events (
|
|
180
198
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
181
199
|
artifact_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
@@ -799,6 +817,32 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
799
817
|
}
|
|
800
818
|
},
|
|
801
819
|
},
|
|
820
|
+
{
|
|
821
|
+
version: 27,
|
|
822
|
+
name: "task-lifecycle-mutation-receipts",
|
|
823
|
+
up: (db) => {
|
|
824
|
+
db.exec(`
|
|
825
|
+
CREATE TABLE IF NOT EXISTS task_mutation_requests (
|
|
826
|
+
request_scope TEXT NOT NULL,
|
|
827
|
+
idempotency_key TEXT NOT NULL,
|
|
828
|
+
receipt_id TEXT NOT NULL UNIQUE,
|
|
829
|
+
task_id TEXT REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
830
|
+
operation TEXT NOT NULL,
|
|
831
|
+
request_hash TEXT NOT NULL,
|
|
832
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
|
833
|
+
response_json TEXT,
|
|
834
|
+
created_at TEXT NOT NULL,
|
|
835
|
+
updated_at TEXT NOT NULL,
|
|
836
|
+
expires_at TEXT NOT NULL,
|
|
837
|
+
PRIMARY KEY (request_scope, idempotency_key),
|
|
838
|
+
CHECK ((state = 'pending' AND response_json IS NULL) OR (state = 'completed' AND response_json IS NOT NULL))
|
|
839
|
+
);
|
|
840
|
+
CREATE INDEX IF NOT EXISTS task_mutation_requests_expiry_idx ON task_mutation_requests(expires_at);
|
|
841
|
+
CREATE UNIQUE INDEX IF NOT EXISTS task_mutation_requests_pending_task_operation_idx
|
|
842
|
+
ON task_mutation_requests(task_id, operation) WHERE state = 'pending' AND task_id IS NOT NULL;
|
|
843
|
+
`);
|
|
844
|
+
},
|
|
845
|
+
},
|
|
802
846
|
];
|
|
803
847
|
|
|
804
848
|
/**
|
|
@@ -107,9 +107,8 @@ export type TransitionTable<Action extends string, Status extends string> = Reco
|
|
|
107
107
|
/**
|
|
108
108
|
* The from/to-table lookup+validation half of a transition, split out from runTransition
|
|
109
109
|
* (below) so a caller with its own side effects gated on "is this action even valid from the
|
|
110
|
-
* current status"
|
|
111
|
-
*
|
|
112
|
-
* directly instead of duplicating the same lookup+throw three times. Every other caller
|
|
110
|
+
* current status" can call this directly instead of duplicating the same lookup+throw.
|
|
111
|
+
* Task lifecycle now uses its richer retry-safe transition primitive instead. Every other caller
|
|
113
112
|
* (Document/Rule/Playbook, none of which have that ordering constraint) uses runTransition
|
|
114
113
|
* instead, which does this same check plus the write in one call.
|
|
115
114
|
*/
|
package/src/handlers/tasks.ts
CHANGED
|
@@ -19,17 +19,25 @@
|
|
|
19
19
|
*
|
|
20
20
|
* remove/remove_subtree/restore are not duplicated here -- see ./artifact-trash-vehicle.ts.
|
|
21
21
|
*/
|
|
22
|
-
import { VehicleError, type VehicleLimits } from "@danypops/vehicle-core";
|
|
22
|
+
import { VehicleError, type VehicleLimits, type VehicleOperationContext } from "@danypops/vehicle-core";
|
|
23
23
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
24
24
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
25
|
-
import { GATE_TIMEOUT_MAX_MS, TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH } from "../constants.ts";
|
|
25
|
+
import { GATE_TIMEOUT_MAX_MS, TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH, TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH } from "../constants.ts";
|
|
26
26
|
import { PROOF_TYPES } from "../domain/checklist.ts";
|
|
27
27
|
import { GATE_TYPES } from "../domain/gate.ts";
|
|
28
28
|
import type { TaskViewMode } from "../domain/task-scope.ts";
|
|
29
29
|
import { tasksOperations } from "../modules/tasks.ts";
|
|
30
30
|
import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
|
|
31
|
+
import { TaskMutationIdempotencyConflictError, TaskMutationPendingError } from "../stores/task-mutation-request-store.ts";
|
|
31
32
|
import type { TaskExecutionPlan } from "../task/task-execution.ts";
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
type TaskCompletion,
|
|
35
|
+
TaskInvalidTransitionError,
|
|
36
|
+
TaskMutationReceiptNotFoundError,
|
|
37
|
+
TaskProjectAmbiguousError,
|
|
38
|
+
TaskProjectNotFoundError,
|
|
39
|
+
type Tasks,
|
|
40
|
+
} from "../task/task-service.ts";
|
|
33
41
|
import {
|
|
34
42
|
booleanProp,
|
|
35
43
|
classifySessionAuthorization,
|
|
@@ -75,6 +83,13 @@ const GATE_OPERATION_LIMITS: VehicleLimits = {
|
|
|
75
83
|
const objectProp = { type: "object" } as const;
|
|
76
84
|
const arrayProp = { type: "array" } as const;
|
|
77
85
|
const _boolProp = { type: "boolean" } as const;
|
|
86
|
+
const mutationIdempotencyProp = {
|
|
87
|
+
type: "string",
|
|
88
|
+
minLength: 1,
|
|
89
|
+
maxLength: TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH,
|
|
90
|
+
description:
|
|
91
|
+
"Retry key for this exact mutation. Reuse the same key after an unknown outcome; inspect mutation_status before choosing a new action.",
|
|
92
|
+
} as const;
|
|
78
93
|
|
|
79
94
|
const gateProp = {
|
|
80
95
|
type: "array",
|
|
@@ -251,13 +266,58 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
251
266
|
* vehicle-registry's own secure-by-default handler-failed opacity still applies to a genuine
|
|
252
267
|
* unexpected crash (see artifact-vehicle-shared.ts's classify* helpers).
|
|
253
268
|
*/
|
|
269
|
+
const throwLifecycleError = (error: unknown): never => {
|
|
270
|
+
if (error instanceof TaskInvalidTransitionError) {
|
|
271
|
+
throw new VehicleError("invalid-transition", error.message, {
|
|
272
|
+
category: "conflict",
|
|
273
|
+
details: {
|
|
274
|
+
operation: error.operation,
|
|
275
|
+
currentStatus: error.currentStatus,
|
|
276
|
+
intendedStatus: error.intendedStatus,
|
|
277
|
+
allowedActions: [...error.allowedActions],
|
|
278
|
+
recovery: error.recovery,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (error instanceof TaskMutationIdempotencyConflictError) {
|
|
283
|
+
throw new VehicleError("idempotency-key-conflict", error.message, { category: "conflict" });
|
|
284
|
+
}
|
|
285
|
+
if (error instanceof TaskMutationPendingError) {
|
|
286
|
+
throw new VehicleError("mutation-pending", error.message, {
|
|
287
|
+
category: "conflict",
|
|
288
|
+
details: { receiptId: error.receiptId, operation: error.operation },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (error instanceof TaskMutationReceiptNotFoundError) {
|
|
292
|
+
throw new VehicleError("mutation-receipt-not-found", error.message, { category: "not_found" });
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
};
|
|
296
|
+
const classifyLifecycle = <T>(run: () => T): T => {
|
|
297
|
+
try {
|
|
298
|
+
const result = run();
|
|
299
|
+
return result instanceof Promise ? (result.catch(throwLifecycleError) as T) : result;
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return throwLifecycleError(error);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
254
304
|
const call = (name: string, input: Record<string, unknown>): unknown =>
|
|
255
305
|
classifySessionAuthorization(() =>
|
|
256
306
|
classifyTaskCreateIdempotency(() =>
|
|
257
|
-
classifyTaskExecutionBounds(() =>
|
|
307
|
+
classifyTaskExecutionBounds(() =>
|
|
308
|
+
classifyTaskDependencyCycles(() => classifyLifecycle(() => moduleOperations.get(name)!.execute(input))),
|
|
309
|
+
),
|
|
258
310
|
),
|
|
259
311
|
);
|
|
260
312
|
const define = createOperationDefiner(registry, OWNER, "tasks", ["tasks:read", "tasks:write"], call);
|
|
313
|
+
const mutationInput = (
|
|
314
|
+
input: Record<string, unknown>,
|
|
315
|
+
context: VehicleOperationContext<Record<string, unknown>>,
|
|
316
|
+
): Record<string, unknown> => ({
|
|
317
|
+
...input,
|
|
318
|
+
idempotency_key: input.idempotency_key ?? context.idempotencyKey,
|
|
319
|
+
idempotency_caller: context.principal?.id ?? "anonymous",
|
|
320
|
+
});
|
|
261
321
|
|
|
262
322
|
const resolveProject = (reference: string) => {
|
|
263
323
|
try {
|
|
@@ -522,7 +582,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
522
582
|
): void => {
|
|
523
583
|
define(action, description, "local-write", properties, required, resolve, (resolvedInput, context) => {
|
|
524
584
|
const claims = context.principal?.claims as { sessionId?: string; sessionSecret?: string } | undefined;
|
|
525
|
-
|
|
585
|
+
const operationInput = action === "pause" || action === "unpause" ? mutationInput(resolvedInput, context) : resolvedInput;
|
|
586
|
+
return call(`tasks.${action}`, { ...operationInput, session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
|
|
526
587
|
});
|
|
527
588
|
};
|
|
528
589
|
|
|
@@ -536,63 +597,67 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
536
597
|
id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
|
|
537
598
|
}),
|
|
538
599
|
);
|
|
539
|
-
focusOperation(
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
define(
|
|
544
|
-
"start",
|
|
545
|
-
"Lifecycle transition: todo -> in-progress.",
|
|
546
|
-
"local-write",
|
|
547
|
-
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
548
|
-
[],
|
|
549
|
-
resolveIdAndScope,
|
|
550
|
-
);
|
|
551
|
-
define(
|
|
552
|
-
"submit",
|
|
553
|
-
"Lifecycle transition: in-progress -> review.",
|
|
554
|
-
"local-write",
|
|
555
|
-
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
556
|
-
[],
|
|
557
|
-
resolveIdAndScope,
|
|
558
|
-
);
|
|
559
|
-
define(
|
|
560
|
-
"reject",
|
|
561
|
-
"Lifecycle transition: review -> rejected.",
|
|
562
|
-
"local-write",
|
|
563
|
-
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
564
|
-
[],
|
|
565
|
-
resolveIdAndScope,
|
|
566
|
-
);
|
|
567
|
-
define(
|
|
568
|
-
"retry",
|
|
569
|
-
"Lifecycle transition: rejected -> in-progress.",
|
|
570
|
-
"local-write",
|
|
571
|
-
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
600
|
+
focusOperation(
|
|
601
|
+
"pause",
|
|
602
|
+
"Pauses Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
|
|
603
|
+
{ reason: stringProp, idempotency_key: mutationIdempotencyProp },
|
|
572
604
|
[],
|
|
573
|
-
|
|
605
|
+
(input) => input,
|
|
574
606
|
);
|
|
575
|
-
|
|
576
|
-
"
|
|
577
|
-
"
|
|
578
|
-
|
|
579
|
-
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
607
|
+
focusOperation(
|
|
608
|
+
"unpause",
|
|
609
|
+
"Resumes paused Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
|
|
610
|
+
{ idempotency_key: mutationIdempotencyProp },
|
|
580
611
|
[],
|
|
581
|
-
|
|
612
|
+
(input) => input,
|
|
582
613
|
);
|
|
614
|
+
focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
|
|
583
615
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
616
|
+
const transitionOperation = (action: "start" | "submit" | "reject" | "retry" | "cancel" | "reopen", description: string): void =>
|
|
617
|
+
define(
|
|
618
|
+
action,
|
|
619
|
+
`${description} Destination-state idempotent: a replay after success returns changed=false. After an unknown outcome, call tasks.show and mutation_status, then reuse the SAME idempotency_key; never retry with a new key from stale state.`,
|
|
620
|
+
"local-write",
|
|
621
|
+
{
|
|
622
|
+
id: stringProp,
|
|
623
|
+
name: stringProp,
|
|
624
|
+
reason: stringProp,
|
|
625
|
+
session_id: stringProp,
|
|
626
|
+
project_root: stringProp,
|
|
627
|
+
idempotency_key: mutationIdempotencyProp,
|
|
628
|
+
},
|
|
629
|
+
[],
|
|
630
|
+
resolveIdAndScope,
|
|
631
|
+
(input, context) => {
|
|
632
|
+
const result = call(`tasks.${action}`, mutationInput(input, context)) as {
|
|
633
|
+
title: string;
|
|
634
|
+
status: string;
|
|
635
|
+
changed: boolean;
|
|
636
|
+
receiptId?: string;
|
|
637
|
+
replayed?: boolean;
|
|
638
|
+
};
|
|
639
|
+
const text = result.changed
|
|
640
|
+
? `${result.title} transitioned to ${result.status}.`
|
|
641
|
+
: result.replayed
|
|
642
|
+
? `Recovered the prior ${action} receipt for ${result.title}; call tasks.show to confirm its current status before the next action.`
|
|
643
|
+
: `${result.title} was already ${result.status}; replay was a safe no-op.`;
|
|
644
|
+
return { ...result, content: [{ type: "text" as const, text }] };
|
|
645
|
+
},
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
transitionOperation("start", "Lifecycle transition: todo -> in-progress.");
|
|
649
|
+
transitionOperation("submit", "Lifecycle transition: in-progress -> review.");
|
|
650
|
+
transitionOperation("reject", "Lifecycle transition: review -> rejected.");
|
|
651
|
+
transitionOperation("retry", "Lifecycle transition: rejected -> in-progress.");
|
|
652
|
+
transitionOperation(
|
|
653
|
+
"cancel",
|
|
654
|
+
"Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if premature.",
|
|
591
655
|
);
|
|
656
|
+
transitionOperation("reopen", "Lifecycle transition: canceled -> todo for work that should resume.");
|
|
592
657
|
|
|
593
658
|
define(
|
|
594
659
|
"complete",
|
|
595
|
-
"Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects
|
|
660
|
+
"Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects on gate/checklist failure. Reuse the same idempotency_key after an unknown outcome so gates and history are not run twice; inspect mutation_status before choosing a new action. A replay after done is a changed=false no-op.",
|
|
596
661
|
"local-write",
|
|
597
662
|
{
|
|
598
663
|
id: stringProp,
|
|
@@ -603,11 +668,12 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
603
668
|
scope: { type: "string", enum: ["project", "graph", "all"] },
|
|
604
669
|
root_task_id: stringProp,
|
|
605
670
|
root_task_name: stringProp,
|
|
671
|
+
idempotency_key: mutationIdempotencyProp,
|
|
606
672
|
},
|
|
607
673
|
[],
|
|
608
674
|
resolveIdAndScope,
|
|
609
|
-
async (input) => {
|
|
610
|
-
const result = (await call("tasks.complete", input)) as TaskCompletion;
|
|
675
|
+
async (input, context) => {
|
|
676
|
+
const result = (await call("tasks.complete", mutationInput(input, context))) as TaskCompletion;
|
|
611
677
|
const dependencyIds = result.blocked.flatMap((entry) => entry.dependencyIds);
|
|
612
678
|
const labels = labelsById(artifacts, dependencyIds);
|
|
613
679
|
return { ...result, content: [{ type: "text" as const, text: completionContentText(labels, result) }] };
|
|
@@ -615,6 +681,16 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
615
681
|
GATE_OPERATION_LIMITS,
|
|
616
682
|
);
|
|
617
683
|
|
|
684
|
+
define(
|
|
685
|
+
"mutation_status",
|
|
686
|
+
"Resolves an unknown lifecycle mutation outcome by the original idempotency_key. Read this receipt before selecting another transition; never invent a replacement key for the same attempt.",
|
|
687
|
+
"read",
|
|
688
|
+
{ idempotency_key: mutationIdempotencyProp },
|
|
689
|
+
["idempotency_key"],
|
|
690
|
+
(input) => input,
|
|
691
|
+
(input, context) => call("tasks.mutation_status", mutationInput(input, context)),
|
|
692
|
+
);
|
|
693
|
+
|
|
618
694
|
define(
|
|
619
695
|
"run_gates",
|
|
620
696
|
"Runs a Task's configured gates without transitioning its status -- for checking readiness before submit/complete.",
|
package/src/id-migration.ts
CHANGED
|
@@ -54,6 +54,7 @@ const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
|
|
|
54
54
|
{ table: "task_events", column: "task_id" },
|
|
55
55
|
{ table: "task_scopes", column: "task_id" },
|
|
56
56
|
{ table: "task_views", column: "root_task_id" },
|
|
57
|
+
{ table: "task_mutation_requests", column: "task_id" },
|
|
57
58
|
{ table: "artifact_events", column: "artifact_id" },
|
|
58
59
|
{ table: "artifact_events", column: "related_id" },
|
|
59
60
|
];
|
|
@@ -68,6 +69,7 @@ const TEXT_SCAN_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
|
|
|
68
69
|
{ table: "artifacts", column: "extra" },
|
|
69
70
|
{ table: "task_events", column: "reason" },
|
|
70
71
|
{ table: "task_events", column: "evidence_json" },
|
|
72
|
+
{ table: "task_mutation_requests", column: "response_json" },
|
|
71
73
|
];
|
|
72
74
|
|
|
73
75
|
/** Audit tables whose append-only guard must be suspended for exactly this migration's duration. */
|
package/src/index.ts
CHANGED
|
@@ -35,4 +35,12 @@ export { taskContext } from "./task/task-context.ts";
|
|
|
35
35
|
export { projectTaskExecution, type TaskExecutionPlan, type TaskExecutionState } from "./task/task-execution.ts";
|
|
36
36
|
export { projectTaskGraph, type TaskGraphView } from "./task/task-graph-view.ts";
|
|
37
37
|
export { fallbackLabel, projectTaskRelationships } from "./task/task-relationship-view.ts";
|
|
38
|
-
export type {
|
|
38
|
+
export type {
|
|
39
|
+
TaskCompletion,
|
|
40
|
+
TaskGraph,
|
|
41
|
+
TaskLifecycleMutationResult,
|
|
42
|
+
TaskMutationReceiptView,
|
|
43
|
+
TaskNode,
|
|
44
|
+
TaskStatus,
|
|
45
|
+
} from "./task/task-service.ts";
|
|
46
|
+
export { TaskInvalidTransitionError, TaskMutationReceiptNotFoundError } from "./task/task-service.ts";
|