@tomflow/proflow-task-orchestration 0.1.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.
@@ -0,0 +1,1358 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { ZodError } from "zod";
5
+ import { validatePublicInput } from "./contracts.js";
6
+ import { requiredTaskAgentPackageRefs } from "./model.js";
7
+ const allowedDocumentTypes = [
8
+ "REQUIREMENT",
9
+ "PRD",
10
+ "TECHNICAL_DESIGN",
11
+ "TEST_PLAN",
12
+ "TEST_RESULT",
13
+ "RELEASE_RESULT",
14
+ ];
15
+ class DomainError extends Error {
16
+ code;
17
+ retryable;
18
+ details;
19
+ constructor(code, message, retryable = false, details) {
20
+ super(message);
21
+ this.code = code;
22
+ this.retryable = retryable;
23
+ this.details = details;
24
+ }
25
+ }
26
+ const success = (data) => ({
27
+ contract: "task-orchestration",
28
+ contractVersion: "1.0.0",
29
+ ok: true,
30
+ data,
31
+ });
32
+ const failure = (error) => {
33
+ const domain = error instanceof DomainError ? error : undefined;
34
+ return {
35
+ contract: "task-orchestration",
36
+ contractVersion: "1.0.0",
37
+ ok: false,
38
+ error: {
39
+ code: domain?.code ??
40
+ (error instanceof ZodError ? "INVALID_REQUEST" : "INTERNAL_ERROR"),
41
+ message: domain?.message ??
42
+ (error instanceof Error ? error.message : "unexpected error"),
43
+ retryable: domain?.retryable ?? false,
44
+ correlationId: `corr-${randomUUID()}`,
45
+ ...(domain?.details === undefined ? {} : { details: domain.details }),
46
+ },
47
+ };
48
+ };
49
+ function canonical(value) {
50
+ if (Array.isArray(value))
51
+ return value.map(canonical);
52
+ if (value !== null && typeof value === "object")
53
+ return Object.fromEntries(Object.entries(value)
54
+ .sort(([left], [right]) => left.localeCompare(right))
55
+ .map(([key, item]) => [key, canonical(item)]));
56
+ return value;
57
+ }
58
+ const hash = (value) => `sha256:${createHash("sha256")
59
+ .update(JSON.stringify(canonical(value)))
60
+ .digest("hex")}`;
61
+ const contentHash = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
62
+ const auditEventTypes = {
63
+ bindTaskWorker: "TASK_ROLE_BOUND",
64
+ startTask: "TASK_STARTED",
65
+ pauseTask: "TASK_PAUSED",
66
+ resumeTask: "TASK_RESUMED",
67
+ terminateTask: "TASK_TERMINATED",
68
+ startNode: "NODE_STARTED",
69
+ completeNode: "NODE_COMPLETED",
70
+ waitNode: "NODE_WAITING",
71
+ failNode: "NODE_FAILED",
72
+ };
73
+ function requireTask(tx, id) {
74
+ const value = tx.tasks.get(id);
75
+ if (!value)
76
+ throw new DomainError("TASK_NOT_FOUND", `Task ${id} was not found.`);
77
+ return value;
78
+ }
79
+ function requireNode(tx, taskId, nodeId) {
80
+ const value = tx.nodes.get(nodeId);
81
+ if (!value || value.taskId !== taskId)
82
+ throw new DomainError("NODE_NOT_FOUND", `Node ${nodeId} was not found.`);
83
+ return value;
84
+ }
85
+ function checkVersion(actual, expected, code) {
86
+ if (actual !== expected)
87
+ throw new DomainError(code, "Version does not match.", true, {
88
+ expected,
89
+ actual,
90
+ });
91
+ }
92
+ function event(tx, value, now) {
93
+ tx.events.insert({ ...value, createdAt: now });
94
+ }
95
+ function preserveRunHistory(tx, node, finalStatus, endedAt) {
96
+ if (node.startedAt === null && node.workerRef === null)
97
+ return;
98
+ if (tx.executionHistory
99
+ .listByTask(node.taskId)
100
+ .some((item) => item.nodeId === node.nodeId && item.runNo === node.runNo))
101
+ return;
102
+ tx.executionHistory.insert({
103
+ taskId: node.taskId,
104
+ nodeId: node.nodeId,
105
+ runNo: node.runNo,
106
+ workerRef: node.workerRef,
107
+ finalStatus,
108
+ resultSummary: node.resultSummary,
109
+ errorCode: node.errorCode,
110
+ errorMessage: node.errorMessage,
111
+ errorRetryable: node.errorRetryable,
112
+ inputDocuments: node.inputDocuments,
113
+ outputDocuments: node.outputDocuments,
114
+ startedAt: node.startedAt,
115
+ endedAt,
116
+ });
117
+ }
118
+ function nodeResult(task, node) {
119
+ return {
120
+ taskId: task.taskId,
121
+ nodeId: node.nodeId,
122
+ status: node.status,
123
+ runNo: node.runNo,
124
+ workerRef: node.workerRef,
125
+ taskVersion: task.version,
126
+ nodeVersion: node.version,
127
+ startedAt: node.startedAt,
128
+ };
129
+ }
130
+ function readinessBlockedReason(tx, task, workspaceRoot) {
131
+ const requirement = tx.documents.get(task.taskId, "REQUIREMENT");
132
+ if (!requirement)
133
+ return "TASK_REQUIREMENT_REQUIRED";
134
+ const requirementAbsolute = join(workspaceRoot, requirement.filePath);
135
+ if (!existsSync(requirementAbsolute))
136
+ return "TASK_REQUIREMENT_REQUIRED";
137
+ if (contentHash(readFileSync(requirementAbsolute, "utf8")) !==
138
+ requirement.contentHash)
139
+ return "TASK_DOCUMENT_INDEX_MISMATCH";
140
+ const bindings = tx.roleBindings.listByTask(task.taskId);
141
+ if (bindings.length !== requiredTaskAgentPackageRefs.length)
142
+ return "TASK_ROLE_BINDING_REQUIRED";
143
+ if (tx.nodes
144
+ .listByTask(task.taskId)
145
+ .some((node) => !requiredTaskAgentPackageRefs.includes(node.requiredAgentPackageRef)))
146
+ return "TASK_NODE_AGENT_PACKAGE_INVALID";
147
+ for (const agentPackageRef of requiredTaskAgentPackageRefs) {
148
+ const binding = bindings.find((item) => item.agentPackageRef === agentPackageRef);
149
+ if (!binding)
150
+ return "TASK_ROLE_BINDING_REQUIRED";
151
+ if (binding.workerRef === null || binding.conversationLocator === null)
152
+ return "TASK_ROLE_BINDING_INCOMPLETE";
153
+ }
154
+ if (task.taskGroupId !== null) {
155
+ const group = tx.taskGroups.get(task.taskGroupId);
156
+ if (group?.status !== "ACTIVE")
157
+ return "TASK_GROUP_NOT_ACTIVE";
158
+ const siblings = tx.tasks.list(task.taskGroupId);
159
+ if (siblings.some((item) => (item.sequenceNo ?? 0) < (task.sequenceNo ?? 0) &&
160
+ item.status !== "SUCCEEDED"))
161
+ return "PREDECESSOR_NOT_SUCCEEDED";
162
+ if (siblings.some((item) => item.taskId !== task.taskId &&
163
+ ["READY", "ACTIVE", "WAITING", "FAILED", "PAUSED"].includes(item.status)))
164
+ return "TASK_GROUP_BUSY";
165
+ }
166
+ return null;
167
+ }
168
+ function recomputeReadiness(tx, task, timestamp, workspaceRoot) {
169
+ if (task.status !== "PENDING")
170
+ return task;
171
+ if (readinessBlockedReason(tx, task, workspaceRoot) !== null)
172
+ return task;
173
+ return {
174
+ ...task,
175
+ status: "READY",
176
+ version: task.version + 1,
177
+ updatedAt: timestamp,
178
+ };
179
+ }
180
+ function matchesWorker(actorRef, workerRef) {
181
+ return workerRef !== null && actorRef === `worker:${workerRef}`;
182
+ }
183
+ export function createTaskServices(options) {
184
+ const { store, workspaceRoot } = options;
185
+ const now = options.now ?? (() => new Date().toISOString());
186
+ const createId = options.createId ?? ((prefix) => `${prefix}-${randomUUID()}`);
187
+ function command(operation, raw, work) {
188
+ try {
189
+ const input = validatePublicInput(operation, raw);
190
+ const controls = input;
191
+ return store.transaction((tx) => {
192
+ const requestHash = hash(input);
193
+ const prior = tx.idempotency.get(controls.idempotencyKey);
194
+ if (prior) {
195
+ if (prior.operation !== operation ||
196
+ prior.requestHash !== requestHash)
197
+ throw new DomainError("IDEMPOTENCY_CONFLICT", "Idempotency key has a different request fingerprint.");
198
+ return JSON.parse(prior.responseJson);
199
+ }
200
+ const timestamp = now();
201
+ const data = work(tx, input, timestamp);
202
+ const auditEventType = auditEventTypes[operation];
203
+ if (auditEventType && typeof input === "object" && input !== null) {
204
+ const taskId = Reflect.get(input, "taskId");
205
+ const nodeId = Reflect.get(input, "nodeId");
206
+ if (typeof taskId === "string") {
207
+ const task = tx.tasks.get(taskId);
208
+ const node = typeof nodeId === "string" ? tx.nodes.get(nodeId) : undefined;
209
+ event(tx, {
210
+ taskId,
211
+ nodeId: typeof nodeId === "string" ? nodeId : null,
212
+ eventType: auditEventType,
213
+ actorRef: controls.actorRef,
214
+ taskVersion: task?.version ?? null,
215
+ nodeVersion: node?.version ?? null,
216
+ payload: null,
217
+ }, timestamp);
218
+ }
219
+ }
220
+ const result = success(data);
221
+ tx.idempotency.insert({
222
+ idempotencyKey: controls.idempotencyKey,
223
+ operation,
224
+ requestHash,
225
+ responseJson: JSON.stringify(result),
226
+ createdAt: timestamp,
227
+ });
228
+ return result;
229
+ });
230
+ }
231
+ catch (error) {
232
+ return failure(error);
233
+ }
234
+ }
235
+ function query(operation, raw, work) {
236
+ try {
237
+ const input = validatePublicInput(operation, raw);
238
+ return success(store.read((tx) => work(tx, input)));
239
+ }
240
+ catch (error) {
241
+ return failure(error);
242
+ }
243
+ }
244
+ const view = (tx, task) => ({
245
+ taskId: task.taskId,
246
+ taskGroupId: task.taskGroupId,
247
+ sequenceNo: task.sequenceNo,
248
+ title: task.title,
249
+ objective: task.objective,
250
+ status: task.status,
251
+ version: task.version,
252
+ planVersion: task.planVersion,
253
+ currentNodeId: task.currentNodeId,
254
+ nodes: tx.nodes.listByTask(task.taskId).map((node) => ({
255
+ nodeId: node.nodeId,
256
+ title: node.title,
257
+ status: node.status,
258
+ runNo: node.runNo,
259
+ requiredAgentPackageRef: node.requiredAgentPackageRef,
260
+ workerRef: node.workerRef,
261
+ version: node.version,
262
+ })),
263
+ roleBindings: tx.roleBindings.listByTask(task.taskId).map((binding) => ({
264
+ agentPackageRef: binding.agentPackageRef,
265
+ roleRef: binding.roleRef,
266
+ workerRef: binding.workerRef,
267
+ conversationLocator: binding.conversationLocator,
268
+ })),
269
+ pendingMessages: tx.messages
270
+ .listPending()
271
+ .filter((message) => message.taskId === task.taskId),
272
+ readiness: {
273
+ ready: task.status === "READY" &&
274
+ readinessBlockedReason(tx, task, workspaceRoot) === null,
275
+ },
276
+ });
277
+ const relativeDocumentPath = (taskId, type) => `.proflow/tasks/${/^[A-Za-z0-9_-]+$/.test(taskId) ? taskId : `id-${createHash("sha256").update(taskId).digest("hex")}`}/documents/${type.toLowerCase().replaceAll("_", "-")}.md`;
278
+ const ensureType = (value) => {
279
+ if (!allowedDocumentTypes.includes(value))
280
+ throw new DomainError("DOCUMENT_TYPE_NOT_ALLOWED", `Document type ${value} is not allowed.`);
281
+ return value;
282
+ };
283
+ const atomicWrite = (relativePath, content) => {
284
+ const path = join(workspaceRoot, relativePath);
285
+ const directory = dirname(path);
286
+ mkdirSync(directory, { recursive: true });
287
+ const temporary = `${path}.tmp-${randomUUID()}`;
288
+ writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" });
289
+ const descriptor = openSync(temporary, "r");
290
+ try {
291
+ fsyncSync(descriptor);
292
+ }
293
+ finally {
294
+ closeSync(descriptor);
295
+ }
296
+ renameSync(temporary, path);
297
+ const directoryDescriptor = openSync(directory, "r");
298
+ try {
299
+ fsyncSync(directoryDescriptor);
300
+ }
301
+ finally {
302
+ closeSync(directoryDescriptor);
303
+ }
304
+ };
305
+ const writeDocument = options.writeDocument ?? atomicWrite;
306
+ const promoteDocument = options.promoteDocument ??
307
+ ((stagedRelativePath, finalRelativePath) => {
308
+ const staged = join(workspaceRoot, stagedRelativePath);
309
+ const final = join(workspaceRoot, finalRelativePath);
310
+ mkdirSync(dirname(final), { recursive: true });
311
+ renameSync(staged, final);
312
+ const directoryDescriptor = openSync(dirname(final), "r");
313
+ try {
314
+ fsyncSync(directoryDescriptor);
315
+ }
316
+ finally {
317
+ closeSync(directoryDescriptor);
318
+ }
319
+ });
320
+ const safeTaskId = (taskId) => /^[A-Za-z0-9_-]+$/.test(taskId)
321
+ ? taskId
322
+ : `id-${createHash("sha256").update(taskId).digest("hex")}`;
323
+ const stageDocumentPath = (taskId, type) => `.proflow/recovery/task-create/${safeTaskId(taskId)}/${type.toLowerCase().replaceAll("_", "-")}.md`;
324
+ const documentUpdateRecoveryRoot = (taskId, type) => `.proflow/recovery/task-document/${safeTaskId(taskId)}/${type.toLowerCase().replaceAll("_", "-")}`;
325
+ const documentUpdateRecoveryPath = (taskId, type, idempotencyKey) => `${documentUpdateRecoveryRoot(taskId, type)}/${createHash("sha256").update(idempotencyKey).digest("hex")}`;
326
+ const recoverPendingDocumentUpdates = (taskId, type) => {
327
+ const recoveryRoot = documentUpdateRecoveryRoot(taskId, type);
328
+ const recoveryRootAbsolute = join(workspaceRoot, recoveryRoot);
329
+ if (!existsSync(recoveryRootAbsolute))
330
+ return;
331
+ const canonicalPath = relativeDocumentPath(taskId, type);
332
+ const canonicalAbsolute = join(workspaceRoot, canonicalPath);
333
+ const currentMetadata = store.read((tx) => tx.documents.get(taskId, type));
334
+ const databaseHash = currentMetadata?.contentHash ?? null;
335
+ for (const entry of readdirSync(recoveryRootAbsolute, {
336
+ withFileTypes: true,
337
+ })) {
338
+ if (!entry.isDirectory())
339
+ continue;
340
+ const attemptAbsolute = join(recoveryRootAbsolute, entry.name);
341
+ const stateAbsolute = join(attemptAbsolute, "state.json");
342
+ if (!existsSync(stateAbsolute)) {
343
+ rmSync(attemptAbsolute, { recursive: true, force: true });
344
+ continue;
345
+ }
346
+ let state;
347
+ try {
348
+ state = JSON.parse(readFileSync(stateAbsolute, "utf8"));
349
+ }
350
+ catch {
351
+ throw new DomainError("DOCUMENT_RECOVERY_REQUIRED", "Task document recovery journal is unreadable.");
352
+ }
353
+ const canonicalHash = existsSync(canonicalAbsolute)
354
+ ? contentHash(readFileSync(canonicalAbsolute, "utf8"))
355
+ : null;
356
+ if (canonicalHash === databaseHash) {
357
+ rmSync(attemptAbsolute, { recursive: true, force: true });
358
+ continue;
359
+ }
360
+ if (state.previousHash !== databaseHash)
361
+ throw new DomainError("DOCUMENT_RECOVERY_REQUIRED", "Task document recovery state does not match durable metadata.");
362
+ if (state.previousHash === null) {
363
+ rmSync(canonicalAbsolute, { force: true });
364
+ }
365
+ else {
366
+ const previousAbsolute = join(attemptAbsolute, "previous.md");
367
+ if (!existsSync(previousAbsolute))
368
+ throw new DomainError("DOCUMENT_RECOVERY_REQUIRED", "Task document recovery backup is missing.");
369
+ const previousContent = readFileSync(previousAbsolute, "utf8");
370
+ if (contentHash(previousContent) !== state.previousHash)
371
+ throw new DomainError("DOCUMENT_RECOVERY_REQUIRED", "Task document recovery backup hash does not match metadata.");
372
+ atomicWrite(canonicalPath, previousContent);
373
+ }
374
+ rmSync(attemptAbsolute, { recursive: true, force: true });
375
+ }
376
+ if (readdirSync(recoveryRootAbsolute).length === 0)
377
+ rmSync(recoveryRootAbsolute, { recursive: true, force: true });
378
+ };
379
+ const cleanupCreateStage = (taskId) => {
380
+ rmSync(join(workspaceRoot, `.proflow/recovery/task-create/${safeTaskId(taskId)}`), { recursive: true, force: true });
381
+ };
382
+ // A createTask crash window exists between the SQLite COMMIT of
383
+ // Task/task_documents metadata and the promotion of staged initial documents
384
+ // to their canonical Markdown paths. Mirroring the putTaskDocument recovery
385
+ // protocol, a first owner read consumes the pending task-create stage so the
386
+ // canonical Markdown converges with durable metadata instead of remaining
387
+ // missing.
388
+ const recoverTaskCreateStage = (taskId) => {
389
+ if (!store.read((tx) => tx.tasks.get(taskId) !== undefined)) {
390
+ cleanupCreateStage(taskId);
391
+ return;
392
+ }
393
+ for (const type of allowedDocumentTypes) {
394
+ const staged = stageDocumentPath(taskId, type);
395
+ const stagedAbsolute = join(workspaceRoot, staged);
396
+ if (!existsSync(stagedAbsolute))
397
+ continue;
398
+ const canonical = relativeDocumentPath(taskId, type);
399
+ if (existsSync(join(workspaceRoot, canonical)))
400
+ continue;
401
+ promoteDocument(staged, canonical);
402
+ }
403
+ cleanupCreateStage(taskId);
404
+ };
405
+ const recoverTaskDocuments = (taskId) => {
406
+ for (const type of allowedDocumentTypes)
407
+ recoverPendingDocumentUpdates(taskId, type);
408
+ recoverTaskCreateStage(taskId);
409
+ };
410
+ const queryWithDocumentRecovery = (operation, raw, work) => {
411
+ try {
412
+ const input = validatePublicInput(operation, raw);
413
+ recoverTaskDocuments(input.taskId);
414
+ return success(store.read((tx) => work(tx, input)));
415
+ }
416
+ catch (error) {
417
+ return failure(error);
418
+ }
419
+ };
420
+ const commands = {
421
+ createTaskGroup: (raw) => command("createTaskGroup", raw, (tx, input, timestamp) => {
422
+ const taskGroupId = input.taskGroupId ?? createId("task-group");
423
+ if (tx.taskGroups.get(taskGroupId))
424
+ throw new DomainError("TASK_GROUP_ALREADY_EXISTS", "TaskGroup already exists.");
425
+ const value = {
426
+ taskGroupId,
427
+ title: input.title,
428
+ objective: input.objective ?? null,
429
+ status: "READY",
430
+ maxActiveTasks: 1,
431
+ version: 1,
432
+ createdByRef: input.actorRef,
433
+ createdAt: timestamp,
434
+ updatedAt: timestamp,
435
+ };
436
+ tx.taskGroups.insert(value);
437
+ return value;
438
+ }),
439
+ startTaskGroup: (raw) => command("startTaskGroup", raw, (tx, input, timestamp) => {
440
+ const group = tx.taskGroups.get(input.taskGroupId);
441
+ if (!group)
442
+ throw new DomainError("TASK_GROUP_NOT_FOUND", "TaskGroup was not found.");
443
+ checkVersion(group.version, input.expectedGroupVersion, "TASK_GROUP_VERSION_CONFLICT");
444
+ if (group.status !== "READY")
445
+ throw new DomainError("TASK_GROUP_INVALID_STATE", "TaskGroup is not READY.");
446
+ const updated = {
447
+ ...group,
448
+ status: "ACTIVE",
449
+ version: group.version + 1,
450
+ updatedAt: timestamp,
451
+ };
452
+ tx.taskGroups.update(updated);
453
+ let firstEligibleTaskId = null;
454
+ const members = [...tx.tasks.list(group.taskGroupId)].sort((left, right) => (left.sequenceNo ?? 0) - (right.sequenceNo ?? 0));
455
+ for (const member of members) {
456
+ const readied = recomputeReadiness(tx, member, timestamp, workspaceRoot);
457
+ if (readied.status !== member.status)
458
+ tx.tasks.update(readied);
459
+ if (firstEligibleTaskId === null && readied.status === "READY")
460
+ firstEligibleTaskId = readied.taskId;
461
+ }
462
+ return { ...updated, firstEligibleTaskId };
463
+ }),
464
+ createTask: (raw) => {
465
+ let normalized;
466
+ let stagedTaskId = "";
467
+ try {
468
+ const input = validatePublicInput("createTask", raw);
469
+ stagedTaskId =
470
+ input.taskId ??
471
+ `task-${createHash("sha256").update(input.idempotencyKey).digest("hex").slice(0, 24)}`;
472
+ normalized = { ...input, taskId: stagedTaskId };
473
+ for (const document of normalized.initialDocuments)
474
+ ensureType(document.documentType);
475
+ for (const node of normalized.plan.nodes)
476
+ for (const documentType of [
477
+ ...node.inputDocuments,
478
+ ...node.outputDocuments,
479
+ ])
480
+ ensureType(documentType);
481
+ if (new Set(normalized.initialDocuments.map((item) => item.documentType))
482
+ .size !== normalized.initialDocuments.length)
483
+ throw new DomainError("INVALID_REQUEST", "Initial documentType values must be unique.");
484
+ if (new Set(normalized.roleBindings.map((item) => item.agentPackageRef))
485
+ .size !== normalized.roleBindings.length)
486
+ throw new DomainError("INVALID_REQUEST", "Task role bindings must be unique by agentPackageRef.");
487
+ const declaredAgentPackageRefs = new Set(normalized.roleBindings.map((item) => item.agentPackageRef));
488
+ if (declaredAgentPackageRefs.size !==
489
+ requiredTaskAgentPackageRefs.length ||
490
+ requiredTaskAgentPackageRefs.some((agentPackageRef) => !declaredAgentPackageRefs.has(agentPackageRef)))
491
+ throw new DomainError("INVALID_REQUEST", "Task must declare exactly the fixed Product, Controller/Dev, and Test/Ops agent packages.");
492
+ for (const document of normalized.initialDocuments) {
493
+ const type = document.documentType;
494
+ const stagedPath = stageDocumentPath(stagedTaskId, type);
495
+ const stagedAbsolute = join(workspaceRoot, stagedPath);
496
+ if (existsSync(stagedAbsolute)) {
497
+ if (contentHash(readFileSync(stagedAbsolute, "utf8")) !==
498
+ contentHash(document.content))
499
+ throw new DomainError("DOCUMENT_WRITE_FAILED", "Staged document content does not match the retried request.");
500
+ }
501
+ else {
502
+ writeDocument(stagedPath, document.content);
503
+ }
504
+ }
505
+ }
506
+ catch (error) {
507
+ if (typeof stagedTaskId === "string")
508
+ cleanupCreateStage(stagedTaskId);
509
+ return failure(error);
510
+ }
511
+ const result = command("createTask", normalized, (tx, validated, timestamp) => {
512
+ const taskId = validated.taskId ?? createId("task");
513
+ if (tx.tasks.get(taskId))
514
+ throw new DomainError("TASK_ALREADY_EXISTS", "Task already exists.");
515
+ if (validated.taskGroupId &&
516
+ !tx.taskGroups.get(validated.taskGroupId))
517
+ throw new DomainError("TASK_GROUP_NOT_FOUND", "TaskGroup was not found.");
518
+ const value = {
519
+ taskId,
520
+ taskGroupId: validated.taskGroupId ?? null,
521
+ sequenceNo: validated.sequenceNo ?? null,
522
+ title: validated.title,
523
+ objective: validated.objective,
524
+ status: "PENDING",
525
+ version: 1,
526
+ planVersion: 1,
527
+ currentNodeId: null,
528
+ createdByRef: validated.actorRef,
529
+ createdAt: timestamp,
530
+ startedAt: null,
531
+ completedAt: null,
532
+ updatedAt: timestamp,
533
+ };
534
+ tx.tasks.insert(value);
535
+ validated.plan.nodes.forEach((item, index) => {
536
+ tx.nodes.insert({
537
+ nodeId: item.nodeId ?? createId("node"),
538
+ taskId,
539
+ sequenceNo: index + 1,
540
+ title: item.title,
541
+ objective: item.objective,
542
+ status: "PENDING",
543
+ version: 1,
544
+ runNo: 1,
545
+ requiredAgentPackageRef: item.requiredAgentPackageRef,
546
+ workerRef: null,
547
+ inputDocuments: item.inputDocuments,
548
+ outputDocuments: item.outputDocuments,
549
+ resultSummary: null,
550
+ errorCode: null,
551
+ errorMessage: null,
552
+ errorRetryable: null,
553
+ startedAt: null,
554
+ completedAt: null,
555
+ updatedAt: timestamp,
556
+ });
557
+ });
558
+ for (const item of validated.roleBindings)
559
+ tx.roleBindings.upsert({
560
+ taskId,
561
+ agentPackageRef: item.agentPackageRef,
562
+ roleRef: item.roleRef,
563
+ workerRef: item.workerRef,
564
+ conversationLocator: item.conversationLocator,
565
+ version: 1,
566
+ createdAt: timestamp,
567
+ updatedAt: timestamp,
568
+ });
569
+ for (const item of validated.initialDocuments) {
570
+ const path = relativeDocumentPath(taskId, item.documentType);
571
+ tx.documents.upsert({
572
+ taskId,
573
+ documentType: item.documentType,
574
+ sourceNodeId: null,
575
+ filePath: path,
576
+ contentHash: contentHash(item.content),
577
+ updatedByRef: validated.actorRef,
578
+ updatedAt: timestamp,
579
+ });
580
+ }
581
+ event(tx, {
582
+ taskId,
583
+ nodeId: null,
584
+ eventType: "TASK_CREATED",
585
+ actorRef: validated.actorRef,
586
+ taskVersion: 1,
587
+ nodeVersion: null,
588
+ payload: null,
589
+ }, timestamp);
590
+ return {
591
+ taskId: value.taskId,
592
+ taskGroupId: value.taskGroupId,
593
+ status: value.status,
594
+ version: value.version,
595
+ planVersion: value.planVersion,
596
+ currentNodeId: value.currentNodeId,
597
+ roleBindings: validated.roleBindings.map((binding) => ({
598
+ agentPackageRef: binding.agentPackageRef,
599
+ roleRef: binding.roleRef,
600
+ workerRef: binding.workerRef,
601
+ conversationLocator: binding.conversationLocator,
602
+ })),
603
+ };
604
+ });
605
+ if (!result.ok) {
606
+ cleanupCreateStage(stagedTaskId);
607
+ return result;
608
+ }
609
+ try {
610
+ for (const document of normalized.initialDocuments) {
611
+ const type = document.documentType;
612
+ promoteDocument(stageDocumentPath(stagedTaskId, type), relativeDocumentPath(stagedTaskId, type));
613
+ }
614
+ cleanupCreateStage(stagedTaskId);
615
+ return result;
616
+ }
617
+ catch (error) {
618
+ return failure(new DomainError("DOCUMENT_WRITE_FAILED", error instanceof Error
619
+ ? error.message
620
+ : "document promotion failed", true));
621
+ }
622
+ },
623
+ bindTaskWorker: (raw) => command("bindTaskWorker", raw, (tx, input, timestamp) => {
624
+ const task = requireTask(tx, input.taskId);
625
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
626
+ if (["SUCCEEDED", "TERMINATED"].includes(task.status))
627
+ throw new DomainError("TASK_INVALID_STATE", "Task is terminal.");
628
+ const old = tx.roleBindings.get(task.taskId, input.agentPackageRef);
629
+ if (!old)
630
+ throw new DomainError("AGENT_PACKAGE_NOT_ELIGIBLE", "Agent package is not declared by the Task.");
631
+ if (old.roleRef !== input.roleRef)
632
+ throw new DomainError("ROLE_BINDING_MISMATCH", "RoleRef does not match the declared binding.");
633
+ if (old.workerRef === input.workerRef &&
634
+ old.conversationLocator === input.conversationLocator)
635
+ return {
636
+ taskId: task.taskId,
637
+ version: task.version,
638
+ roleBinding: {
639
+ agentPackageRef: old.agentPackageRef,
640
+ roleRef: old.roleRef,
641
+ workerRef: input.workerRef,
642
+ conversationLocator: input.conversationLocator,
643
+ },
644
+ };
645
+ if (old.workerRef !== null && old.workerRef !== input.workerRef)
646
+ throw new DomainError("TASK_ROLE_BINDING_CONFLICT", "Role is already bound to a different Worker.");
647
+ if (old.conversationLocator !== null &&
648
+ old.conversationLocator !== input.conversationLocator)
649
+ throw new DomainError("TASK_ROLE_BINDING_CONFLICT", "Role is already bound to a different Conversation locator.");
650
+ tx.roleBindings.upsert({
651
+ ...old,
652
+ workerRef: input.workerRef,
653
+ conversationLocator: input.conversationLocator,
654
+ version: old.version + 1,
655
+ updatedAt: timestamp,
656
+ });
657
+ const updated = {
658
+ ...task,
659
+ version: task.version + 1,
660
+ updatedAt: timestamp,
661
+ };
662
+ tx.tasks.update(updated);
663
+ const readied = recomputeReadiness(tx, updated, timestamp, workspaceRoot);
664
+ if (readied.status !== updated.status)
665
+ tx.tasks.update(readied);
666
+ return {
667
+ taskId: readied.taskId,
668
+ version: readied.version,
669
+ roleBinding: {
670
+ agentPackageRef: old.agentPackageRef,
671
+ roleRef: old.roleRef,
672
+ workerRef: input.workerRef,
673
+ conversationLocator: input.conversationLocator,
674
+ },
675
+ };
676
+ }),
677
+ startTask: (raw) => command("startTask", raw, (tx, input, timestamp) => {
678
+ const task = requireTask(tx, input.taskId);
679
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
680
+ if (task.status !== "READY")
681
+ throw new DomainError("TASK_INVALID_STATE", "Task is not READY.");
682
+ const blockedReason = readinessBlockedReason(tx, task, workspaceRoot);
683
+ if (blockedReason !== null)
684
+ throw new DomainError("TASK_BLOCKED", `Task readiness prerequisite failed: ${blockedReason}.`, false, { blockedReason });
685
+ const nodes = tx.nodes.listByTask(task.taskId);
686
+ const first = nodes[0];
687
+ if (!first)
688
+ throw new DomainError("TASK_PLAN_EMPTY", "Task has no nodes.");
689
+ const ready = {
690
+ ...first,
691
+ status: "READY",
692
+ version: first.version + 1,
693
+ updatedAt: timestamp,
694
+ };
695
+ tx.nodes.update(ready);
696
+ const updated = {
697
+ ...task,
698
+ status: "ACTIVE",
699
+ version: task.version + 1,
700
+ currentNodeId: first.nodeId,
701
+ startedAt: timestamp,
702
+ updatedAt: timestamp,
703
+ };
704
+ tx.tasks.update(updated);
705
+ return view(tx, updated);
706
+ }),
707
+ pauseTask: (raw) => command("pauseTask", raw, (tx, input, timestamp) => {
708
+ const task = requireTask(tx, input.taskId);
709
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
710
+ if (!["ACTIVE", "WAITING"].includes(task.status))
711
+ throw new DomainError("TASK_INVALID_STATE", "Task cannot be paused.");
712
+ const updated = {
713
+ ...task,
714
+ status: "PAUSED",
715
+ version: task.version + 1,
716
+ updatedAt: timestamp,
717
+ };
718
+ tx.tasks.update(updated);
719
+ return updated;
720
+ }),
721
+ resumeTask: (raw) => command("resumeTask", raw, (tx, input, timestamp) => {
722
+ const task = requireTask(tx, input.taskId);
723
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
724
+ if (!["WAITING", "PAUSED"].includes(task.status))
725
+ throw new DomainError("TASK_INVALID_STATE", "Task cannot be resumed.");
726
+ const current = task.currentNodeId
727
+ ? tx.nodes.get(task.currentNodeId)
728
+ : undefined;
729
+ if (current?.status === "FAILED")
730
+ throw new DomainError("TASK_INVALID_STATE", "A failed Node must be explicitly reopened before resume.");
731
+ if (current?.status === "WAITING")
732
+ tx.nodes.update({
733
+ ...current,
734
+ status: "IN_PROGRESS",
735
+ version: current.version + 1,
736
+ updatedAt: timestamp,
737
+ });
738
+ const updated = {
739
+ ...task,
740
+ status: "ACTIVE",
741
+ version: task.version + 1,
742
+ updatedAt: timestamp,
743
+ };
744
+ tx.tasks.update(updated);
745
+ return updated;
746
+ }),
747
+ terminateTask: (raw) => command("terminateTask", raw, (tx, input, timestamp) => {
748
+ const task = requireTask(tx, input.taskId);
749
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
750
+ if (["SUCCEEDED", "TERMINATED"].includes(task.status))
751
+ throw new DomainError("TASK_INVALID_STATE", "Task is terminal.");
752
+ for (const item of tx.nodes.listByTask(task.taskId))
753
+ if (!["SUCCEEDED", "TERMINATED"].includes(item.status)) {
754
+ preserveRunHistory(tx, item, "TERMINATED", timestamp);
755
+ tx.nodes.update({
756
+ ...item,
757
+ status: "TERMINATED",
758
+ version: item.version + 1,
759
+ completedAt: timestamp,
760
+ updatedAt: timestamp,
761
+ });
762
+ }
763
+ const updated = {
764
+ ...task,
765
+ status: "TERMINATED",
766
+ version: task.version + 1,
767
+ completedAt: timestamp,
768
+ updatedAt: timestamp,
769
+ };
770
+ tx.tasks.update(updated);
771
+ return updated;
772
+ }),
773
+ startNode: (raw) => command("startNode", raw, (tx, input, timestamp) => {
774
+ const task = requireTask(tx, input.taskId);
775
+ const node = requireNode(tx, input.taskId, input.nodeId);
776
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
777
+ checkVersion(node.version, input.expectedNodeVersion, "NODE_VERSION_CONFLICT");
778
+ if (task.status !== "ACTIVE" ||
779
+ node.status !== "READY" ||
780
+ task.currentNodeId !== node.nodeId)
781
+ throw new DomainError("NODE_INVALID_STATE", "Node is not READY/current.");
782
+ const binding = tx.roleBindings.get(task.taskId, node.requiredAgentPackageRef);
783
+ if (!binding?.workerRef || !binding.conversationLocator)
784
+ throw new DomainError("TASK_ROLE_BINDING_REQUIRED", "Node role is not bound.");
785
+ const updatedNode = {
786
+ ...node,
787
+ status: "IN_PROGRESS",
788
+ workerRef: binding.workerRef,
789
+ version: node.version + 1,
790
+ startedAt: timestamp,
791
+ updatedAt: timestamp,
792
+ };
793
+ const updatedTask = {
794
+ ...task,
795
+ version: task.version + 1,
796
+ updatedAt: timestamp,
797
+ };
798
+ tx.nodes.update(updatedNode);
799
+ tx.tasks.update(updatedTask);
800
+ return nodeResult(updatedTask, updatedNode);
801
+ }),
802
+ completeNode: (raw) => command("completeNode", raw, (tx, input, timestamp) => {
803
+ const task = requireTask(tx, input.taskId);
804
+ const node = requireNode(tx, input.taskId, input.nodeId);
805
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
806
+ checkVersion(node.version, input.expectedNodeVersion, "NODE_VERSION_CONFLICT");
807
+ if (task.status !== "ACTIVE" ||
808
+ node.status !== "IN_PROGRESS" ||
809
+ !matchesWorker(input.actorRef, node.workerRef))
810
+ throw new DomainError(!matchesWorker(input.actorRef, node.workerRef)
811
+ ? "WORKER_MISMATCH"
812
+ : "NODE_INVALID_STATE", "Only the bound running Worker may complete the Node.");
813
+ for (const type of node.outputDocuments) {
814
+ const doc = tx.documents.get(task.taskId, type);
815
+ if (!doc || !existsSync(join(workspaceRoot, doc.filePath)))
816
+ throw new DomainError("NODE_OUTPUT_MISSING", "A required output document is missing.");
817
+ if (contentHash(readFileSync(join(workspaceRoot, doc.filePath), "utf8")) !== doc.contentHash)
818
+ throw new DomainError("DOCUMENT_INDEX_MISMATCH", "A required output document does not match its index.");
819
+ }
820
+ const completedNode = {
821
+ ...node,
822
+ status: "SUCCEEDED",
823
+ version: node.version + 1,
824
+ resultSummary: input.resultSummary,
825
+ completedAt: timestamp,
826
+ updatedAt: timestamp,
827
+ };
828
+ tx.nodes.update(completedNode);
829
+ tx.executionHistory.insert({
830
+ taskId: task.taskId,
831
+ nodeId: node.nodeId,
832
+ runNo: node.runNo,
833
+ workerRef: node.workerRef,
834
+ finalStatus: "SUCCEEDED",
835
+ resultSummary: input.resultSummary,
836
+ errorCode: null,
837
+ errorMessage: null,
838
+ errorRetryable: null,
839
+ inputDocuments: node.inputDocuments,
840
+ outputDocuments: node.outputDocuments,
841
+ startedAt: node.startedAt,
842
+ endedAt: timestamp,
843
+ });
844
+ const nodes = tx.nodes.listByTask(task.taskId);
845
+ const next = nodes.find((item) => item.sequenceNo > node.sequenceNo && item.status === "PENDING");
846
+ let updatedTask;
847
+ if (next) {
848
+ tx.nodes.update({
849
+ ...next,
850
+ status: "READY",
851
+ version: next.version + 1,
852
+ updatedAt: timestamp,
853
+ });
854
+ updatedTask = {
855
+ ...task,
856
+ version: task.version + 1,
857
+ currentNodeId: next.nodeId,
858
+ updatedAt: timestamp,
859
+ };
860
+ }
861
+ else {
862
+ updatedTask = {
863
+ ...task,
864
+ status: "SUCCEEDED",
865
+ version: task.version + 1,
866
+ currentNodeId: null,
867
+ completedAt: timestamp,
868
+ updatedAt: timestamp,
869
+ };
870
+ }
871
+ tx.tasks.update(updatedTask);
872
+ if (updatedTask.status === "SUCCEEDED" && task.taskGroupId) {
873
+ const group = tx.taskGroups.get(task.taskGroupId);
874
+ if (group) {
875
+ const groupTasks = tx.tasks.list(group.taskGroupId);
876
+ const nextTask = groupTasks.find((item) => (item.sequenceNo ?? 0) > (task.sequenceNo ?? 0) &&
877
+ item.status === "PENDING");
878
+ if (nextTask) {
879
+ const readied = recomputeReadiness(tx, nextTask, timestamp, workspaceRoot);
880
+ if (readied.status !== nextTask.status)
881
+ tx.tasks.update(readied);
882
+ }
883
+ const allDone = groupTasks
884
+ .filter((item) => item.taskId !== task.taskId)
885
+ .every((item) => item.status === "SUCCEEDED");
886
+ tx.taskGroups.update({
887
+ ...group,
888
+ status: allDone ? "SUCCEEDED" : group.status,
889
+ version: group.version + 1,
890
+ updatedAt: timestamp,
891
+ });
892
+ }
893
+ }
894
+ return {
895
+ nodeId: completedNode.nodeId,
896
+ status: "SUCCEEDED",
897
+ runNo: completedNode.runNo,
898
+ completedAt: timestamp,
899
+ taskStatus: updatedTask.status,
900
+ taskVersion: updatedTask.version,
901
+ nextNodeId: updatedTask.currentNodeId,
902
+ };
903
+ }),
904
+ waitNode: (raw) => command("waitNode", raw, (tx, input, timestamp) => {
905
+ const task = requireTask(tx, input.taskId);
906
+ const node = requireNode(tx, input.taskId, input.nodeId);
907
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
908
+ checkVersion(node.version, input.expectedNodeVersion, "NODE_VERSION_CONFLICT");
909
+ if (task.status !== "ACTIVE")
910
+ throw new DomainError("TASK_INVALID_STATE", "Task must be ACTIVE before a Node can wait.");
911
+ if (node.status !== "IN_PROGRESS" ||
912
+ !matchesWorker(input.actorRef, node.workerRef))
913
+ throw new DomainError(!matchesWorker(input.actorRef, node.workerRef)
914
+ ? "WORKER_MISMATCH"
915
+ : "NODE_INVALID_STATE", "Node cannot wait.");
916
+ tx.nodes.update({
917
+ ...node,
918
+ status: "WAITING",
919
+ version: node.version + 1,
920
+ updatedAt: timestamp,
921
+ });
922
+ const updated = {
923
+ ...task,
924
+ status: "WAITING",
925
+ version: task.version + 1,
926
+ updatedAt: timestamp,
927
+ };
928
+ tx.tasks.update(updated);
929
+ tx.messages.insert({
930
+ messageId: createId("message"),
931
+ taskId: task.taskId,
932
+ nodeId: node.nodeId,
933
+ messageType: input.waitType,
934
+ reasonCode: input.reasonCode,
935
+ message: input.message,
936
+ relatedRef: input.relatedRef ?? null,
937
+ status: "PENDING",
938
+ createdByRef: input.actorRef,
939
+ createdAt: timestamp,
940
+ acknowledgedByRef: null,
941
+ acknowledgedAt: null,
942
+ resolution: null,
943
+ });
944
+ return updated;
945
+ }),
946
+ failNode: (raw) => command("failNode", raw, (tx, input, timestamp) => {
947
+ const task = requireTask(tx, input.taskId);
948
+ const node = requireNode(tx, input.taskId, input.nodeId);
949
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
950
+ checkVersion(node.version, input.expectedNodeVersion, "NODE_VERSION_CONFLICT");
951
+ if (!["ACTIVE", "WAITING"].includes(task.status))
952
+ throw new DomainError("TASK_INVALID_STATE", "Task cannot accept a Node failure in its current state.");
953
+ if (!["IN_PROGRESS", "WAITING"].includes(node.status))
954
+ throw new DomainError("NODE_INVALID_STATE", "Node cannot fail.");
955
+ const failedNode = {
956
+ ...node,
957
+ status: "FAILED",
958
+ version: node.version + 1,
959
+ errorCode: input.errorCode,
960
+ errorMessage: input.errorMessage,
961
+ errorRetryable: input.retryable,
962
+ completedAt: timestamp,
963
+ updatedAt: timestamp,
964
+ };
965
+ tx.nodes.update(failedNode);
966
+ preserveRunHistory(tx, failedNode, "FAILED", timestamp);
967
+ const updated = {
968
+ ...task,
969
+ status: "FAILED",
970
+ version: task.version + 1,
971
+ updatedAt: timestamp,
972
+ };
973
+ tx.tasks.update(updated);
974
+ return updated;
975
+ }),
976
+ reopenNode: (raw) => command("reopenNode", raw, (tx, input, timestamp) => {
977
+ const task = requireTask(tx, input.taskId);
978
+ const target = requireNode(tx, input.taskId, input.nodeId);
979
+ checkVersion(task.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
980
+ if (["SUCCEEDED", "TERMINATED"].includes(task.status))
981
+ throw new DomainError("TASK_INVALID_STATE", "A terminal Task cannot reopen.");
982
+ if (!["SUCCEEDED", "FAILED"].includes(target.status))
983
+ throw new DomainError("NODE_INVALID_STATE", "Only completed or failed Nodes can reopen.");
984
+ preserveRunHistory(tx, target, target.status, timestamp);
985
+ const reopened = {
986
+ ...target,
987
+ status: "READY",
988
+ version: target.version + 1,
989
+ runNo: target.runNo + 1,
990
+ workerRef: null,
991
+ resultSummary: null,
992
+ errorCode: null,
993
+ errorMessage: null,
994
+ errorRetryable: null,
995
+ startedAt: null,
996
+ completedAt: null,
997
+ updatedAt: timestamp,
998
+ };
999
+ tx.nodes.update(reopened);
1000
+ for (const later of tx.nodes.listByTask(task.taskId))
1001
+ if (later.sequenceNo > target.sequenceNo) {
1002
+ preserveRunHistory(tx, later, later.status, timestamp);
1003
+ tx.nodes.update({
1004
+ ...later,
1005
+ status: "PENDING",
1006
+ version: later.version + 1,
1007
+ workerRef: null,
1008
+ resultSummary: null,
1009
+ errorCode: null,
1010
+ errorMessage: null,
1011
+ errorRetryable: null,
1012
+ startedAt: null,
1013
+ completedAt: null,
1014
+ updatedAt: timestamp,
1015
+ });
1016
+ }
1017
+ const updated = {
1018
+ ...task,
1019
+ status: "ACTIVE",
1020
+ version: task.version + 1,
1021
+ currentNodeId: target.nodeId,
1022
+ completedAt: null,
1023
+ updatedAt: timestamp,
1024
+ };
1025
+ tx.tasks.update(updated);
1026
+ event(tx, {
1027
+ taskId: task.taskId,
1028
+ nodeId: target.nodeId,
1029
+ eventType: "NODE_REOPENED",
1030
+ actorRef: input.actorRef,
1031
+ taskVersion: updated.version,
1032
+ nodeVersion: reopened.version,
1033
+ payload: { reason: input.reason },
1034
+ }, timestamp);
1035
+ return {
1036
+ ...nodeResult(updated, reopened),
1037
+ currentNodeId: target.nodeId,
1038
+ };
1039
+ }),
1040
+ acknowledgeMessage: (raw) => command("acknowledgeMessage", raw, (tx, input, timestamp) => {
1041
+ const message = tx.messages.get(input.messageId);
1042
+ if (!message)
1043
+ throw new DomainError("MESSAGE_NOT_FOUND", "Message was not found.");
1044
+ if (message.status === "ACKNOWLEDGED")
1045
+ return message;
1046
+ const updated = {
1047
+ ...message,
1048
+ status: "ACKNOWLEDGED",
1049
+ acknowledgedByRef: input.actorRef,
1050
+ acknowledgedAt: timestamp,
1051
+ resolution: input.resolution ?? null,
1052
+ };
1053
+ tx.messages.update(updated);
1054
+ const task = tx.tasks.get(message.taskId);
1055
+ event(tx, {
1056
+ taskId: message.taskId,
1057
+ nodeId: message.nodeId,
1058
+ eventType: "MESSAGE_ACKNOWLEDGED",
1059
+ actorRef: input.actorRef,
1060
+ taskVersion: task?.version ?? null,
1061
+ nodeVersion: message.nodeId
1062
+ ? (tx.nodes.get(message.nodeId)?.version ?? null)
1063
+ : null,
1064
+ payload: { messageId: message.messageId },
1065
+ }, timestamp);
1066
+ return updated;
1067
+ }),
1068
+ };
1069
+ const queries = {
1070
+ getTaskGroup: (raw) => query("getTaskGroup", raw, (tx, input) => {
1071
+ const group = tx.taskGroups.get(input.taskGroupId);
1072
+ if (!group)
1073
+ throw new DomainError("TASK_GROUP_NOT_FOUND", "TaskGroup was not found.");
1074
+ const tasks = tx.tasks.list(group.taskGroupId);
1075
+ return {
1076
+ ...group,
1077
+ currentTaskId: tasks.find((item) => ["READY", "ACTIVE", "WAITING", "FAILED", "PAUSED"].includes(item.status))?.taskId ?? null,
1078
+ tasks,
1079
+ };
1080
+ }),
1081
+ listTasks: (raw) => query("listTasks", raw, (tx, input) => {
1082
+ const tasks = tx.tasks
1083
+ .list(input.taskGroupId)
1084
+ .filter((item) => input.statuses === undefined ||
1085
+ input.statuses.includes(item.status));
1086
+ return {
1087
+ tasks: tasks.map((item) => {
1088
+ let blockedReason = readinessBlockedReason(tx, item, workspaceRoot);
1089
+ if (item.status !== "READY" && blockedReason === null)
1090
+ blockedReason = "TASK_NOT_READY";
1091
+ return {
1092
+ ...item,
1093
+ canStart: item.status === "READY" && blockedReason === null,
1094
+ blockedReason,
1095
+ };
1096
+ }),
1097
+ };
1098
+ }),
1099
+ getTask: (raw) => query("getTask", raw, (tx, input) => view(tx, requireTask(tx, input.taskId))),
1100
+ getTaskDriveProjection: (raw) => query("getTaskDriveProjection", raw, (tx, input) => {
1101
+ const task = requireTask(tx, input.taskId);
1102
+ const terminal = ["SUCCEEDED", "TERMINATED"].includes(task.status);
1103
+ const currentNode = task.currentNodeId
1104
+ ? tx.nodes.get(task.currentNodeId)
1105
+ : undefined;
1106
+ const roleBinding = currentNode
1107
+ ? tx.roleBindings.get(task.taskId, currentNode.requiredAgentPackageRef)
1108
+ : undefined;
1109
+ let blockedReason = null;
1110
+ if (!terminal && !currentNode)
1111
+ blockedReason = "TASK_PLAN_EMPTY";
1112
+ else if (!terminal && currentNode?.status !== "READY")
1113
+ blockedReason = "NODE_NOT_READY";
1114
+ else if (!terminal && !roleBinding?.workerRef)
1115
+ blockedReason = "TASK_ROLE_BINDING_REQUIRED";
1116
+ const canDrive = !terminal && blockedReason === null;
1117
+ return {
1118
+ taskId: task.taskId,
1119
+ taskStatus: task.status,
1120
+ taskVersion: task.version,
1121
+ terminal,
1122
+ currentNode: currentNode
1123
+ ? {
1124
+ nodeId: currentNode.nodeId,
1125
+ status: currentNode.status,
1126
+ version: currentNode.version,
1127
+ runNo: currentNode.runNo,
1128
+ requiredAgentPackageRef: currentNode.requiredAgentPackageRef,
1129
+ }
1130
+ : null,
1131
+ roleBinding: roleBinding
1132
+ ? {
1133
+ agentPackageRef: roleBinding.agentPackageRef,
1134
+ roleRef: roleBinding.roleRef,
1135
+ workerRef: roleBinding.workerRef,
1136
+ conversationLocator: roleBinding.conversationLocator,
1137
+ }
1138
+ : null,
1139
+ canDrive,
1140
+ blockedReason,
1141
+ };
1142
+ }),
1143
+ getNodeContext: (raw) => queryWithDocumentRecovery("getNodeContext", raw, (tx, input) => {
1144
+ const task = requireTask(tx, input.taskId);
1145
+ const node = requireNode(tx, input.taskId, input.nodeId);
1146
+ const documents = node.inputDocuments
1147
+ .map((type) => tx.documents.get(task.taskId, type))
1148
+ .filter((item) => item !== undefined)
1149
+ .map((document) => ({
1150
+ documentType: document.documentType,
1151
+ path: document.filePath,
1152
+ contentHash: document.contentHash,
1153
+ sizeBytes: statSync(join(workspaceRoot, document.filePath)).size,
1154
+ }));
1155
+ return {
1156
+ task: {
1157
+ taskId: task.taskId,
1158
+ title: task.title,
1159
+ objective: task.objective,
1160
+ status: task.status,
1161
+ version: task.version,
1162
+ },
1163
+ node: {
1164
+ nodeId: node.nodeId,
1165
+ title: node.title,
1166
+ objective: node.objective,
1167
+ status: node.status,
1168
+ version: node.version,
1169
+ runNo: node.runNo,
1170
+ requiredAgentPackageRef: node.requiredAgentPackageRef,
1171
+ workerRef: node.workerRef,
1172
+ inputDocuments: node.inputDocuments,
1173
+ outputDocuments: node.outputDocuments,
1174
+ },
1175
+ documents,
1176
+ };
1177
+ }),
1178
+ listPendingMessages: (raw) => query("listPendingMessages", raw, (tx, input) => ({
1179
+ messages: tx.messages
1180
+ .listPending()
1181
+ .filter((item) => !input.taskId || item.taskId === input.taskId),
1182
+ })),
1183
+ listTaskEvents: (raw) => query("listTaskEvents", raw, (tx, input) => ({
1184
+ events: tx.events
1185
+ .listByTask(input.taskId)
1186
+ .filter((event) => input.afterEventId === undefined ||
1187
+ (event.eventId ?? 0) > input.afterEventId)
1188
+ .slice(0, input.limit ?? 100),
1189
+ })),
1190
+ getTaskDocument: (raw) => queryWithDocumentRecovery("getTaskDocument", raw, (tx, input) => {
1191
+ const type = ensureType(input.documentType);
1192
+ const document = tx.documents.get(input.taskId, type);
1193
+ if (!document)
1194
+ throw new DomainError("DOCUMENT_NOT_FOUND", "Document was not found.");
1195
+ const content = readFileSync(join(workspaceRoot, document.filePath), "utf8");
1196
+ const task = requireTask(tx, input.taskId);
1197
+ return {
1198
+ taskId: input.taskId,
1199
+ nodeId: document.sourceNodeId,
1200
+ documentType: type,
1201
+ path: document.filePath,
1202
+ contentHash: document.contentHash,
1203
+ sizeBytes: statSync(join(workspaceRoot, document.filePath)).size,
1204
+ taskVersion: task.version,
1205
+ updatedAt: document.updatedAt,
1206
+ content,
1207
+ };
1208
+ }),
1209
+ };
1210
+ const documents = {
1211
+ putTaskDocument: (raw) => {
1212
+ let input;
1213
+ let type;
1214
+ let recoveryPath;
1215
+ try {
1216
+ input = validatePublicInput("putTaskDocument", raw);
1217
+ type = ensureType(input.documentType);
1218
+ recoverPendingDocumentUpdates(input.taskId, type);
1219
+ const replay = store.read((tx) => tx.idempotency.get(input.idempotencyKey));
1220
+ if (replay) {
1221
+ if (replay.operation !== "putTaskDocument" ||
1222
+ replay.requestHash !== hash(input))
1223
+ throw new DomainError("IDEMPOTENCY_CONFLICT", "Idempotency key has a different request fingerprint.");
1224
+ return JSON.parse(replay.responseJson);
1225
+ }
1226
+ const canonicalPath = relativeDocumentPath(input.taskId, type);
1227
+ const canonicalAbsolute = join(workspaceRoot, canonicalPath);
1228
+ const currentMetadata = store.read((tx) => tx.documents.get(input.taskId, type));
1229
+ const previousContent = existsSync(canonicalAbsolute)
1230
+ ? readFileSync(canonicalAbsolute, "utf8")
1231
+ : null;
1232
+ const previousHash = previousContent === null ? null : contentHash(previousContent);
1233
+ if ((currentMetadata?.contentHash ?? null) !== previousHash)
1234
+ throw new DomainError("DOCUMENT_RECOVERY_REQUIRED", "Canonical Markdown and durable document metadata must be reconciled before update.");
1235
+ recoveryPath = documentUpdateRecoveryPath(input.taskId, type, input.idempotencyKey);
1236
+ const recoveryAbsolute = join(workspaceRoot, recoveryPath);
1237
+ rmSync(recoveryAbsolute, { recursive: true, force: true });
1238
+ if (previousContent !== null)
1239
+ writeDocument(`${recoveryPath}/previous.md`, previousContent);
1240
+ writeDocument(`${recoveryPath}/next.md`, input.content);
1241
+ atomicWrite(`${recoveryPath}/state.json`, JSON.stringify({
1242
+ previousHash,
1243
+ requestHash: hash(input),
1244
+ }));
1245
+ }
1246
+ catch (error) {
1247
+ return failure(error);
1248
+ }
1249
+ const result = command("putTaskDocument", raw, (tx, validated, timestamp) => {
1250
+ const task = requireTask(tx, validated.taskId);
1251
+ checkVersion(task.version, validated.expectedTaskVersion, "TASK_VERSION_CONFLICT");
1252
+ if (validated.nodeId !== null)
1253
+ requireNode(tx, task.taskId, validated.nodeId);
1254
+ const validatedType = ensureType(validated.documentType);
1255
+ const path = relativeDocumentPath(task.taskId, validatedType);
1256
+ const staged = `${documentUpdateRecoveryPath(task.taskId, validatedType, validated.idempotencyKey)}/next.md`;
1257
+ // BEGIN IMMEDIATE + optimistic version validation happens before the
1258
+ // canonical Markdown is touched. The durable recovery journal keeps the
1259
+ // previous content until DB metadata and the file converge, so a process
1260
+ // crash at the promote/commit boundary can be repaired deterministically.
1261
+ promoteDocument(staged, path);
1262
+ const updated = {
1263
+ ...task,
1264
+ version: task.version + 1,
1265
+ updatedAt: timestamp,
1266
+ };
1267
+ tx.tasks.update(updated);
1268
+ tx.documents.upsert({
1269
+ taskId: task.taskId,
1270
+ documentType: validatedType,
1271
+ sourceNodeId: validated.nodeId,
1272
+ filePath: path,
1273
+ contentHash: contentHash(validated.content),
1274
+ updatedByRef: validated.actorRef,
1275
+ updatedAt: timestamp,
1276
+ });
1277
+ const readied = recomputeReadiness(tx, updated, timestamp, workspaceRoot);
1278
+ if (readied.status !== updated.status)
1279
+ tx.tasks.update(readied);
1280
+ event(tx, {
1281
+ taskId: task.taskId,
1282
+ nodeId: validated.nodeId,
1283
+ eventType: "TASK_DOCUMENT_PUT",
1284
+ actorRef: validated.actorRef,
1285
+ taskVersion: readied.version,
1286
+ nodeVersion: null,
1287
+ payload: { documentType: validatedType },
1288
+ }, timestamp);
1289
+ return {
1290
+ taskId: task.taskId,
1291
+ nodeId: validated.nodeId,
1292
+ documentType: validatedType,
1293
+ path,
1294
+ contentHash: contentHash(validated.content),
1295
+ sizeBytes: Buffer.byteLength(validated.content),
1296
+ taskVersion: readied.version,
1297
+ updatedAt: timestamp,
1298
+ };
1299
+ });
1300
+ try {
1301
+ recoverPendingDocumentUpdates(input.taskId, type);
1302
+ }
1303
+ catch (error) {
1304
+ return failure(error);
1305
+ }
1306
+ if (!result.ok && result.error.code === "INTERNAL_ERROR")
1307
+ return failure(new DomainError("DOCUMENT_WRITE_FAILED", result.error.message, true));
1308
+ return result;
1309
+ },
1310
+ reconcileDocumentIndex: (input) => {
1311
+ try {
1312
+ for (const type of allowedDocumentTypes)
1313
+ recoverPendingDocumentUpdates(input.taskId, type);
1314
+ if (!store.read((tx) => tx.tasks.get(input.taskId))) {
1315
+ cleanupCreateStage(input.taskId);
1316
+ throw new DomainError("TASK_NOT_FOUND", `Task ${input.taskId} was not found.`);
1317
+ }
1318
+ for (const type of allowedDocumentTypes) {
1319
+ const staged = stageDocumentPath(input.taskId, type);
1320
+ if (existsSync(join(workspaceRoot, staged)))
1321
+ promoteDocument(staged, relativeDocumentPath(input.taskId, type));
1322
+ }
1323
+ cleanupCreateStage(input.taskId);
1324
+ const result = store.transaction((tx) => {
1325
+ requireTask(tx, input.taskId);
1326
+ let reconciled = 0;
1327
+ for (const type of allowedDocumentTypes) {
1328
+ const path = relativeDocumentPath(input.taskId, type);
1329
+ const absolute = join(workspaceRoot, path);
1330
+ if (!existsSync(absolute))
1331
+ continue;
1332
+ const content = readFileSync(absolute, "utf8");
1333
+ const current = tx.documents.get(input.taskId, type);
1334
+ const digest = contentHash(content);
1335
+ if (!current || current.contentHash !== digest) {
1336
+ tx.documents.upsert({
1337
+ taskId: input.taskId,
1338
+ documentType: type,
1339
+ sourceNodeId: current?.sourceNodeId ?? null,
1340
+ filePath: path,
1341
+ contentHash: digest,
1342
+ updatedByRef: input.actorRef,
1343
+ updatedAt: now(),
1344
+ });
1345
+ reconciled++;
1346
+ }
1347
+ }
1348
+ return { reconciled };
1349
+ });
1350
+ return success(result);
1351
+ }
1352
+ catch (error) {
1353
+ return failure(error);
1354
+ }
1355
+ },
1356
+ };
1357
+ return { commands, queries, documents };
1358
+ }