task-manager-logic 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-manager-logic",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "Runtime-first TaskSpace transition processors and backend-neutral in-memory owner",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  ".": "./src/index.ts"
10
10
  },
11
11
  "dependencies": {
12
- "task-manager-contract": "0.1.2"
12
+ "task-manager-contract": "0.1.3"
13
13
  },
14
14
  "files": [
15
15
  "src"
@@ -0,0 +1,88 @@
1
+ import type { TaskId, TaskSpaceDefinition } from 'task-manager-contract';
2
+
3
+ export interface TaskCompletionGraph {
4
+ readonly predecessorIdsByTask: ReadonlyMap<TaskId, readonly TaskId[]>;
5
+ readonly authoritySuccessorIdsByTask: ReadonlyMap<TaskId, readonly TaskId[]>;
6
+ }
7
+
8
+ /**
9
+ * Builds the one completion graph used by admission and runtime readiness.
10
+ * Authority edges additionally include child -> parent containment because a
11
+ * group cannot complete before its descendants.
12
+ */
13
+ export function buildTaskCompletionGraph(definition: TaskSpaceDefinition): TaskCompletionGraph {
14
+ const taskById = new Map(definition.tasks.map((task) => [task.taskId, task]));
15
+ const childrenByParent = new Map<TaskId, { readonly taskId: TaskId; readonly order: number }[]>();
16
+ for (const relation of definition.relations) {
17
+ if (relation.kind !== 'parent-child' || relation.parentTaskId === null) continue;
18
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
19
+ children.push({ taskId: relation.childTaskId, order: relation.order });
20
+ childrenByParent.set(relation.parentTaskId, children);
21
+ }
22
+ for (const children of childrenByParent.values()) {
23
+ children.sort((left, right) => left.order - right.order || left.taskId.localeCompare(right.taskId));
24
+ }
25
+
26
+ const descendantsByTask = new Map<TaskId, readonly TaskId[]>();
27
+ const descendants = (taskId: TaskId): readonly TaskId[] => {
28
+ const cached = descendantsByTask.get(taskId);
29
+ if (cached) return cached;
30
+ const result = (childrenByParent.get(taskId) ?? [])
31
+ .flatMap((child) => [child.taskId, ...descendants(child.taskId)]);
32
+ descendantsByTask.set(taskId, result);
33
+ return result;
34
+ };
35
+
36
+ const runtimeSuccessors = new Map<TaskId, Set<TaskId>>();
37
+ const addRuntimeEdge = (predecessor: TaskId, successor: TaskId): void => {
38
+ const successors = runtimeSuccessors.get(predecessor) ?? new Set<TaskId>();
39
+ successors.add(successor);
40
+ runtimeSuccessors.set(predecessor, successors);
41
+ };
42
+ for (const relation of definition.relations) {
43
+ if (relation.kind !== 'dependency') continue;
44
+ addRuntimeEdge(relation.predecessorTaskId, relation.successorTaskId);
45
+ if (taskById.get(relation.successorTaskId)?.kind === 'task-group') {
46
+ for (const descendant of descendants(relation.successorTaskId)) {
47
+ addRuntimeEdge(relation.predecessorTaskId, descendant);
48
+ }
49
+ }
50
+ }
51
+ for (const group of definition.tasks) {
52
+ if (group.kind !== 'task-group' || group.mode !== 'sequential') continue;
53
+ const children = childrenByParent.get(group.taskId) ?? [];
54
+ for (let index = 1; index < children.length; index += 1) {
55
+ const predecessor = children[index - 1]!.taskId;
56
+ const successor = children[index]!.taskId;
57
+ addRuntimeEdge(predecessor, successor);
58
+ for (const descendant of descendants(successor)) addRuntimeEdge(predecessor, descendant);
59
+ }
60
+ }
61
+
62
+ const predecessorSets = new Map<TaskId, Set<TaskId>>();
63
+ for (const [predecessor, successors] of runtimeSuccessors) {
64
+ for (const successor of successors) {
65
+ const predecessors = predecessorSets.get(successor) ?? new Set<TaskId>();
66
+ predecessors.add(predecessor);
67
+ predecessorSets.set(successor, predecessors);
68
+ }
69
+ }
70
+ const authoritySuccessors = new Map<TaskId, Set<TaskId>>(
71
+ [...runtimeSuccessors].map(([taskId, successors]) => [taskId, new Set(successors)]),
72
+ );
73
+ for (const [parent, children] of childrenByParent) {
74
+ for (const child of children) {
75
+ const successors = authoritySuccessors.get(child.taskId) ?? new Set<TaskId>();
76
+ successors.add(parent);
77
+ authoritySuccessors.set(child.taskId, successors);
78
+ }
79
+ }
80
+ return {
81
+ predecessorIdsByTask: new Map(
82
+ [...predecessorSets].map(([taskId, predecessors]) => [taskId, [...predecessors]]),
83
+ ),
84
+ authoritySuccessorIdsByTask: new Map(
85
+ [...authoritySuccessors].map(([taskId, successors]) => [taskId, [...successors]]),
86
+ ),
87
+ };
88
+ }
package/src/errors.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type TaskManagerErrorCode =
2
2
  | 'artifact-conflict'
3
+ | 'artifact-not-found'
4
+ | 'canonical-data-invalid'
3
5
  | 'claim-expired'
4
6
  | 'claim-not-expired'
5
7
  | 'claim-token-mismatch'
@@ -28,4 +30,3 @@ export class TaskManagerError extends Error {
28
30
  super(`[${code}] ${message}`);
29
31
  }
30
32
  }
