@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.
- package/README.md +5 -0
- package/conformance.json +4 -0
- package/dist/deployment/adapter.d.ts +47 -0
- package/dist/deployment/adapter.js +26 -0
- package/dist/deployment/descriptor.d.ts +46 -0
- package/dist/deployment/descriptor.js +45 -0
- package/dist/src/contracts.d.ts +3 -0
- package/dist/src/contracts.js +187 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/model.d.ts +189 -0
- package/dist/src/model.js +5 -0
- package/dist/src/services.d.ts +175 -0
- package/dist/src/services.js +1358 -0
- package/dist/src/transitions.d.ts +4 -0
- package/dist/src/transitions.js +27 -0
- package/package.json +51 -0
- package/proflow.module.json +56 -0
- package/self-install.mjs +26 -0
|
@@ -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
|
+
}
|