@tomflow/proflow-task-orchestration 0.1.11 → 0.1.13
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/CHANGELOG.md +14 -0
- package/dist/deployment/adapter.d.ts +3 -3
- package/dist/deployment/descriptor.d.ts +1 -1
- package/dist/deployment/descriptor.js +1 -1
- package/dist/src/contracts.js +8 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.js +3 -2
- package/dist/src/phase4-continuation-services.d.ts +127 -0
- package/dist/src/phase4-continuation-services.js +296 -0
- package/dist/src/phase4-contracts.d.ts +5 -0
- package/dist/src/phase4-contracts.js +193 -0
- package/dist/src/phase4-model.d.ts +122 -0
- package/dist/src/phase4-model.js +1 -0
- package/dist/src/phase4-services.d.ts +118 -0
- package/dist/src/phase4-services.js +809 -0
- package/dist/src/product-document-path.d.ts +2 -0
- package/dist/src/product-document-path.js +24 -0
- package/dist/src/services.js +27 -2
- package/package.json +3 -3
- package/proflow.module.json +1 -1
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { productDocumentAbsolute } from "./product-document-path.js";
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
import { validatePublicInput } from "./phase4-contracts.js";
|
|
7
|
+
import { requiredTaskAgentPackageRefs } from "./model.js";
|
|
8
|
+
import { createTaskServices as createLegacyTaskServices, } from "./services.js";
|
|
9
|
+
class Phase4DomainError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
retryable;
|
|
12
|
+
details;
|
|
13
|
+
constructor(code, message = code, retryable = false, details) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.retryable = retryable;
|
|
17
|
+
this.details = details;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const success = (data) => ({
|
|
21
|
+
contract: "task-orchestration",
|
|
22
|
+
contractVersion: "1.0.0",
|
|
23
|
+
ok: true,
|
|
24
|
+
data,
|
|
25
|
+
});
|
|
26
|
+
const failure = (error) => {
|
|
27
|
+
const domain = error instanceof Phase4DomainError ? error : undefined;
|
|
28
|
+
return {
|
|
29
|
+
contract: "task-orchestration",
|
|
30
|
+
contractVersion: "1.0.0",
|
|
31
|
+
ok: false,
|
|
32
|
+
error: {
|
|
33
|
+
code: domain?.code ??
|
|
34
|
+
(error instanceof ZodError ? "INVALID_REQUEST" : "INTERNAL_ERROR"),
|
|
35
|
+
message: domain?.message ??
|
|
36
|
+
(error instanceof Error ? error.message : "unexpected error"),
|
|
37
|
+
retryable: domain?.retryable ?? false,
|
|
38
|
+
correlationId: `corr-${randomUUID()}`,
|
|
39
|
+
...(domain?.details === undefined ? {} : { details: domain.details }),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
function canonical(value) {
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return value.map(canonical);
|
|
46
|
+
if (value !== null && typeof value === "object")
|
|
47
|
+
return Object.fromEntries(Object.entries(value)
|
|
48
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
49
|
+
.map(([key, item]) => [key, canonical(item)]));
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
const stableJson = (value) => JSON.stringify(canonical(value));
|
|
53
|
+
const hash = (value) => `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
|
|
54
|
+
const contentHash = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
55
|
+
const same = (left, right) => stableJson(left) === stableJson(right);
|
|
56
|
+
function subsetOf(candidate, boundary) {
|
|
57
|
+
if (same(candidate, boundary))
|
|
58
|
+
return true;
|
|
59
|
+
if (Array.isArray(candidate) && Array.isArray(boundary))
|
|
60
|
+
return candidate.every((item) => boundary.some((allowed) => same(item, allowed)));
|
|
61
|
+
if (candidate !== null &&
|
|
62
|
+
boundary !== null &&
|
|
63
|
+
typeof candidate === "object" &&
|
|
64
|
+
typeof boundary === "object" &&
|
|
65
|
+
!Array.isArray(candidate) &&
|
|
66
|
+
!Array.isArray(boundary)) {
|
|
67
|
+
const allowed = boundary;
|
|
68
|
+
return Object.entries(candidate).every(([key, value]) => key in allowed && subsetOf(value, allowed[key]));
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
function findCost(value) {
|
|
73
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
74
|
+
return undefined;
|
|
75
|
+
for (const [key, item] of Object.entries(value)) {
|
|
76
|
+
if (key.toLowerCase() === "cost")
|
|
77
|
+
return item;
|
|
78
|
+
const nested = findCost(item);
|
|
79
|
+
if (nested !== undefined)
|
|
80
|
+
return nested;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const requiresZeroCost = (constraints) => {
|
|
85
|
+
const cost = findCost(constraints);
|
|
86
|
+
return cost === 0 || cost === "0";
|
|
87
|
+
};
|
|
88
|
+
function ownerStore(store) {
|
|
89
|
+
const candidate = store;
|
|
90
|
+
if (typeof candidate.phase4Transaction !== "function" ||
|
|
91
|
+
typeof candidate.phase4Read !== "function")
|
|
92
|
+
throw new Phase4DomainError("PHASE4_OWNER_STORE_REQUIRED", "Task Store does not expose Phase4 owner transactions.");
|
|
93
|
+
return candidate;
|
|
94
|
+
}
|
|
95
|
+
function checkVersion(actual, expected, code) {
|
|
96
|
+
if (actual !== expected)
|
|
97
|
+
throw new Phase4DomainError(code, "Version does not match.", true, {
|
|
98
|
+
expected,
|
|
99
|
+
actual,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function requireCampaign(tx, campaignRef) {
|
|
103
|
+
const value = tx.productCampaigns.get(campaignRef);
|
|
104
|
+
if (!value)
|
|
105
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_NOT_FOUND");
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function requireIntent(tx, intentRef) {
|
|
109
|
+
const value = tx.productIntents.get(intentRef);
|
|
110
|
+
if (!value)
|
|
111
|
+
throw new Phase4DomainError("PRODUCT_INTENT_NOT_FOUND");
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function requireAuthorization(tx, authorizationRef) {
|
|
115
|
+
const value = tx.campaignAuthorizations.get(authorizationRef);
|
|
116
|
+
if (!value)
|
|
117
|
+
throw new Phase4DomainError("CAMPAIGN_AUTHORIZATION_NOT_FOUND");
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
function requireTask(tx, taskId) {
|
|
121
|
+
const value = tx.tasks.get(taskId);
|
|
122
|
+
if (!value)
|
|
123
|
+
throw new Phase4DomainError("TASK_NOT_FOUND", `Task ${taskId} was not found.`);
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function assertProductActor(actorRef, discussionRef) {
|
|
127
|
+
if (actorRef !== `product-discussion:${discussionRef}`)
|
|
128
|
+
throw new Phase4DomainError("PRODUCT_DISCUSSION_MISMATCH");
|
|
129
|
+
}
|
|
130
|
+
function assertGrantActor(actorRef) {
|
|
131
|
+
if (!actorRef.startsWith("human:") && !actorRef.startsWith("trusted:"))
|
|
132
|
+
throw new Phase4DomainError("TASK_START_AUTHORITY_INVALID");
|
|
133
|
+
}
|
|
134
|
+
function assertContinuationActor(actorRef) {
|
|
135
|
+
if (actorRef !== "platform:campaign-continuation")
|
|
136
|
+
throw new Phase4DomainError("TASK_START_AUTHORITY_INVALID");
|
|
137
|
+
}
|
|
138
|
+
function readinessBlockedReason(tx, task, workspaceRoot) {
|
|
139
|
+
const requirement = tx.documents.get(task.taskId, "REQUIREMENT");
|
|
140
|
+
if (!requirement)
|
|
141
|
+
return "TASK_REQUIREMENT_REQUIRED";
|
|
142
|
+
const absolute = join(workspaceRoot, requirement.filePath);
|
|
143
|
+
if (!existsSync(absolute))
|
|
144
|
+
return "TASK_REQUIREMENT_REQUIRED";
|
|
145
|
+
if (contentHash(readFileSync(absolute, "utf8")) !== requirement.contentHash)
|
|
146
|
+
return "TASK_DOCUMENT_INDEX_MISMATCH";
|
|
147
|
+
const bindings = tx.roleBindings.listByTask(task.taskId);
|
|
148
|
+
if (bindings.length !== requiredTaskAgentPackageRefs.length)
|
|
149
|
+
return "TASK_ROLE_BINDING_REQUIRED";
|
|
150
|
+
for (const packageRef of requiredTaskAgentPackageRefs) {
|
|
151
|
+
const binding = bindings.find((item) => item.agentPackageRef === packageRef);
|
|
152
|
+
if (!binding)
|
|
153
|
+
return "TASK_ROLE_BINDING_REQUIRED";
|
|
154
|
+
if (binding.workerRef === null || binding.conversationLocator === null)
|
|
155
|
+
return "TASK_ROLE_BINDING_INCOMPLETE";
|
|
156
|
+
}
|
|
157
|
+
if (tx.nodes
|
|
158
|
+
.listByTask(task.taskId)
|
|
159
|
+
.some((node) => !requiredTaskAgentPackageRefs.includes(node.requiredAgentPackageRef)))
|
|
160
|
+
return "TASK_NODE_AGENT_PACKAGE_INVALID";
|
|
161
|
+
if (task.taskGroupId !== null) {
|
|
162
|
+
const group = tx.taskGroups.get(task.taskGroupId);
|
|
163
|
+
if (group?.status !== "ACTIVE")
|
|
164
|
+
return "TASK_GROUP_NOT_ACTIVE";
|
|
165
|
+
const siblings = tx.tasks.list(task.taskGroupId);
|
|
166
|
+
if (siblings.some((item) => (item.sequenceNo ?? 0) < (task.sequenceNo ?? 0) && item.status !== "SUCCEEDED"))
|
|
167
|
+
return "PREDECESSOR_NOT_SUCCEEDED";
|
|
168
|
+
if (siblings.some((item) => item.taskId !== task.taskId &&
|
|
169
|
+
["READY", "ACTIVE", "WAITING", "FAILED", "PAUSED"].includes(item.status)))
|
|
170
|
+
return "TASK_GROUP_BUSY";
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function taskView(tx, task, workspaceRoot) {
|
|
175
|
+
return {
|
|
176
|
+
taskId: task.taskId,
|
|
177
|
+
taskGroupId: task.taskGroupId,
|
|
178
|
+
sequenceNo: task.sequenceNo,
|
|
179
|
+
title: task.title,
|
|
180
|
+
objective: task.objective,
|
|
181
|
+
status: task.status,
|
|
182
|
+
version: task.version,
|
|
183
|
+
planVersion: task.planVersion,
|
|
184
|
+
currentNodeId: task.currentNodeId,
|
|
185
|
+
nodes: tx.nodes.listByTask(task.taskId).map((node) => ({
|
|
186
|
+
nodeId: node.nodeId,
|
|
187
|
+
title: node.title,
|
|
188
|
+
status: node.status,
|
|
189
|
+
runNo: node.runNo,
|
|
190
|
+
requiredAgentPackageRef: node.requiredAgentPackageRef,
|
|
191
|
+
workerRef: node.workerRef,
|
|
192
|
+
version: node.version,
|
|
193
|
+
})),
|
|
194
|
+
roleBindings: tx.roleBindings.listByTask(task.taskId).map((binding) => ({
|
|
195
|
+
agentPackageRef: binding.agentPackageRef,
|
|
196
|
+
roleRef: binding.roleRef,
|
|
197
|
+
workerRef: binding.workerRef,
|
|
198
|
+
conversationLocator: binding.conversationLocator,
|
|
199
|
+
})),
|
|
200
|
+
pendingMessages: tx.messages
|
|
201
|
+
.listPending()
|
|
202
|
+
.filter((message) => message.taskId === task.taskId),
|
|
203
|
+
readiness: {
|
|
204
|
+
ready: task.status === "READY" &&
|
|
205
|
+
readinessBlockedReason(tx, task, workspaceRoot) === null,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function createTaskServices(options) {
|
|
210
|
+
const legacy = createLegacyTaskServices(options);
|
|
211
|
+
const store = ownerStore(options.store);
|
|
212
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
213
|
+
const createId = options.createId ?? ((prefix) => `${prefix}-${randomUUID()}`);
|
|
214
|
+
function command(operation, raw, work) {
|
|
215
|
+
try {
|
|
216
|
+
const input = validatePublicInput(operation, raw);
|
|
217
|
+
return store.phase4Transaction((tx) => {
|
|
218
|
+
const requestHash = hash(input);
|
|
219
|
+
const prior = tx.idempotency.get(input.idempotencyKey);
|
|
220
|
+
if (prior) {
|
|
221
|
+
if (prior.operation !== operation || prior.requestHash !== requestHash)
|
|
222
|
+
throw new Phase4DomainError("IDEMPOTENCY_CONFLICT");
|
|
223
|
+
return JSON.parse(prior.responseJson);
|
|
224
|
+
}
|
|
225
|
+
const timestamp = now();
|
|
226
|
+
const result = success(work(tx, input, timestamp));
|
|
227
|
+
tx.idempotency.insert({
|
|
228
|
+
idempotencyKey: input.idempotencyKey,
|
|
229
|
+
operation,
|
|
230
|
+
requestHash,
|
|
231
|
+
responseJson: JSON.stringify(result),
|
|
232
|
+
createdAt: timestamp,
|
|
233
|
+
});
|
|
234
|
+
return result;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
return failure(error);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function query(operation, raw, work) {
|
|
242
|
+
try {
|
|
243
|
+
const input = validatePublicInput(operation, raw);
|
|
244
|
+
return success(store.phase4Read((tx) => work(tx, input)));
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
return failure(error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const createCampaignAuthorization = (raw) => command("createCampaignAuthorization", raw, (tx, input, timestamp) => {
|
|
251
|
+
assertGrantActor(input.actorRef);
|
|
252
|
+
if (!requiresZeroCost(input.constraints))
|
|
253
|
+
throw new Phase4DomainError("CAMPAIGN_CONSTRAINT_VIOLATION");
|
|
254
|
+
let campaign = tx.productCampaigns.get(input.campaignRef);
|
|
255
|
+
if (!campaign) {
|
|
256
|
+
campaign = {
|
|
257
|
+
campaignRef: input.campaignRef,
|
|
258
|
+
discussionRef: input.discussionRef,
|
|
259
|
+
goalRevision: input.goalRevision,
|
|
260
|
+
goalSummary: input.goalSummary,
|
|
261
|
+
scope: input.scope,
|
|
262
|
+
constraints: input.constraints,
|
|
263
|
+
status: "OPEN",
|
|
264
|
+
version: 1,
|
|
265
|
+
createdByRef: input.actorRef,
|
|
266
|
+
createdAt: timestamp,
|
|
267
|
+
closedAt: null,
|
|
268
|
+
closureSummary: null,
|
|
269
|
+
};
|
|
270
|
+
tx.productCampaigns.insert(campaign);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
if (campaign.status !== "OPEN")
|
|
274
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_CLOSED");
|
|
275
|
+
if (campaign.discussionRef !== input.discussionRef ||
|
|
276
|
+
campaign.goalRevision !== input.goalRevision)
|
|
277
|
+
throw new Phase4DomainError("CAMPAIGN_GOAL_REVISION_MISMATCH");
|
|
278
|
+
if (!subsetOf(input.scope, campaign.scope))
|
|
279
|
+
throw new Phase4DomainError("CAMPAIGN_SCOPE_VIOLATION");
|
|
280
|
+
if (!same(input.constraints, campaign.constraints))
|
|
281
|
+
throw new Phase4DomainError("CAMPAIGN_CONSTRAINT_VIOLATION");
|
|
282
|
+
}
|
|
283
|
+
const authorizationRef = input.authorizationRef ??
|
|
284
|
+
createId(`authorization-${input.campaignRef.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`);
|
|
285
|
+
if (tx.campaignAuthorizations.get(authorizationRef))
|
|
286
|
+
throw new Phase4DomainError("PRODUCT_INTENT_CONFLICT");
|
|
287
|
+
const authorization = {
|
|
288
|
+
authorizationRef,
|
|
289
|
+
campaignRef: campaign.campaignRef,
|
|
290
|
+
discussionRef: campaign.discussionRef,
|
|
291
|
+
goalRevision: campaign.goalRevision,
|
|
292
|
+
goalSummary: campaign.goalSummary,
|
|
293
|
+
scope: input.scope,
|
|
294
|
+
constraints: input.constraints,
|
|
295
|
+
version: 1,
|
|
296
|
+
createdByRef: input.actorRef,
|
|
297
|
+
createdAt: timestamp,
|
|
298
|
+
revokedAt: null,
|
|
299
|
+
revokedByRef: null,
|
|
300
|
+
revokeReason: null,
|
|
301
|
+
};
|
|
302
|
+
tx.campaignAuthorizations.insert(authorization);
|
|
303
|
+
return { campaign, authorization };
|
|
304
|
+
});
|
|
305
|
+
const revokeCampaignAuthorization = (raw) => command("revokeCampaignAuthorization", raw, (tx, input, timestamp) => {
|
|
306
|
+
assertGrantActor(input.actorRef);
|
|
307
|
+
const authorization = requireAuthorization(tx, input.authorizationRef);
|
|
308
|
+
checkVersion(authorization.version, input.expectedAuthorizationVersion, "CAMPAIGN_AUTHORIZATION_VERSION_CONFLICT");
|
|
309
|
+
if (authorization.revokedAt !== null)
|
|
310
|
+
throw new Phase4DomainError("CAMPAIGN_AUTHORIZATION_REVOKED");
|
|
311
|
+
const updated = {
|
|
312
|
+
...authorization,
|
|
313
|
+
version: authorization.version + 1,
|
|
314
|
+
revokedAt: timestamp,
|
|
315
|
+
revokedByRef: input.actorRef,
|
|
316
|
+
revokeReason: input.reason,
|
|
317
|
+
};
|
|
318
|
+
tx.campaignAuthorizations.update(updated);
|
|
319
|
+
return updated;
|
|
320
|
+
});
|
|
321
|
+
const submitProductTaskIntent = (raw) => command("submitProductTaskIntent", raw, (tx, input, timestamp) => {
|
|
322
|
+
assertProductActor(input.actorRef, input.discussionRef);
|
|
323
|
+
if (input.campaignRef !== undefined) {
|
|
324
|
+
const campaign = requireCampaign(tx, input.campaignRef);
|
|
325
|
+
if (campaign.status !== "OPEN")
|
|
326
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_CLOSED");
|
|
327
|
+
checkVersion(campaign.version, input.expectedCampaignVersion, "PRODUCT_CAMPAIGN_VERSION_CONFLICT");
|
|
328
|
+
if (campaign.discussionRef !== input.discussionRef ||
|
|
329
|
+
campaign.goalRevision !== input.goalRevision)
|
|
330
|
+
throw new Phase4DomainError("CAMPAIGN_GOAL_REVISION_MISMATCH");
|
|
331
|
+
if (!subsetOf(input.scope, campaign.scope))
|
|
332
|
+
throw new Phase4DomainError("PRODUCT_INTENT_SCOPE_VIOLATION");
|
|
333
|
+
if (!same(input.constraints, campaign.constraints))
|
|
334
|
+
throw new Phase4DomainError("CAMPAIGN_CONSTRAINT_VIOLATION");
|
|
335
|
+
}
|
|
336
|
+
if (input.productDocuments.length > 0 && input.campaignRef === undefined)
|
|
337
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_NOT_FOUND");
|
|
338
|
+
for (const supplied of input.productDocuments) {
|
|
339
|
+
const document = tx.productDocuments.get(supplied.documentRef);
|
|
340
|
+
if (!document)
|
|
341
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_NOT_FOUND");
|
|
342
|
+
if (document.campaignRef !== input.campaignRef ||
|
|
343
|
+
(document.intentRef !== null && document.intentRef !== input.intentRef) ||
|
|
344
|
+
document.documentType !== supplied.documentType || document.filePath !== supplied.filePath ||
|
|
345
|
+
document.contentHash !== supplied.contentHash || document.revision !== supplied.revision)
|
|
346
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_SCOPE_MISMATCH");
|
|
347
|
+
let absolute;
|
|
348
|
+
try {
|
|
349
|
+
absolute = productDocumentAbsolute(options.workspaceRoot, document.filePath);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_PATH_INVALID");
|
|
353
|
+
}
|
|
354
|
+
if (!existsSync(absolute) || contentHash(readFileSync(absolute, "utf8")) !== document.contentHash)
|
|
355
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_INDEX_MISMATCH");
|
|
356
|
+
}
|
|
357
|
+
const existing = tx.productIntents.get(input.intentRef);
|
|
358
|
+
if (existing) {
|
|
359
|
+
const bindings = existing.materializedTaskId
|
|
360
|
+
? tx.roleBindings.listByTask(existing.materializedTaskId)
|
|
361
|
+
: [];
|
|
362
|
+
const compatible = existing.materializedTaskId !== null &&
|
|
363
|
+
existing.campaignRef === (input.campaignRef ?? null) &&
|
|
364
|
+
existing.discussionRef === input.discussionRef &&
|
|
365
|
+
existing.goalRevision === input.goalRevision &&
|
|
366
|
+
existing.intentRevision === input.intentRevision &&
|
|
367
|
+
existing.predecessorTaskId === (input.predecessorTaskId ?? null) &&
|
|
368
|
+
existing.title === input.title &&
|
|
369
|
+
existing.objective === input.objective &&
|
|
370
|
+
same(existing.scope, input.scope) &&
|
|
371
|
+
same(existing.constraints, input.constraints) &&
|
|
372
|
+
same(existing.plan, input.plan) &&
|
|
373
|
+
bindings.length === input.roleBindings.length &&
|
|
374
|
+
input.roleBindings.every((binding) => bindings.some((current) => current.agentPackageRef === binding.agentPackageRef &&
|
|
375
|
+
current.roleRef === binding.roleRef));
|
|
376
|
+
if (!compatible || !existing.materializedTaskId)
|
|
377
|
+
throw new Phase4DomainError("PRODUCT_INTENT_CONFLICT");
|
|
378
|
+
const existingTask = requireTask(tx, existing.materializedTaskId);
|
|
379
|
+
return {
|
|
380
|
+
intentRef: existing.intentRef,
|
|
381
|
+
campaignRef: existing.campaignRef,
|
|
382
|
+
taskId: existingTask.taskId,
|
|
383
|
+
status: existingTask.status,
|
|
384
|
+
version: existingTask.version,
|
|
385
|
+
roleBindings: bindings.map((binding) => ({
|
|
386
|
+
agentPackageRef: binding.agentPackageRef,
|
|
387
|
+
roleRef: binding.roleRef,
|
|
388
|
+
workerRef: null,
|
|
389
|
+
conversationLocator: null,
|
|
390
|
+
})),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (input.predecessorTaskId !== undefined) {
|
|
394
|
+
if (input.campaignRef === undefined)
|
|
395
|
+
throw new Phase4DomainError("PRODUCT_INTENT_PREDECESSOR_INVALID");
|
|
396
|
+
const predecessor = requireTask(tx, input.predecessorTaskId);
|
|
397
|
+
const predecessorLink = tx.taskProductLinks.get(predecessor.taskId);
|
|
398
|
+
if (!["SUCCEEDED", "TERMINATED"].includes(predecessor.status) ||
|
|
399
|
+
predecessorLink?.campaignRef !== input.campaignRef ||
|
|
400
|
+
!predecessorLink.sourceProductIntentRef)
|
|
401
|
+
throw new Phase4DomainError("PRODUCT_INTENT_PREDECESSOR_INVALID");
|
|
402
|
+
const review = tx.productGapReviews.getByIntent(predecessorLink.sourceProductIntentRef);
|
|
403
|
+
if (!review || review.taskId !== predecessor.taskId)
|
|
404
|
+
throw new Phase4DomainError("PRODUCT_GAP_REVIEW_REQUIRED");
|
|
405
|
+
if (tx.productIntents
|
|
406
|
+
.listByCampaign(input.campaignRef)
|
|
407
|
+
.some((candidate) => candidate.intentRef !== input.intentRef &&
|
|
408
|
+
candidate.predecessorTaskId === predecessor.taskId))
|
|
409
|
+
throw new Phase4DomainError("PRODUCT_INTENT_CONFLICT");
|
|
410
|
+
}
|
|
411
|
+
const taskId = `task-${createHash("sha256")
|
|
412
|
+
.update(`${input.intentRef}\0${input.intentRevision}`)
|
|
413
|
+
.digest("hex")
|
|
414
|
+
.slice(0, 24)}`;
|
|
415
|
+
if (tx.tasks.get(taskId))
|
|
416
|
+
throw new Phase4DomainError("PRODUCT_INTENT_CONFLICT");
|
|
417
|
+
const materializedTask = {
|
|
418
|
+
taskId,
|
|
419
|
+
taskGroupId: null,
|
|
420
|
+
sequenceNo: null,
|
|
421
|
+
title: input.title,
|
|
422
|
+
objective: input.objective,
|
|
423
|
+
status: "PENDING",
|
|
424
|
+
version: 1,
|
|
425
|
+
planVersion: 1,
|
|
426
|
+
currentNodeId: null,
|
|
427
|
+
createdByRef: input.actorRef,
|
|
428
|
+
createdAt: timestamp,
|
|
429
|
+
startedAt: null,
|
|
430
|
+
completedAt: null,
|
|
431
|
+
updatedAt: timestamp,
|
|
432
|
+
};
|
|
433
|
+
tx.tasks.insert(materializedTask);
|
|
434
|
+
input.plan.nodes.forEach((node, index) => tx.nodes.insert({
|
|
435
|
+
nodeId: node.nodeId,
|
|
436
|
+
taskId,
|
|
437
|
+
sequenceNo: index + 1,
|
|
438
|
+
title: node.title,
|
|
439
|
+
objective: node.objective,
|
|
440
|
+
status: "PENDING",
|
|
441
|
+
version: 1,
|
|
442
|
+
runNo: 1,
|
|
443
|
+
requiredAgentPackageRef: node.requiredAgentPackageRef,
|
|
444
|
+
workerRef: null,
|
|
445
|
+
inputDocuments: node.inputDocuments,
|
|
446
|
+
outputDocuments: node.outputDocuments,
|
|
447
|
+
resultSummary: null,
|
|
448
|
+
errorCode: null,
|
|
449
|
+
errorMessage: null,
|
|
450
|
+
errorRetryable: null,
|
|
451
|
+
startedAt: null,
|
|
452
|
+
completedAt: null,
|
|
453
|
+
updatedAt: timestamp,
|
|
454
|
+
}));
|
|
455
|
+
for (const binding of input.roleBindings)
|
|
456
|
+
tx.roleBindings.upsert({
|
|
457
|
+
taskId,
|
|
458
|
+
agentPackageRef: binding.agentPackageRef,
|
|
459
|
+
roleRef: binding.roleRef,
|
|
460
|
+
workerRef: null,
|
|
461
|
+
conversationLocator: null,
|
|
462
|
+
version: 1,
|
|
463
|
+
createdAt: timestamp,
|
|
464
|
+
updatedAt: timestamp,
|
|
465
|
+
});
|
|
466
|
+
const intent = {
|
|
467
|
+
intentRef: input.intentRef,
|
|
468
|
+
campaignRef: input.campaignRef ?? null,
|
|
469
|
+
discussionRef: input.discussionRef,
|
|
470
|
+
goalRevision: input.goalRevision,
|
|
471
|
+
intentRevision: input.intentRevision,
|
|
472
|
+
predecessorTaskId: input.predecessorTaskId ?? null,
|
|
473
|
+
title: input.title,
|
|
474
|
+
objective: input.objective,
|
|
475
|
+
scope: input.scope,
|
|
476
|
+
constraints: input.constraints,
|
|
477
|
+
plan: input.plan,
|
|
478
|
+
materializedTaskId: taskId,
|
|
479
|
+
version: 1,
|
|
480
|
+
createdByRef: input.actorRef,
|
|
481
|
+
createdAt: timestamp,
|
|
482
|
+
materializedAt: timestamp,
|
|
483
|
+
};
|
|
484
|
+
tx.productIntents.insert(intent);
|
|
485
|
+
tx.taskProductLinks.set({
|
|
486
|
+
taskId,
|
|
487
|
+
sourceProductIntentRef: intent.intentRef,
|
|
488
|
+
campaignRef: intent.campaignRef,
|
|
489
|
+
});
|
|
490
|
+
for (const document of input.productDocuments)
|
|
491
|
+
tx.productDocuments.bindIntent({
|
|
492
|
+
documentRef: document.documentRef,
|
|
493
|
+
intentRef: input.intentRef,
|
|
494
|
+
expectedRevision: document.revision,
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
intentRef: intent.intentRef,
|
|
498
|
+
campaignRef: intent.campaignRef,
|
|
499
|
+
taskId,
|
|
500
|
+
status: materializedTask.status,
|
|
501
|
+
version: materializedTask.version,
|
|
502
|
+
roleBindings: input.roleBindings.map((binding) => ({
|
|
503
|
+
...binding,
|
|
504
|
+
workerRef: null,
|
|
505
|
+
conversationLocator: null,
|
|
506
|
+
})),
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
const transferProductIntentToTask = (raw) => command("transferProductIntentToTask", raw, (tx, input, timestamp) => {
|
|
510
|
+
assertContinuationActor(input.actorRef);
|
|
511
|
+
const intent = requireIntent(tx, input.intentRef);
|
|
512
|
+
checkVersion(intent.version, input.expectedIntentVersion, "PRODUCT_INTENT_REVISION_CONFLICT");
|
|
513
|
+
const current = requireTask(tx, input.taskId);
|
|
514
|
+
checkVersion(current.version, input.expectedTaskVersion, "TASK_VERSION_CONFLICT");
|
|
515
|
+
if (current.status !== "PENDING")
|
|
516
|
+
throw new Phase4DomainError("TASK_INVALID_STATE");
|
|
517
|
+
const link = tx.taskProductLinks.get(current.taskId);
|
|
518
|
+
if (intent.materializedTaskId !== current.taskId ||
|
|
519
|
+
link?.sourceProductIntentRef !== intent.intentRef ||
|
|
520
|
+
link.campaignRef !== intent.campaignRef)
|
|
521
|
+
throw new Phase4DomainError("PRODUCT_INTENT_TASK_MISMATCH");
|
|
522
|
+
const bindings = tx.roleBindings.listByTask(current.taskId);
|
|
523
|
+
if (bindings.length !== requiredTaskAgentPackageRefs.length ||
|
|
524
|
+
bindings.some((binding) => binding.workerRef === null || binding.conversationLocator === null))
|
|
525
|
+
throw new Phase4DomainError("TASK_ROLE_BINDING_INCOMPLETE");
|
|
526
|
+
const requirements = tx.productDocuments
|
|
527
|
+
.listByIntent(intent.intentRef)
|
|
528
|
+
.filter((document) => document.documentType === "REQUIREMENT");
|
|
529
|
+
if (requirements.length !== 1)
|
|
530
|
+
throw new Phase4DomainError(requirements.length === 0
|
|
531
|
+
? "PRODUCT_DOCUMENT_NOT_FOUND"
|
|
532
|
+
: "PRODUCT_DOCUMENT_AMBIGUOUS");
|
|
533
|
+
const requirement = requirements[0];
|
|
534
|
+
if (!requirement)
|
|
535
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_NOT_FOUND");
|
|
536
|
+
const absolute = join(options.workspaceRoot, requirement.filePath);
|
|
537
|
+
if (!existsSync(absolute) ||
|
|
538
|
+
contentHash(readFileSync(absolute, "utf8")) !== requirement.contentHash)
|
|
539
|
+
throw new Phase4DomainError("PRODUCT_DOCUMENT_INDEX_MISMATCH");
|
|
540
|
+
tx.documents.upsert({
|
|
541
|
+
taskId: current.taskId,
|
|
542
|
+
documentType: "REQUIREMENT",
|
|
543
|
+
sourceNodeId: null,
|
|
544
|
+
filePath: requirement.filePath,
|
|
545
|
+
contentHash: requirement.contentHash,
|
|
546
|
+
updatedByRef: input.actorRef,
|
|
547
|
+
updatedAt: timestamp,
|
|
548
|
+
});
|
|
549
|
+
const advancedIntent = {
|
|
550
|
+
...intent,
|
|
551
|
+
version: intent.version + 1,
|
|
552
|
+
};
|
|
553
|
+
tx.productIntents.update(advancedIntent);
|
|
554
|
+
const blockedReason = readinessBlockedReason(tx, current, options.workspaceRoot);
|
|
555
|
+
if (blockedReason !== null)
|
|
556
|
+
throw new Phase4DomainError("TASK_BLOCKED", blockedReason);
|
|
557
|
+
const updated = {
|
|
558
|
+
...current,
|
|
559
|
+
status: "READY",
|
|
560
|
+
version: current.version + 1,
|
|
561
|
+
updatedAt: timestamp,
|
|
562
|
+
};
|
|
563
|
+
tx.tasks.update(updated);
|
|
564
|
+
tx.events.insert({
|
|
565
|
+
taskId: updated.taskId,
|
|
566
|
+
nodeId: null,
|
|
567
|
+
eventType: "PRODUCT_REQUIREMENT_TRANSFERRED",
|
|
568
|
+
actorRef: input.actorRef,
|
|
569
|
+
taskVersion: updated.version,
|
|
570
|
+
nodeVersion: null,
|
|
571
|
+
payload: {
|
|
572
|
+
intentRef: intent.intentRef,
|
|
573
|
+
productDocumentRef: requirement.documentRef,
|
|
574
|
+
},
|
|
575
|
+
createdAt: timestamp,
|
|
576
|
+
});
|
|
577
|
+
return taskView(tx, updated, options.workspaceRoot);
|
|
578
|
+
});
|
|
579
|
+
const recordProductGapReview = (raw) => command("recordProductGapReview", raw, (tx, input, timestamp) => {
|
|
580
|
+
assertProductActor(input.actorRef, input.discussionRef);
|
|
581
|
+
const campaign = requireCampaign(tx, input.campaignRef);
|
|
582
|
+
if (campaign.status !== "OPEN")
|
|
583
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_CLOSED");
|
|
584
|
+
if (campaign.discussionRef !== input.discussionRef)
|
|
585
|
+
throw new Phase4DomainError("PRODUCT_DISCUSSION_MISMATCH");
|
|
586
|
+
const intent = requireIntent(tx, input.intentRef);
|
|
587
|
+
if (intent.goalRevision !== campaign.goalRevision)
|
|
588
|
+
throw new Phase4DomainError("CAMPAIGN_GOAL_REVISION_MISMATCH");
|
|
589
|
+
checkVersion(intent.version, input.expectedIntentVersion, "PRODUCT_INTENT_REVISION_CONFLICT");
|
|
590
|
+
const current = requireTask(tx, input.taskId);
|
|
591
|
+
checkVersion(current.version, input.terminalTaskVersion, "TASK_VERSION_CONFLICT");
|
|
592
|
+
const link = tx.taskProductLinks.get(current.taskId);
|
|
593
|
+
if (!["SUCCEEDED", "TERMINATED"].includes(current.status) ||
|
|
594
|
+
intent.campaignRef !== campaign.campaignRef ||
|
|
595
|
+
intent.discussionRef !== input.discussionRef ||
|
|
596
|
+
intent.materializedTaskId !== current.taskId ||
|
|
597
|
+
link?.sourceProductIntentRef !== intent.intentRef ||
|
|
598
|
+
link.campaignRef !== campaign.campaignRef)
|
|
599
|
+
throw new Phase4DomainError("PRODUCT_REVIEW_TASK_MISMATCH");
|
|
600
|
+
if (tx.productGapReviews.getByIntent(intent.intentRef))
|
|
601
|
+
throw new Phase4DomainError("PRODUCT_GAP_ALREADY_REVIEWED");
|
|
602
|
+
const priorSameGap = tx.productGapReviews
|
|
603
|
+
.listByCampaign(campaign.campaignRef)
|
|
604
|
+
.filter((review) => review.gapIdentity === input.gapIdentity);
|
|
605
|
+
if (priorSameGap.length > 0) {
|
|
606
|
+
const knownEvidence = new Set(priorSameGap.flatMap((review) => review.progressEvidenceRefs));
|
|
607
|
+
if (!input.progressEvidenceRefs.some((ref) => !knownEvidence.has(ref)))
|
|
608
|
+
throw new Phase4DomainError("PRODUCT_GAP_NO_PROGRESS");
|
|
609
|
+
}
|
|
610
|
+
const review = {
|
|
611
|
+
reviewRef: `product-review:${campaign.campaignRef}:${current.taskId}:${current.version}`,
|
|
612
|
+
campaignRef: campaign.campaignRef,
|
|
613
|
+
intentRef: intent.intentRef,
|
|
614
|
+
taskId: current.taskId,
|
|
615
|
+
discussionRef: input.discussionRef,
|
|
616
|
+
gapIdentity: input.gapIdentity,
|
|
617
|
+
progressEvidenceRefs: [...new Set(input.progressEvidenceRefs)],
|
|
618
|
+
terminalTaskVersion: current.version,
|
|
619
|
+
createdByRef: input.actorRef,
|
|
620
|
+
createdAt: timestamp,
|
|
621
|
+
};
|
|
622
|
+
tx.productGapReviews.insert(review);
|
|
623
|
+
tx.productIntents.update({
|
|
624
|
+
...intent,
|
|
625
|
+
version: intent.version + 1,
|
|
626
|
+
});
|
|
627
|
+
return review;
|
|
628
|
+
});
|
|
629
|
+
const recordProductGoalSatisfied = (raw) => command("recordProductGoalSatisfied", raw, (tx, input, timestamp) => {
|
|
630
|
+
assertProductActor(input.actorRef, input.discussionRef);
|
|
631
|
+
const campaign = requireCampaign(tx, input.campaignRef);
|
|
632
|
+
checkVersion(campaign.version, input.expectedCampaignVersion, "PRODUCT_CAMPAIGN_VERSION_CONFLICT");
|
|
633
|
+
if (campaign.status !== "OPEN")
|
|
634
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_CLOSED");
|
|
635
|
+
if (campaign.discussionRef !== input.discussionRef ||
|
|
636
|
+
campaign.goalRevision !== input.goalRevision)
|
|
637
|
+
throw new Phase4DomainError("CAMPAIGN_GOAL_REVISION_MISMATCH");
|
|
638
|
+
const current = requireTask(tx, input.taskId);
|
|
639
|
+
checkVersion(current.version, input.terminalTaskVersion, "TASK_VERSION_CONFLICT");
|
|
640
|
+
const link = tx.taskProductLinks.get(current.taskId);
|
|
641
|
+
if (!["SUCCEEDED", "TERMINATED"].includes(current.status) ||
|
|
642
|
+
link?.campaignRef !== campaign.campaignRef ||
|
|
643
|
+
!link.sourceProductIntentRef)
|
|
644
|
+
throw new Phase4DomainError("PRODUCT_REVIEW_TASK_MISMATCH");
|
|
645
|
+
const intent = requireIntent(tx, link.sourceProductIntentRef);
|
|
646
|
+
if (intent.discussionRef !== input.discussionRef ||
|
|
647
|
+
intent.goalRevision !== campaign.goalRevision ||
|
|
648
|
+
intent.materializedTaskId !== current.taskId)
|
|
649
|
+
throw new Phase4DomainError("PRODUCT_REVIEW_TASK_MISMATCH");
|
|
650
|
+
if (tx.productIntents
|
|
651
|
+
.listByCampaign(campaign.campaignRef)
|
|
652
|
+
.some((candidate) => candidate.predecessorTaskId === current.taskId))
|
|
653
|
+
throw new Phase4DomainError("PRODUCT_REVIEW_ALREADY_CONTINUED");
|
|
654
|
+
// A terminal Task is evidence for review, not proof that all Campaign
|
|
655
|
+
// work has stopped. Closure must not strand another durable Task.
|
|
656
|
+
for (const candidate of tx.productIntents.listByCampaign(campaign.campaignRef)) {
|
|
657
|
+
if (!candidate.materializedTaskId ||
|
|
658
|
+
!["SUCCEEDED", "TERMINATED"].includes(requireTask(tx, candidate.materializedTaskId).status))
|
|
659
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_TASKS_NONTERMINAL");
|
|
660
|
+
}
|
|
661
|
+
const updated = {
|
|
662
|
+
...campaign,
|
|
663
|
+
status: "CLOSED",
|
|
664
|
+
version: campaign.version + 1,
|
|
665
|
+
closedAt: timestamp,
|
|
666
|
+
closureSummary: input.closureSummary,
|
|
667
|
+
};
|
|
668
|
+
tx.productCampaigns.update(updated);
|
|
669
|
+
return updated;
|
|
670
|
+
});
|
|
671
|
+
const startTask = (raw) => {
|
|
672
|
+
let input;
|
|
673
|
+
try {
|
|
674
|
+
input = validatePublicInput("startTask", raw);
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
return failure(error);
|
|
678
|
+
}
|
|
679
|
+
const authority = input.startAuthority;
|
|
680
|
+
if (authority === undefined || authority.type === "HUMAN_CONFIRMATION") {
|
|
681
|
+
const { startAuthority: _ignored, ...legacyInput } = input;
|
|
682
|
+
return legacy.commands.startTask(legacyInput);
|
|
683
|
+
}
|
|
684
|
+
const campaignAuthority = authority;
|
|
685
|
+
return command("startTask", input, (tx, validated, timestamp) => {
|
|
686
|
+
assertContinuationActor(validated.actorRef);
|
|
687
|
+
const current = requireTask(tx, validated.taskId);
|
|
688
|
+
checkVersion(current.version, validated.expectedTaskVersion, "TASK_VERSION_CONFLICT");
|
|
689
|
+
if (current.status !== "READY")
|
|
690
|
+
throw new Phase4DomainError("TASK_INVALID_STATE");
|
|
691
|
+
const blockedReason = readinessBlockedReason(tx, current, options.workspaceRoot);
|
|
692
|
+
if (blockedReason !== null)
|
|
693
|
+
throw new Phase4DomainError("TASK_BLOCKED", blockedReason);
|
|
694
|
+
const link = tx.taskProductLinks.get(current.taskId);
|
|
695
|
+
if (!link?.campaignRef || !link.sourceProductIntentRef)
|
|
696
|
+
throw new Phase4DomainError("TASK_START_AUTHORITY_INVALID");
|
|
697
|
+
const campaign = requireCampaign(tx, link.campaignRef);
|
|
698
|
+
checkVersion(campaign.version, campaignAuthority.expectedCampaignVersion, "PRODUCT_CAMPAIGN_VERSION_CONFLICT");
|
|
699
|
+
if (campaign.status !== "OPEN")
|
|
700
|
+
throw new Phase4DomainError("PRODUCT_CAMPAIGN_CLOSED");
|
|
701
|
+
const authorization = requireAuthorization(tx, campaignAuthority.authorizationRef);
|
|
702
|
+
checkVersion(authorization.version, campaignAuthority.expectedAuthorizationVersion, "CAMPAIGN_AUTHORIZATION_VERSION_CONFLICT");
|
|
703
|
+
if (authorization.revokedAt !== null)
|
|
704
|
+
throw new Phase4DomainError("CAMPAIGN_AUTHORIZATION_REVOKED");
|
|
705
|
+
if (authorization.campaignRef !== campaign.campaignRef)
|
|
706
|
+
throw new Phase4DomainError("TASK_START_AUTHORITY_INVALID");
|
|
707
|
+
const intent = tx.productIntents.get(link.sourceProductIntentRef);
|
|
708
|
+
if (!intent || intent.materializedTaskId !== current.taskId)
|
|
709
|
+
throw new Phase4DomainError("TASK_START_AUTHORITY_INVALID");
|
|
710
|
+
if (intent.goalRevision !== campaign.goalRevision ||
|
|
711
|
+
authorization.goalRevision !== campaign.goalRevision)
|
|
712
|
+
throw new Phase4DomainError("CAMPAIGN_GOAL_REVISION_MISMATCH");
|
|
713
|
+
if (!subsetOf(intent.scope, campaign.scope) ||
|
|
714
|
+
!subsetOf(intent.scope, authorization.scope))
|
|
715
|
+
throw new Phase4DomainError("CAMPAIGN_SCOPE_VIOLATION");
|
|
716
|
+
if (!same(intent.constraints, campaign.constraints) ||
|
|
717
|
+
!same(intent.constraints, authorization.constraints) ||
|
|
718
|
+
!requiresZeroCost(intent.constraints))
|
|
719
|
+
throw new Phase4DomainError("CAMPAIGN_CONSTRAINT_VIOLATION");
|
|
720
|
+
const first = tx.nodes.listByTask(current.taskId)[0];
|
|
721
|
+
if (!first)
|
|
722
|
+
throw new Phase4DomainError("TASK_PLAN_EMPTY");
|
|
723
|
+
tx.nodes.update({
|
|
724
|
+
...first,
|
|
725
|
+
status: "READY",
|
|
726
|
+
version: first.version + 1,
|
|
727
|
+
updatedAt: timestamp,
|
|
728
|
+
});
|
|
729
|
+
const updated = {
|
|
730
|
+
...current,
|
|
731
|
+
status: "ACTIVE",
|
|
732
|
+
version: current.version + 1,
|
|
733
|
+
currentNodeId: first.nodeId,
|
|
734
|
+
startedAt: timestamp,
|
|
735
|
+
updatedAt: timestamp,
|
|
736
|
+
};
|
|
737
|
+
tx.tasks.update(updated);
|
|
738
|
+
tx.events.insert({
|
|
739
|
+
taskId: current.taskId,
|
|
740
|
+
nodeId: null,
|
|
741
|
+
eventType: "TASK_STARTED",
|
|
742
|
+
actorRef: validated.actorRef,
|
|
743
|
+
taskVersion: updated.version,
|
|
744
|
+
nodeVersion: null,
|
|
745
|
+
payload: {
|
|
746
|
+
startAuthority: "CAMPAIGN_AUTHORIZATION",
|
|
747
|
+
authorizationRef: authorization.authorizationRef,
|
|
748
|
+
},
|
|
749
|
+
createdAt: timestamp,
|
|
750
|
+
});
|
|
751
|
+
return taskView(tx, updated, options.workspaceRoot);
|
|
752
|
+
});
|
|
753
|
+
};
|
|
754
|
+
const commands = {
|
|
755
|
+
...legacy.commands,
|
|
756
|
+
createCampaignAuthorization,
|
|
757
|
+
revokeCampaignAuthorization,
|
|
758
|
+
submitProductTaskIntent,
|
|
759
|
+
transferProductIntentToTask,
|
|
760
|
+
recordProductGapReview,
|
|
761
|
+
recordProductGoalSatisfied,
|
|
762
|
+
startTask,
|
|
763
|
+
};
|
|
764
|
+
const queries = {
|
|
765
|
+
...legacy.queries,
|
|
766
|
+
getProductCampaign: (raw) => query("getProductCampaign", raw, (tx, input) => requireCampaign(tx, input.campaignRef)),
|
|
767
|
+
getCampaignAuthorization: (raw) => query("getCampaignAuthorization", raw, (tx, input) => requireAuthorization(tx, input.authorizationRef)),
|
|
768
|
+
getProductContinuationProjection: (raw) => query("getProductContinuationProjection", raw, (tx, input) => {
|
|
769
|
+
const campaign = requireCampaign(tx, input.campaignRef);
|
|
770
|
+
const intents = tx.productIntents.listByCampaign(campaign.campaignRef);
|
|
771
|
+
const intent = (input.taskId
|
|
772
|
+
? intents.find((candidate) => candidate.materializedTaskId === input.taskId)
|
|
773
|
+
: intents.at(-1)) ?? null;
|
|
774
|
+
const current = intent?.materializedTaskId
|
|
775
|
+
? tx.tasks.get(intent.materializedTaskId) ?? null
|
|
776
|
+
: null;
|
|
777
|
+
let nextAction = "NONE";
|
|
778
|
+
if (campaign.status === "OPEN" && current) {
|
|
779
|
+
if (["SUCCEEDED", "TERMINATED"].includes(current.status)) {
|
|
780
|
+
const continued = intents.some((candidate) => candidate.predecessorTaskId === current.taskId);
|
|
781
|
+
// Catch-up scans include historical Tasks. A successor already owns
|
|
782
|
+
// continuation; re-reviewing its predecessor rolls discussion context back.
|
|
783
|
+
if (!continued)
|
|
784
|
+
nextAction = "PRODUCT_REVIEW_REQUIRED";
|
|
785
|
+
}
|
|
786
|
+
else if (current.status === "PENDING") {
|
|
787
|
+
const bindings = tx.roleBindings.listByTask(current.taskId);
|
|
788
|
+
const complete = bindings.length === requiredTaskAgentPackageRefs.length &&
|
|
789
|
+
bindings.every((binding) => binding.workerRef !== null && binding.conversationLocator !== null);
|
|
790
|
+
if (!complete)
|
|
791
|
+
nextAction = "PROVISION";
|
|
792
|
+
else if (!tx.documents.get(current.taskId, "REQUIREMENT"))
|
|
793
|
+
nextAction = "TRANSFER_REQUIREMENT";
|
|
794
|
+
}
|
|
795
|
+
if (current.status === "READY")
|
|
796
|
+
nextAction = "CONSIDER_START";
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
campaign,
|
|
800
|
+
intent,
|
|
801
|
+
task: current
|
|
802
|
+
? { taskId: current.taskId, status: current.status, version: current.version }
|
|
803
|
+
: null,
|
|
804
|
+
nextAction,
|
|
805
|
+
};
|
|
806
|
+
}),
|
|
807
|
+
};
|
|
808
|
+
return { commands, queries, documents: legacy.documents };
|
|
809
|
+
}
|