31
-
@@ -10,8 +10,9 @@ import type {
10
10
  TaskSpaceOwnerPort,
11
11
  TaskSpaceSnapshot,
12
12
  } from 'task-manager-contract';
13
+ import { canonicalOwnDataDigest } from 'task-manager-contract';
13
14
  import { TaskManagerError } from './errors.js';
14
- import { deepImmutableClone } from './validation.js';
15
+ import { assertArtifactBody, assertArtifactRef, deepImmutableClone } from './validation.js';
15
16
 
16
17
  interface MemoryTaskSpaceState {
17
18
  readonly snapshot: TaskSpaceSnapshot;
@@ -50,9 +51,14 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
50
51
  }
51
52
 
52
53
  async compareAndSwap(commit: TaskSpaceCommit): Promise<TaskSpaceCommitResult> {
54
+ commit = deepImmutableClone(commit);
55
+ const submittedCommandDigest = validateSubmittedCommand(commit);
53
56
  const current = this.#spaces.get(commit.taskSpaceId);
54
57
  const replay = current?.receiptsByCommand.get(commit.commandId);
55
58
  if (current && replay) {
59
+ if (replay.commandDigest !== submittedCommandDigest) {
60
+ throw new TaskManagerError('command-receipt-mismatch', `command ${commit.commandId} digest does not match prior admission`);
61
+ }
56
62
  return deepImmutableClone({
57
63
  kind: 'command-replayed',
58
64
  taskSpaceId: commit.taskSpaceId,
@@ -64,12 +70,12 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
64
70
 
65
71
  const actualRevision = current?.snapshot.revision ?? null;
66
72
  if (actualRevision !== commit.expectedRevision) {
67
- return {
73
+ return deepImmutableClone({
68
74
  kind: 'revision-conflict',
69
75
  taskSpaceId: commit.taskSpaceId,
70
76
  expectedRevision: commit.expectedRevision,
71
77
  actualRevision,
72
- };
78
+ });
73
79
  }
74
80
 
75
81
  const receipt = validateCommit(commit, current);
@@ -100,12 +106,31 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
100
106
  }
101
107
  }
102
108
 
109
+ function validateSubmittedCommand(commit: TaskSpaceCommit): `sha256:${string}` {
110
+ if (commit.kind !== 'task-space-commit'
111
+ || commit.command.commandId !== commit.commandId
112
+ || commit.command.taskSpaceId !== commit.taskSpaceId) {
113
+ throw new TaskManagerError('command-receipt-mismatch', 'submitted command identity does not match its CAS envelope');
114
+ }
115
+ const commandDigest = canonicalOwnDataDigest(commit.command);
116
+ if (commandDigest !== commit.commandDigest) {
117
+ throw new TaskManagerError(
118
+ 'command-receipt-mismatch',
119
+ `command ${commit.commandId} digest does not match the submitted canonical command`,
120
+ );
121
+ }
122
+ return commandDigest;
123
+ }
124
+
103
125
  function validateCommit(
104
126
  commit: TaskSpaceCommit,
105
127
  current: MemoryTaskSpaceState | undefined,
106
128
  ): TaskCommandReceipt {
107
129
  const expectedNextRevision = commit.expectedRevision === null ? 0 : commit.expectedRevision + 1;
108
130
  if (commit.kind !== 'task-space-commit'
131
+ || commit.command.commandId !== commit.commandId
132
+ || commit.command.taskSpaceId !== commit.taskSpaceId
133
+ || canonicalOwnDataDigest(commit.command) !== commit.commandDigest
109
134
  || commit.snapshot.taskSpaceId !== commit.taskSpaceId
110
135
  || commit.snapshot.definition.taskSpaceId !== commit.taskSpaceId
111
136
  || commit.snapshot.revision !== expectedNextRevision) {
@@ -113,7 +138,10 @@ function validateCommit(
113
138
  }
114
139
  if (commit.receipts.length !== 1) invalid('one command admission must contain exactly one receipt');
115
140
  const receipt = commit.receipts[0];
116
- if (!receipt || receipt.commandId !== commit.commandId || receipt.taskSpaceId !== commit.taskSpaceId) {
141
+ if (!receipt
142
+ || receipt.commandId !== commit.commandId
143
+ || receipt.commandDigest !== commit.commandDigest
144
+ || receipt.taskSpaceId !== commit.taskSpaceId) {
117
145
  invalid('commit receipt does not match command identity');
118
146
  }
119
147
  if (receipt.toRevision !== expectedNextRevision) invalid('receipt toRevision does not match snapshot');
@@ -139,6 +167,27 @@ function validateCommit(
139
167
  }
140
168
  seenEvents.add(event.eventId);
141
169
  }
170
+ const availableArtifacts = new Map(current?.artifacts ?? []);
171
+ for (const body of commit.artifacts) {
172
+ assertArtifactBody(body, `artifact body ${body.digest}`);
173
+ const prior = availableArtifacts.get(body.digest);
174
+ if (prior && canonicalOwnDataDigest(prior) !== canonicalOwnDataDigest(body)) {
175
+ throw new TaskManagerError('artifact-conflict', `artifact ${body.digest} has conflicting content`);
176
+ }
177
+ availableArtifacts.set(body.digest, body);
178
+ }
179
+ const refs = [
180
+ ...commit.snapshot.definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts),
181
+ ...commit.snapshot.tasks.flatMap(({ outputArtifacts }) => outputArtifacts),
182
+ ];
183
+ for (const ref of refs) {
184
+ assertArtifactRef(ref, `artifact ref ${ref.digest}`);
185
+ const body = availableArtifacts.get(ref.digest);
186
+ if (!body) throw new TaskManagerError('artifact-not-found', `artifact ${ref.digest} is not present in owner CAS`);
187
+ if (body.mediaType !== ref.mediaType || body.sizeBytes !== ref.sizeBytes) {
188
+ throw new TaskManagerError('artifact-conflict', `artifact ${ref.digest} metadata disagrees with owner body`);
189
+ }
190
+ }
142
191
  return receipt;
143
192
  }
144
193
 
@@ -157,4 +206,3 @@ function sameArtifact(left: TaskArtifactBody, right: TaskArtifactBody): boolean
157
206
  function invalid(message: string): never {
158
207
  throw new TaskManagerError('invalid-commit', message);
159
208
  }
160
-
package/src/processors.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  SettleTaskCommand,
11
11
  StartTaskCommand,
12
12
  TaskArtifactBody,
13
+ TaskArtifactRef,
13
14
  TaskClaimReceipt,
14
15
  TaskClaimToken,
15
16
  TaskCommand,
@@ -29,14 +30,17 @@ import type {
29
30
  TaskTransitionReceipt,
30
31
  WaitTaskCommand,
31
32
  } from 'task-manager-contract';
33
+ import { canonicalOwnDataDigest } from 'task-manager-contract';
34
+ import { buildTaskCompletionGraph, type TaskCompletionGraph } from './completionGraph.js';
32
35
  import { TaskManagerError } from './errors.js';
33
36
  import { assertTaskTransition, isTerminal } from './statusMachine.js';
34
37
  import {
35
38
  assertClaimIdentity,
36
39
  assertClaimLive,
40
+ assertArtifactBody,
41
+ assertArtifactRef,
37
42
  deepImmutableClone,
38
43
  normalizeTaskProcessorConfig,
39
- predecessorIds,
40
44
  taskRecordById,
41
45
  validateTaskSpaceDefinition,
42
46
  validateTimestamp,
@@ -58,6 +62,7 @@ export async function createTaskSpace<Profile extends TaskProfileEnvelope>(
58
62
  input: CreateTaskSpaceCommand<Profile>,
59
63
  config: TaskProcessorConfig,
60
64
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
65
+ input = canonicalCommand(input);
61
66
  const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
62
67
  if (replay) return replay;
63
68
  if (input.definition.taskSpaceId !== input.taskSpaceId) {
@@ -70,13 +75,16 @@ export async function createTaskSpace<Profile extends TaskProfileEnvelope>(
70
75
  validateTaskSpaceDefinition(input.definition, normalized);
71
76
  validateTimestamp(input.createdAt, 'createdAt');
72
77
 
73
- const tasks: TaskRecord[] = input.definition.tasks.map((definition) => ({
78
+ const artifactBodies = input.artifactBodies ?? [];
79
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, input.definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
80
+
81
+ const provisional: TaskRecord[] = input.definition.tasks.map((definition) => ({
74
82
  kind: 'task-record',
75
83
  taskSpaceId: input.taskSpaceId,
76
84
  taskId: definition.taskId,
77
85
  definition,
78
86
  profile: definition.profile,
79
- status: predecessorIds(input.definition, definition.taskId).length === 0 ? 'Ready' : 'Pending',
87
+ status: 'Pending',
80
88
  attempt: 0,
81
89
  leaseEpoch: 0,
82
90
  activeClaim: null,
@@ -84,6 +92,7 @@ export async function createTaskSpace<Profile extends TaskProfileEnvelope>(
84
92
  createdAt: input.createdAt,
85
93
  updatedAt: input.createdAt,
86
94
  }));
95
+ const tasks = stabilizeTaskProjection(input.definition, provisional);
87
96
  const event = {
88
97
  ...eventBase(input, 0, 0, 0, input.createdAt),
89
98
  kind: 'task-space.created',
@@ -103,7 +112,7 @@ export async function createTaskSpace<Profile extends TaskProfileEnvelope>(
103
112
  createdAt: input.createdAt,
104
113
  updatedAt: input.createdAt,
105
114
  };
106
- return admit(runtime, input, null, snapshot, [event], receipt, []);
115
+ return admit(runtime, input, null, snapshot, [event], receipt, artifactBodies);
107
116
  }
108
117
 
109
118
  export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
@@ -111,12 +120,22 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
111
120
  input: PatchTaskSpaceCommand<Profile>,
112
121
  config: TaskProcessorConfig,
113
122
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
123
+ input = canonicalCommand(input);
114
124
  const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
115
125
  if (replay) return replay;
116
126
  const snapshot = await loadExpected(runtime, input);
117
127
  validateTimestamp(input.patchedAt, 'patchedAt');
118
128
  const patch = input.patch;
119
129
  const current = requireTask(snapshot, patch.taskId);
130
+ if (patch.kind === 'replace-task-profile' && isTerminal(current.status)) {
131
+ throw new TaskManagerError('terminal-task-immutable', `task ${current.taskId} is terminal (${current.status})`);
132
+ }
133
+ if (patch.kind === 'replace-task-profile') {
134
+ throw new TaskManagerError(
135
+ 'invalid-task-transition',
136
+ `task ${current.taskId} profile may change only through a kernel lifecycle Processor`,
137
+ );
138
+ }
120
139
  let nextDefinition: TaskNodeDefinition;
121
140
  if (patch.kind === 'rename-task') {
122
141
  nextDefinition = {
@@ -124,13 +143,17 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
124
143
  name: patch.name,
125
144
  ...(patch.description === undefined ? {} : { description: patch.description }),
126
145
  };
127
- } else if (patch.kind === 'replace-task-profile') {
128
- nextDefinition = { ...current.definition, profile: patch.profile };
129
146
  } else {
130
- const hasArtifact = current.definition.inputArtifacts.some(({ digest }) => digest === patch.artifact.digest);
147
+ const priorArtifact = current.definition.inputArtifacts.find(({ digest }) => digest === patch.artifact.digest);
148
+ if (priorArtifact && canonicalOwnDataDigest(priorArtifact) !== canonicalOwnDataDigest(patch.artifact)) {
149
+ throw new TaskManagerError(
150
+ 'artifact-conflict',
151
+ `artifact ${patch.artifact.digest} conflicts with the attached reference`,
152
+ );
153
+ }
131
154
  nextDefinition = {
132
155
  ...current.definition,
133
- inputArtifacts: hasArtifact
156
+ inputArtifacts: priorArtifact
134
157
  ? current.definition.inputArtifacts
135
158
  : [...current.definition.inputArtifacts, patch.artifact],
136
159
  };
@@ -140,6 +163,8 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
140
163
  tasks: snapshot.definition.tasks.map((task) => task.taskId === current.taskId ? nextDefinition : task),
141
164
  };
142
165
  validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
166
+ const artifactBodies = input.artifactBodies ?? [];
167
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
143
168
  const nextTasks = snapshot.tasks.map((task) => task.taskId === current.taskId
144
169
  ? { ...task, definition: nextDefinition, profile: nextDefinition.profile, updatedAt: input.patchedAt }
145
170
  : task);
@@ -153,7 +178,7 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
153
178
  kind: 'task-transition-receipt',
154
179
  taskId: current.taskId,
155
180
  };
156
- return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt);
181
+ return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt, artifactBodies);
157
182
  }
158
183
 
159
184
  export async function claimTask(
@@ -161,6 +186,7 @@ export async function claimTask(
161
186
  input: ClaimTaskCommand,
162
187
  config: TaskProcessorConfig,
163
188
  ): Promise<TaskProcessorResult<TaskClaimReceipt>> {
189
+ input = canonicalCommand(input);
164
190
  const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
165
191
  if (replay) return replay;
166
192
  const snapshot = await loadExpected(runtime, input);
@@ -217,6 +243,7 @@ export async function heartbeatTaskClaim(
217
243
  input: HeartbeatTaskClaimCommand,
218
244
  config: TaskProcessorConfig,
219
245
  ): Promise<TaskProcessorResult<TaskClaimReceipt>> {
246
+ input = canonicalCommand(input);
220
247
  const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
221
248
  if (replay) return replay;
222
249
  const snapshot = await loadExpected(runtime, input);
@@ -263,6 +290,7 @@ export async function expireTaskClaim(
263
290
  input: ExpireTaskClaimCommand,
264
291
  _config: TaskProcessorConfig,
265
292
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
293
+ input = canonicalCommand(input);
266
294
  const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
267
295
  if (replay) return replay;
268
296
  const snapshot = await loadExpected(runtime, input);
@@ -303,6 +331,7 @@ export async function startTask(
303
331
  input: StartTaskCommand,
304
332
  _config: TaskProcessorConfig,
305
333
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
334
+ input = canonicalCommand(input);
306
335
  return claimedStateTransition(runtime, input, 'Running', 'task.started', 'task-transition-receipt');
307
336
  }
308
337
 
@@ -311,6 +340,7 @@ export async function waitTask(
311
340
  input: WaitTaskCommand,
312
341
  _config: TaskProcessorConfig,
313
342
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
343
+ input = canonicalCommand(input);
314
344
  const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
315
345
  if (replay) return replay;
316
346
  const snapshot = await loadExpected(runtime, input);
@@ -343,12 +373,15 @@ export async function settleTask(
343
373
  input: SettleTaskCommand,
344
374
  _config: TaskProcessorConfig,
345
375
  ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
376
+ input = canonicalCommand(input);
346
377
  const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
347
378
  if (replay) return replay;
348
379
  const snapshot = await loadExpected(runtime, input);
349
380
  const task = assertClaimIdentity(snapshot, input.claim);
350
381
  assertClaimLive(input.claim, input.occurredAt);
351
382
  assertTaskTransition(task.taskId, task.status, 'Succeeded');
383
+ const artifactBodies = input.artifactBodies ?? [];
384
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, input.outputArtifacts, artifactBodies);
352
385
  const settled: TaskRecord = {
353
386
  ...task,
354
387
  status: 'Succeeded',
@@ -365,15 +398,6 @@ export async function settleTask(
365
398
  outputArtifacts: input.outputArtifacts,
366
399
  ...(input.result === undefined ? {} : { result: input.result }),
367
400
  }];
368
- for (const candidate of tasks) {
369
- if (candidate.status !== 'Pending' || !dependenciesSucceeded(snapshot.definition, tasks, candidate.taskId)) continue;
370
- const ready = { ...candidate, status: 'Ready' as const, updatedAt: input.occurredAt };
371
- tasks.splice(tasks.findIndex(({ taskId }) => taskId === ready.taskId), 1, ready);
372
- events.push({
373
- ...stateEventBase(snapshot, input, events.length, input.occurredAt, candidate, 'Ready'),
374
- kind: 'task.readied',
375
- });
376
- }
377
401
  const receipt: TaskSettlementReceipt = {
378
402
  ...nextReceiptBase(snapshot, input, input.occurredAt, events.map(({ eventId }) => eventId)),
379
403
  kind: 'task-settlement-receipt',
@@ -381,7 +405,7 @@ export async function settleTask(
381
405
  claimId: input.claim.claimId,
382
406
  status: 'Succeeded',
383
407
  };
384
- return admitExisting(runtime, input, snapshot, snapshot.definition, tasks, events, receipt);
408
+ return admitExisting(runtime, input, snapshot, snapshot.definition, tasks, events, receipt, artifactBodies);
385
409
  }
386
410
 
387
411
  export async function failTask(
@@ -389,6 +413,7 @@ export async function failTask(
389
413
  input: FailTaskCommand,
390
414
  _config: TaskProcessorConfig,
391
415
  ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
416
+ input = canonicalCommand(input);
392
417
  const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
393
418
  if (replay) return replay;
394
419
  const snapshot = await loadExpected(runtime, input);
@@ -423,6 +448,7 @@ export async function cancelTask(
423
448
  input: CancelTaskCommand,
424
449
  _config: TaskProcessorConfig,
425
450
  ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
451
+ input = canonicalCommand(input);
426
452
  const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
427
453
  if (replay) return replay;
428
454
  const snapshot = await loadExpected(runtime, input);
@@ -461,6 +487,7 @@ export async function replanTask(
461
487
  input: ReplanTaskCommand,
462
488
  config: TaskProcessorConfig,
463
489
  ): Promise<TaskProcessorResult<TaskReplanReceipt>> {
490
+ input = canonicalCommand(input);
464
491
  const replay = await replayed<TaskReplanReceipt>(runtime, input, 'task-replan-receipt');
465
492
  if (replay) return replay;
466
493
  const snapshot = await loadExpected(runtime, input);
@@ -471,6 +498,8 @@ export async function replanTask(
471
498
  relations: input.plan.relations,
472
499
  };
473
500
  validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
501
+ const artifactBodies = input.artifactBodies ?? [];
502
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
474
503
  const nextIds = new Set(definition.tasks.map(({ taskId }) => taskId));
475
504
  for (const prior of snapshot.tasks) {
476
505
  if (!nextIds.has(prior.taskId) && (isTerminal(prior.status) || prior.activeClaim)) {
@@ -495,9 +524,10 @@ export async function replanTask(
495
524
  updatedAt: input.replannedAt,
496
525
  };
497
526
  });
527
+ const completionGraph = buildTaskCompletionGraph(definition);
498
528
  const nextTasks: TaskRecord[] = provisional.map((task) => {
499
529
  if (isTerminal(task.status) || task.activeClaim || task.status === 'Running' || task.status === 'Waiting') return task;
500
- const status: TaskStatus = dependenciesSucceeded(definition, provisional, task.taskId) ? 'Ready' : 'Pending';
530
+ const status: TaskStatus = dependenciesSucceeded(completionGraph, provisional, task.taskId) ? 'Ready' : 'Pending';
501
531
  return { ...task, status };
502
532
  });
503
533
  const affectedTaskIds = [...new Set([...snapshot.tasks.map(({ taskId }) => taskId), ...nextTasks.map(({ taskId }) => taskId)])].sort();
@@ -513,7 +543,7 @@ export async function replanTask(
513
543
  planId: input.plan.planId,
514
544
  affectedTaskIds,
515
545
  };
516
- return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt);
546
+ return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt, artifactBodies);
517
547
  }
518
548
 
519
549
  async function claimedStateTransition(
@@ -555,8 +585,12 @@ async function replayed<Receipt extends TaskCommandReceipt>(
555
585
  ): Promise<TaskProcessorResult<Receipt> | undefined> {
556
586
  const receipt = await runtime.owner.readReceiptByCommand(input.taskSpaceId, input.commandId);
557
587
  if (!receipt) return undefined;
558
- if (receipt.kind !== receiptKind) {
559
- throw new TaskManagerError('command-receipt-mismatch', `command ${input.commandId} was already used for ${receipt.kind}`);
588
+ const commandDigest = canonicalOwnDataDigest(input);
589
+ if (receipt.kind !== receiptKind || receipt.commandDigest !== commandDigest) {
590
+ throw new TaskManagerError(
591
+ 'command-receipt-mismatch',
592
+ `command ${input.commandId} was already used for a different canonical command`,
593
+ );
560
594
  }
561
595
  const snapshot = await runtime.owner.readSnapshot(input.taskSpaceId);
562
596
  if (!snapshot) {
@@ -591,17 +625,43 @@ async function admitExisting<Receipt extends TaskCommandReceipt>(
591
625
  tasks: readonly TaskRecord[],
592
626
  events: readonly TaskHistoryEvent[],
593
627
  receipt: Receipt,
628
+ artifacts: readonly TaskArtifactBody[] = [],
594
629
  ): Promise<TaskProcessorResult<Receipt>> {
630
+ const occurredAt = events.at(-1)?.occurredAt ?? prior.updatedAt;
631
+ const stabilizedTasks = stabilizeTaskProjection(definition, tasks, occurredAt);
632
+ const projectedEvents: TaskHistoryEvent[] = [];
633
+ for (const next of stabilizedTasks) {
634
+ const previous = prior.tasks.find(({ taskId }) => taskId === next.taskId);
635
+ if (!previous || previous.status === next.status) continue;
636
+ if (events.some((event) => 'taskId' in event && event.taskId === next.taskId && 'toStatus' in event && event.toStatus === next.status)) {
637
+ continue;
638
+ }
639
+ const index = events.length + projectedEvents.length;
640
+ projectedEvents.push(next.definition.kind === 'task-group'
641
+ ? {
642
+ ...stateEventBase(prior, input, index, occurredAt, previous, next.status),
643
+ kind: 'task.group-status-projected',
644
+ }
645
+ : {
646
+ ...stateEventBase(prior, input, index, occurredAt, previous, next.status),
647
+ kind: 'task.readied',
648
+ });
649
+ }
650
+ const admittedEvents = [...events, ...projectedEvents];
651
+ const admittedReceipt = {
652
+ ...receipt,
653
+ eventIds: admittedEvents.map(({ eventId }) => eventId),
654
+ } as Receipt;
595
655
  const snapshot: TaskSpaceSnapshot = {
596
656
  ...prior,
597
657
  revision: prior.revision + 1,
598
658
  definition,
599
- tasks,
600
- historyEventIds: [...prior.historyEventIds, ...events.map(({ eventId }) => eventId)],
601
- issuedReceiptIds: [...prior.issuedReceiptIds, receipt.receiptId],
602
- updatedAt: events.at(-1)?.occurredAt ?? prior.updatedAt,
659
+ tasks: stabilizedTasks,
660
+ historyEventIds: [...prior.historyEventIds, ...admittedEvents.map(({ eventId }) => eventId)],
661
+ issuedReceiptIds: [...prior.issuedReceiptIds, admittedReceipt.receiptId],
662
+ updatedAt: occurredAt,
603
663
  };
604
- return admit(runtime, input, prior.revision, snapshot, events, receipt, []);
664
+ return admit(runtime, input, prior.revision, snapshot, admittedEvents, admittedReceipt, artifacts);
605
665
  }
606
666
 
607
667
  async function admit<Receipt extends TaskCommandReceipt>(
@@ -618,6 +678,8 @@ async function admit<Receipt extends TaskCommandReceipt>(
618
678
  taskSpaceId: input.taskSpaceId,
619
679
  expectedRevision,
620
680
  commandId: input.commandId,
681
+ commandDigest: canonicalOwnDataDigest(input),
682
+ command: input,
621
683
  snapshot,
622
684
  events,
623
685
  receipts: [receipt],
@@ -653,11 +715,11 @@ function replaceTask(snapshot: TaskSpaceSnapshot, replacement: TaskRecord): Task
653
715
  }
654
716
 
655
717
  function dependenciesSucceeded(
656
- definition: TaskSpaceDefinition,
718
+ completionGraph: TaskCompletionGraph,
657
719
  tasks: readonly TaskRecord[],
658
720
  taskId: TaskId,
659
721
  ): boolean {
660
- const predecessors = predecessorIds(definition, taskId);
722
+ const predecessors = completionGraph.predecessorIdsByTask.get(taskId) ?? [];
661
723
  return predecessors.length === 0 || predecessors.every((predecessorId) =>
662
724
  tasks.find((task) => task.taskId === predecessorId)?.status === 'Succeeded');
663
725
  }
@@ -718,6 +780,7 @@ function receiptBase(
718
780
  return {
719
781
  receiptId: stableId('receipt', input.commandId),
720
782
  commandId: input.commandId,
783
+ commandDigest: canonicalOwnDataDigest(input),
721
784
  taskSpaceId: input.taskSpaceId,
722
785
  fromRevision,
723
786
  toRevision,
@@ -745,3 +808,92 @@ function nonEmpty(value: string, field: string): string {
745
808
  }
746
809
  return value;
747
810
  }
811
+
812
+ function canonicalCommand<Command extends TaskCommand>(input: Command): Command {
813
+ return deepImmutableClone(input);
814
+ }
815
+
816
+ async function assertArtifactAuthority(
817
+ owner: TaskSpaceOwnerPort,
818
+ taskSpaceId: string,
819
+ refs: readonly TaskArtifactRef[],
820
+ bodies: readonly TaskArtifactBody[],
821
+ ): Promise<void> {
822
+ const supplied = new Map<string, TaskArtifactBody>();
823
+ for (const body of bodies) {
824
+ assertArtifactBody(body, `artifact body ${body.digest}`);
825
+ const prior = supplied.get(body.digest);
826
+ if (prior && canonicalOwnDataDigest(prior) !== canonicalOwnDataDigest(body)) {
827
+ throw new TaskManagerError('artifact-conflict', `artifact ${body.digest} has conflicting bodies`);
828
+ }
829
+ supplied.set(body.digest, body);
830
+ }
831
+ for (const ref of refs) {
832
+ assertArtifactRef(ref, `artifact ref ${ref.digest}`);
833
+ const body = supplied.get(ref.digest) ?? await owner.readArtifact(taskSpaceId, ref.digest);
834
+ if (!body) throw new TaskManagerError('artifact-not-found', `artifact ${ref.digest} is not present in this owner admission`);
835
+ if (body.digest !== ref.digest || body.mediaType !== ref.mediaType || body.sizeBytes !== ref.sizeBytes) {
836
+ throw new TaskManagerError('artifact-conflict', `artifact ${ref.digest} metadata disagrees with its body`);
837
+ }
838
+ }
839
+ }
840
+
841
+ function stabilizeTaskProjection(
842
+ definition: TaskSpaceDefinition,
843
+ inputTasks: readonly TaskRecord[],
844
+ projectedAt?: TaskTimestamp,
845
+ ): TaskRecord[] {
846
+ const completionGraph = buildTaskCompletionGraph(definition);
847
+ let tasks = inputTasks.map((task) => ({ ...task }));
848
+ const childrenByParent = new Map<TaskId, TaskId[]>();
849
+ for (const relation of definition.relations) {
850
+ if (relation.kind !== 'parent-child' || relation.parentTaskId === null) continue;
851
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
852
+ children.push(relation.childTaskId);
853
+ childrenByParent.set(relation.parentTaskId, children);
854
+ }
855
+ for (let iteration = 0; iteration <= tasks.length * 2; iteration += 1) {
856
+ let changed = false;
857
+ const next = tasks.map((task) => {
858
+ let status = task.status;
859
+ if (task.definition.kind === 'task') {
860
+ if (status === 'Pending' || status === 'Ready') {
861
+ status = dependenciesSucceeded(completionGraph, tasks, task.taskId) ? 'Ready' : 'Pending';
862
+ }
863
+ } else {
864
+ const childStatuses = (childrenByParent.get(task.taskId) ?? [])
865
+ .map((childId) => tasks.find(({ taskId }) => taskId === childId)!.status);
866
+ status = projectGroupStatus(task.definition.mode, childStatuses,
867
+ dependenciesSucceeded(completionGraph, tasks, task.taskId));
868
+ if (isTerminal(task.status) && status !== task.status) {
869
+ throw new TaskManagerError('terminal-task-immutable', `task-group ${task.taskId} is terminal (${task.status})`);
870
+ }
871
+ }
872
+ if (status === task.status) return task;
873
+ changed = true;
874
+ return { ...task, status, ...(projectedAt === undefined ? {} : { updatedAt: projectedAt }) };
875
+ });
876
+ tasks = next;
877
+ if (!changed) return tasks;
878
+ }
879
+ throw new TaskManagerError('cyclic-task-forest', 'TaskGroup projection did not converge');
880
+ }
881
+
882
+ function projectGroupStatus(
883
+ mode: 'sequential' | 'all' | 'any',
884
+ childStatuses: readonly TaskStatus[],
885
+ dependenciesAreSatisfied: boolean,
886
+ ): TaskStatus {
887
+ if (!dependenciesAreSatisfied) return 'Pending';
888
+ if (mode === 'any') {
889
+ if (childStatuses.some((status) => status === 'Succeeded')) return 'Succeeded';
890
+ if (childStatuses.every(isTerminal)) return 'Failed';
891
+ } else {
892
+ if (childStatuses.some((status) => status === 'Failed' || status === 'Cancelled')) return 'Failed';
893
+ if (childStatuses.every((status) => status === 'Succeeded')) return 'Succeeded';
894
+ }
895
+ if (childStatuses.some((status) => status === 'Claimed' || status === 'Running')) return 'Running';
896
+ if (childStatuses.some((status) => status === 'Waiting')) return 'Waiting';
897
+ if (childStatuses.some((status) => status === 'Ready')) return 'Ready';
898
+ return 'Pending';
899
+ }
@@ -12,18 +12,18 @@ export const TASK_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]
12
12
  Cancelled: Object.freeze([] as const),
13
13
  });
14
14
 
15
- export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
15
+ const TERMINAL_STATUSES = Object.freeze([
16
16
  'Succeeded',
17
17
  'Failed',
18
18
  'Cancelled',
19
- ]);
19
+ ] as const);
20
20
 
21
21
  export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
22
22
  return TASK_TRANSITIONS[from].includes(to);
23
23
  }
24
24
 
25
25
  export function isTerminal(status: TaskStatus): status is Extract<TaskStatus, 'Succeeded' | 'Failed' | 'Cancelled'> {
26
- return TERMINAL_STATUSES.has(status);
26
+ return (TERMINAL_STATUSES as readonly TaskStatus[]).includes(status);
27
27
  }
28
28
 
29
29
  export function assertTaskTransition(taskId: string, from: TaskStatus, to: TaskStatus): void {
package/src/validation.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  import type {
2
+ TaskArtifactBody,
3
+ TaskArtifactRef,
2
4
  TaskClaimToken,
3
5
  TaskDefinition,
4
6
  TaskId,
5
7
  TaskNodeDefinition,
6
8
  TaskProfileEnvelope,
7
9
  TaskRecord,
8
- TaskRelation,
9
10
  TaskSpaceDefinition,
10
11
  TaskSpaceSnapshot,
11
12
  TaskTimestamp,
12
13
  } from 'task-manager-contract';
14
+ import { deepFrozenCanonicalOwnDataClone, sha256Hex } from 'task-manager-contract';
15
+ import { buildTaskCompletionGraph } from './completionGraph.js';
13
16
  import { TaskManagerError } from './errors.js';
14
17
 
15
18
  export interface TaskProcessorConfig {
@@ -56,6 +59,7 @@ export function validateTaskSpaceDefinition(
56
59
 
57
60
  const relationIds = new Set<string>();
58
61
  const parentByChild = new Map<TaskId, TaskId | null>();
62
+ const childrenByParent = new Map<TaskId, TaskId[]>();
59
63
  const dependencyEdges = new Map<TaskId, TaskId[]>();
60
64
  for (const relation of definition.relations) {
61
65
  assertNonEmpty(relation.relationId, 'relationId');
@@ -70,6 +74,9 @@ export function validateTaskSpaceDefinition(
70
74
  if (!parent) invalid(`parent relation references missing parent ${relation.parentTaskId}`);
71
75
  if (parent.kind !== 'task-group') invalid(`parent ${relation.parentTaskId} is not a task-group`);
72
76
  if (relation.parentTaskId === relation.childTaskId) cyclic(`task ${relation.childTaskId} parents itself`);
77
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
78
+ children.push(relation.childTaskId);
79
+ childrenByParent.set(relation.parentTaskId, children);
73
80
  }
74
81
  parentByChild.set(relation.childTaskId, relation.parentTaskId);
75
82
  continue;
@@ -91,6 +98,14 @@ export function validateTaskSpaceDefinition(
91
98
  return parent === null || parent === undefined ? [] : [parent];
92
99
  });
93
100
  assertAcyclic(tasks.keys(), (id) => dependencyEdges.get(id) ?? []);
101
+
102
+ for (const task of tasks.values()) {
103
+ if (task.kind === 'task-group' && (childrenByParent.get(task.taskId)?.length ?? 0) === 0) {
104
+ invalid(`task-group ${task.taskId} must contain at least one child`);
105
+ }
106
+ }
107
+ const completionGraph = buildTaskCompletionGraph(definition);
108
+ assertAcyclic(tasks.keys(), (id) => completionGraph.authoritySuccessorIdsByTask.get(id) ?? []);
94
109
  }
95
110
 
96
111
  export function taskDefinitionById(
@@ -105,10 +120,7 @@ export function taskRecordById(snapshot: TaskSpaceSnapshot, taskId: TaskId): Tas
105
120
  }
106
121
 
107
122
  export function predecessorIds(definition: TaskSpaceDefinition, taskId: TaskId): readonly TaskId[] {
108
- return definition.relations
109
- .filter((relation): relation is Extract<TaskRelation, { kind: 'dependency' }> =>
110
- relation.kind === 'dependency' && relation.successorTaskId === taskId)
111
- .map((relation) => relation.predecessorTaskId);
123
+ return buildTaskCompletionGraph(definition).predecessorIdsByTask.get(taskId) ?? [];
112
124
  }
113
125
 
114
126
  export function validateTimestamp(value: TaskTimestamp, field: string): number {
@@ -160,7 +172,11 @@ export function sameClaim(left: TaskClaimToken, right: TaskClaimToken): boolean
160
172
  }
161
173
 
162
174
  export function deepImmutableClone<Value>(value: Value): Value {
163
- return deepFreeze(structuredClone(value));
175
+ try {
176
+ return deepFrozenCanonicalOwnDataClone(value);
177
+ } catch (error) {
178
+ throw new TaskManagerError('canonical-data-invalid', String(error));
179
+ }
164
180
  }
165
181
 
166
182
  function assertTaskDefinition(task: TaskNodeDefinition): asserts task is TaskDefinition | Exclude<TaskNodeDefinition, TaskDefinition> {
@@ -171,13 +187,32 @@ function assertTaskDefinition(task: TaskNodeDefinition): asserts task is TaskDef
171
187
  invalid(`task-group ${task.taskId} has invalid mode`);
172
188
  }
173
189
  assertProfile(task.profile, task.taskId);
174
- for (const artifact of task.inputArtifacts) {
175
- if (!artifact.digest.startsWith('sha256:') || artifact.digest.length <= 'sha256:'.length) {
176
- invalid(`task ${task.taskId} has invalid artifact digest`);
177
- }
178
- if (!Number.isSafeInteger(artifact.sizeBytes) || artifact.sizeBytes < 0) {
179
- invalid(`task ${task.taskId} has invalid artifact size`);
180
- }
190
+ for (const artifact of task.inputArtifacts) assertArtifactRef(artifact, `task ${task.taskId} input artifact`);
191
+ }
192
+
193
+ export function assertArtifactRef(artifact: TaskArtifactRef, field: string): void {
194
+ if (artifact.kind !== 'task-artifact-ref' || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest)) {
195
+ invalid(`${field} has invalid digest`);
196
+ }
197
+ assertNonEmpty(artifact.mediaType, `${field} mediaType`);
198
+ if (!Number.isSafeInteger(artifact.sizeBytes) || artifact.sizeBytes < 0) invalid(`${field} has invalid size`);
199
+ if (artifact.name !== undefined) assertNonEmpty(artifact.name, `${field} name`);
200
+ }
201
+
202
+ export function assertArtifactBody(body: TaskArtifactBody, field: string): void {
203
+ if (body.kind !== 'task-artifact-body' || body.encoding !== 'base64') invalid(`${field} has invalid encoding`);
204
+ assertNonEmpty(body.mediaType, `${field} mediaType`);
205
+ if (!Number.isSafeInteger(body.sizeBytes) || body.sizeBytes < 0) invalid(`${field} has invalid size`);
206
+ let binary: string;
207
+ try {
208
+ binary = atob(body.data);
209
+ } catch {
210
+ invalid(`${field} has invalid base64`);
211
+ }
212
+ if (btoa(binary) !== body.data) invalid(`${field} base64 is not canonical`);
213
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
214
+ if (bytes.byteLength !== body.sizeBytes || `sha256:${sha256Hex(bytes)}` !== body.digest) {
215
+ invalid(`${field} digest or size mismatch`);
181
216
  }
182
217
  }
183
218
 
@@ -219,12 +254,3 @@ function invalid(message: string): never {
219
254
  function cyclic(message: string): never {
220
255
  throw new TaskManagerError('cyclic-task-forest', message);
221
256
  }
222
-
223
- function deepFreeze<Value>(value: Value): Value {
224
- if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
225
- for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
226
- Object.freeze(value);
227
- }
228
- return value;
229
- }
230
-