task-manager-logic 0.1.2 → 0.1.3

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.3",
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"
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,13 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
50
51
  }
51
52
 
52
53
  async compareAndSwap(commit: TaskSpaceCommit): Promise<TaskSpaceCommitResult> {
54
+ commit = deepImmutableClone(commit);
53
55
  const current = this.#spaces.get(commit.taskSpaceId);
54
56
  const replay = current?.receiptsByCommand.get(commit.commandId);
55
57
  if (current && replay) {
58
+ if (replay.commandDigest !== commit.commandDigest) {
59
+ throw new TaskManagerError('command-receipt-mismatch', `command ${commit.commandId} digest does not match prior admission`);
60
+ }
56
61
  return deepImmutableClone({
57
62
  kind: 'command-replayed',
58
63
  taskSpaceId: commit.taskSpaceId,
@@ -64,12 +69,12 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
64
69
 
65
70
  const actualRevision = current?.snapshot.revision ?? null;
66
71
  if (actualRevision !== commit.expectedRevision) {
67
- return {
72
+ return deepImmutableClone({
68
73
  kind: 'revision-conflict',
69
74
  taskSpaceId: commit.taskSpaceId,
70
75
  expectedRevision: commit.expectedRevision,
71
76
  actualRevision,
72
- };
77
+ });
73
78
  }
74
79
 
75
80
  const receipt = validateCommit(commit, current);
@@ -106,6 +111,9 @@ function validateCommit(
106
111
  ): TaskCommandReceipt {
107
112
  const expectedNextRevision = commit.expectedRevision === null ? 0 : commit.expectedRevision + 1;
108
113
  if (commit.kind !== 'task-space-commit'
114
+ || commit.command.commandId !== commit.commandId
115
+ || commit.command.taskSpaceId !== commit.taskSpaceId
116
+ || canonicalOwnDataDigest(commit.command) !== commit.commandDigest
109
117
  || commit.snapshot.taskSpaceId !== commit.taskSpaceId
110
118
  || commit.snapshot.definition.taskSpaceId !== commit.taskSpaceId
111
119
  || commit.snapshot.revision !== expectedNextRevision) {
@@ -113,7 +121,10 @@ function validateCommit(
113
121
  }
114
122
  if (commit.receipts.length !== 1) invalid('one command admission must contain exactly one receipt');
115
123
  const receipt = commit.receipts[0];
116
- if (!receipt || receipt.commandId !== commit.commandId || receipt.taskSpaceId !== commit.taskSpaceId) {
124
+ if (!receipt
125
+ || receipt.commandId !== commit.commandId
126
+ || receipt.commandDigest !== commit.commandDigest
127
+ || receipt.taskSpaceId !== commit.taskSpaceId) {
117
128
  invalid('commit receipt does not match command identity');
118
129
  }
119
130
  if (receipt.toRevision !== expectedNextRevision) invalid('receipt toRevision does not match snapshot');
@@ -139,6 +150,27 @@ function validateCommit(
139
150
  }
140
151
  seenEvents.add(event.eventId);
141
152
  }
153
+ const availableArtifacts = new Map(current?.artifacts ?? []);
154
+ for (const body of commit.artifacts) {
155
+ assertArtifactBody(body, `artifact body ${body.digest}`);
156
+ const prior = availableArtifacts.get(body.digest);
157
+ if (prior && canonicalOwnDataDigest(prior) !== canonicalOwnDataDigest(body)) {
158
+ throw new TaskManagerError('artifact-conflict', `artifact ${body.digest} has conflicting content`);
159
+ }
160
+ availableArtifacts.set(body.digest, body);
161
+ }
162
+ const refs = [
163
+ ...commit.snapshot.definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts),
164
+ ...commit.snapshot.tasks.flatMap(({ outputArtifacts }) => outputArtifacts),
165
+ ];
166
+ for (const ref of refs) {
167
+ assertArtifactRef(ref, `artifact ref ${ref.digest}`);
168
+ const body = availableArtifacts.get(ref.digest);
169
+ if (!body) throw new TaskManagerError('artifact-not-found', `artifact ${ref.digest} is not present in owner CAS`);
170
+ if (body.mediaType !== ref.mediaType || body.sizeBytes !== ref.sizeBytes) {
171
+ throw new TaskManagerError('artifact-conflict', `artifact ${ref.digest} metadata disagrees with owner body`);
172
+ }
173
+ }
142
174
  return receipt;
143
175
  }
144
176
 
@@ -157,4 +189,3 @@ function sameArtifact(left: TaskArtifactBody, right: TaskArtifactBody): boolean
157
189
  function invalid(message: string): never {
158
190
  throw new TaskManagerError('invalid-commit', message);
159
191
  }
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,11 +30,14 @@ import type {
29
30
  TaskTransitionReceipt,
30
31
  WaitTaskCommand,
31
32
  } from 'task-manager-contract';
33
+ import { canonicalOwnDataDigest } from 'task-manager-contract';
32
34
  import { TaskManagerError } from './errors.js';
33
35
  import { assertTaskTransition, isTerminal } from './statusMachine.js';
34
36
  import {
35
37
  assertClaimIdentity,
36
38
  assertClaimLive,
39
+ assertArtifactBody,
40
+ assertArtifactRef,
37
41
  deepImmutableClone,
38
42
  normalizeTaskProcessorConfig,
39
43
  predecessorIds,
@@ -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,8 +143,6 @@ 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
147
  const hasArtifact = current.definition.inputArtifacts.some(({ digest }) => digest === patch.artifact.digest);
131
148
  nextDefinition = {
@@ -140,6 +157,8 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
140
157
  tasks: snapshot.definition.tasks.map((task) => task.taskId === current.taskId ? nextDefinition : task),
141
158
  };
142
159
  validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
160
+ const artifactBodies = input.artifactBodies ?? [];
161
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
143
162
  const nextTasks = snapshot.tasks.map((task) => task.taskId === current.taskId
144
163
  ? { ...task, definition: nextDefinition, profile: nextDefinition.profile, updatedAt: input.patchedAt }
145
164
  : task);
@@ -153,7 +172,7 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
153
172
  kind: 'task-transition-receipt',
154
173
  taskId: current.taskId,
155
174
  };
156
- return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt);
175
+ return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt, artifactBodies);
157
176
  }
158
177
 
159
178
  export async function claimTask(
@@ -161,6 +180,7 @@ export async function claimTask(
161
180
  input: ClaimTaskCommand,
162
181
  config: TaskProcessorConfig,
163
182
  ): Promise<TaskProcessorResult<TaskClaimReceipt>> {
183
+ input = canonicalCommand(input);
164
184
  const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
165
185
  if (replay) return replay;
166
186
  const snapshot = await loadExpected(runtime, input);
@@ -217,6 +237,7 @@ export async function heartbeatTaskClaim(
217
237
  input: HeartbeatTaskClaimCommand,
218
238
  config: TaskProcessorConfig,
219
239
  ): Promise<TaskProcessorResult<TaskClaimReceipt>> {
240
+ input = canonicalCommand(input);
220
241
  const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
221
242
  if (replay) return replay;
222
243
  const snapshot = await loadExpected(runtime, input);
@@ -263,6 +284,7 @@ export async function expireTaskClaim(
263
284
  input: ExpireTaskClaimCommand,
264
285
  _config: TaskProcessorConfig,
265
286
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
287
+ input = canonicalCommand(input);
266
288
  const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
267
289
  if (replay) return replay;
268
290
  const snapshot = await loadExpected(runtime, input);
@@ -303,6 +325,7 @@ export async function startTask(
303
325
  input: StartTaskCommand,
304
326
  _config: TaskProcessorConfig,
305
327
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
328
+ input = canonicalCommand(input);
306
329
  return claimedStateTransition(runtime, input, 'Running', 'task.started', 'task-transition-receipt');
307
330
  }
308
331
 
@@ -311,6 +334,7 @@ export async function waitTask(
311
334
  input: WaitTaskCommand,
312
335
  _config: TaskProcessorConfig,
313
336
  ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
337
+ input = canonicalCommand(input);
314
338
  const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
315
339
  if (replay) return replay;
316
340
  const snapshot = await loadExpected(runtime, input);
@@ -343,12 +367,15 @@ export async function settleTask(
343
367
  input: SettleTaskCommand,
344
368
  _config: TaskProcessorConfig,
345
369
  ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
370
+ input = canonicalCommand(input);
346
371
  const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
347
372
  if (replay) return replay;
348
373
  const snapshot = await loadExpected(runtime, input);
349
374
  const task = assertClaimIdentity(snapshot, input.claim);
350
375
  assertClaimLive(input.claim, input.occurredAt);
351
376
  assertTaskTransition(task.taskId, task.status, 'Succeeded');
377
+ const artifactBodies = input.artifactBodies ?? [];
378
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, input.outputArtifacts, artifactBodies);
352
379
  const settled: TaskRecord = {
353
380
  ...task,
354
381
  status: 'Succeeded',
@@ -365,15 +392,6 @@ export async function settleTask(
365
392
  outputArtifacts: input.outputArtifacts,
366
393
  ...(input.result === undefined ? {} : { result: input.result }),
367
394
  }];
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
395
  const receipt: TaskSettlementReceipt = {
378
396
  ...nextReceiptBase(snapshot, input, input.occurredAt, events.map(({ eventId }) => eventId)),
379
397
  kind: 'task-settlement-receipt',
@@ -381,7 +399,7 @@ export async function settleTask(
381
399
  claimId: input.claim.claimId,
382
400
  status: 'Succeeded',
383
401
  };
384
- return admitExisting(runtime, input, snapshot, snapshot.definition, tasks, events, receipt);
402
+ return admitExisting(runtime, input, snapshot, snapshot.definition, tasks, events, receipt, artifactBodies);
385
403
  }
386
404
 
387
405
  export async function failTask(
@@ -389,6 +407,7 @@ export async function failTask(
389
407
  input: FailTaskCommand,
390
408
  _config: TaskProcessorConfig,
391
409
  ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
410
+ input = canonicalCommand(input);
392
411
  const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
393
412
  if (replay) return replay;
394
413
  const snapshot = await loadExpected(runtime, input);
@@ -423,6 +442,7 @@ export async function cancelTask(
423
442
  input: CancelTaskCommand,
424
443
  _config: TaskProcessorConfig,
425
444
  ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
445
+ input = canonicalCommand(input);
426
446
  const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
427
447
  if (replay) return replay;
428
448
  const snapshot = await loadExpected(runtime, input);
@@ -461,6 +481,7 @@ export async function replanTask(
461
481
  input: ReplanTaskCommand,
462
482
  config: TaskProcessorConfig,
463
483
  ): Promise<TaskProcessorResult<TaskReplanReceipt>> {
484
+ input = canonicalCommand(input);
464
485
  const replay = await replayed<TaskReplanReceipt>(runtime, input, 'task-replan-receipt');
465
486
  if (replay) return replay;
466
487
  const snapshot = await loadExpected(runtime, input);
@@ -471,6 +492,8 @@ export async function replanTask(
471
492
  relations: input.plan.relations,
472
493
  };
473
494
  validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
495
+ const artifactBodies = input.artifactBodies ?? [];
496
+ await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
474
497
  const nextIds = new Set(definition.tasks.map(({ taskId }) => taskId));
475
498
  for (const prior of snapshot.tasks) {
476
499
  if (!nextIds.has(prior.taskId) && (isTerminal(prior.status) || prior.activeClaim)) {
@@ -513,7 +536,7 @@ export async function replanTask(
513
536
  planId: input.plan.planId,
514
537
  affectedTaskIds,
515
538
  };
516
- return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt);
539
+ return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt, artifactBodies);
517
540
  }
518
541
 
519
542
  async function claimedStateTransition(
@@ -555,8 +578,12 @@ async function replayed<Receipt extends TaskCommandReceipt>(
555
578
  ): Promise<TaskProcessorResult<Receipt> | undefined> {
556
579
  const receipt = await runtime.owner.readReceiptByCommand(input.taskSpaceId, input.commandId);
557
580
  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}`);
581
+ const commandDigest = canonicalOwnDataDigest(input);
582
+ if (receipt.kind !== receiptKind || receipt.commandDigest !== commandDigest) {
583
+ throw new TaskManagerError(
584
+ 'command-receipt-mismatch',
585
+ `command ${input.commandId} was already used for a different canonical command`,
586
+ );
560
587
  }
561
588
  const snapshot = await runtime.owner.readSnapshot(input.taskSpaceId);
562
589
  if (!snapshot) {
@@ -591,17 +618,43 @@ async function admitExisting<Receipt extends TaskCommandReceipt>(
591
618
  tasks: readonly TaskRecord[],
592
619
  events: readonly TaskHistoryEvent[],
593
620
  receipt: Receipt,
621
+ artifacts: readonly TaskArtifactBody[] = [],
594
622
  ): Promise<TaskProcessorResult<Receipt>> {
623
+ const occurredAt = events.at(-1)?.occurredAt ?? prior.updatedAt;
624
+ const stabilizedTasks = stabilizeTaskProjection(definition, tasks, occurredAt);
625
+ const projectedEvents: TaskHistoryEvent[] = [];
626
+ for (const next of stabilizedTasks) {
627
+ const previous = prior.tasks.find(({ taskId }) => taskId === next.taskId);
628
+ if (!previous || previous.status === next.status) continue;
629
+ if (events.some((event) => 'taskId' in event && event.taskId === next.taskId && 'toStatus' in event && event.toStatus === next.status)) {
630
+ continue;
631
+ }
632
+ const index = events.length + projectedEvents.length;
633
+ projectedEvents.push(next.definition.kind === 'task-group'
634
+ ? {
635
+ ...stateEventBase(prior, input, index, occurredAt, previous, next.status),
636
+ kind: 'task.group-status-projected',
637
+ }
638
+ : {
639
+ ...stateEventBase(prior, input, index, occurredAt, previous, next.status),
640
+ kind: 'task.readied',
641
+ });
642
+ }
643
+ const admittedEvents = [...events, ...projectedEvents];
644
+ const admittedReceipt = {
645
+ ...receipt,
646
+ eventIds: admittedEvents.map(({ eventId }) => eventId),
647
+ } as Receipt;
595
648
  const snapshot: TaskSpaceSnapshot = {
596
649
  ...prior,
597
650
  revision: prior.revision + 1,
598
651
  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,
652
+ tasks: stabilizedTasks,
653
+ historyEventIds: [...prior.historyEventIds, ...admittedEvents.map(({ eventId }) => eventId)],
654
+ issuedReceiptIds: [...prior.issuedReceiptIds, admittedReceipt.receiptId],
655
+ updatedAt: occurredAt,
603
656
  };
604
- return admit(runtime, input, prior.revision, snapshot, events, receipt, []);
657
+ return admit(runtime, input, prior.revision, snapshot, admittedEvents, admittedReceipt, artifacts);
605
658
  }
606
659
 
607
660
  async function admit<Receipt extends TaskCommandReceipt>(
@@ -618,6 +671,8 @@ async function admit<Receipt extends TaskCommandReceipt>(
618
671
  taskSpaceId: input.taskSpaceId,
619
672
  expectedRevision,
620
673
  commandId: input.commandId,
674
+ commandDigest: canonicalOwnDataDigest(input),
675
+ command: input,
621
676
  snapshot,
622
677
  events,
623
678
  receipts: [receipt],
@@ -657,7 +712,7 @@ function dependenciesSucceeded(
657
712
  tasks: readonly TaskRecord[],
658
713
  taskId: TaskId,
659
714
  ): boolean {
660
- const predecessors = predecessorIds(definition, taskId);
715
+ const predecessors = effectivePredecessorIds(definition, taskId);
661
716
  return predecessors.length === 0 || predecessors.every((predecessorId) =>
662
717
  tasks.find((task) => task.taskId === predecessorId)?.status === 'Succeeded');
663
718
  }
@@ -718,6 +773,7 @@ function receiptBase(
718
773
  return {
719
774
  receiptId: stableId('receipt', input.commandId),
720
775
  commandId: input.commandId,
776
+ commandDigest: canonicalOwnDataDigest(input),
721
777
  taskSpaceId: input.taskSpaceId,
722
778
  fromRevision,
723
779
  toRevision,
@@ -745,3 +801,121 @@ function nonEmpty(value: string, field: string): string {
745
801
  }
746
802
  return value;
747
803
  }
804
+
805
+ function canonicalCommand<Command extends TaskCommand>(input: Command): Command {
806
+ return deepImmutableClone(input);
807
+ }
808
+
809
+ async function assertArtifactAuthority(
810
+ owner: TaskSpaceOwnerPort,
811
+ taskSpaceId: string,
812
+ refs: readonly TaskArtifactRef[],
813
+ bodies: readonly TaskArtifactBody[],
814
+ ): Promise<void> {
815
+ const supplied = new Map<string, TaskArtifactBody>();
816
+ for (const body of bodies) {
817
+ assertArtifactBody(body, `artifact body ${body.digest}`);
818
+ const prior = supplied.get(body.digest);
819
+ if (prior && canonicalOwnDataDigest(prior) !== canonicalOwnDataDigest(body)) {
820
+ throw new TaskManagerError('artifact-conflict', `artifact ${body.digest} has conflicting bodies`);
821
+ }
822
+ supplied.set(body.digest, body);
823
+ }
824
+ for (const ref of refs) {
825
+ assertArtifactRef(ref, `artifact ref ${ref.digest}`);
826
+ const body = supplied.get(ref.digest) ?? await owner.readArtifact(taskSpaceId, ref.digest);
827
+ if (!body) throw new TaskManagerError('artifact-not-found', `artifact ${ref.digest} is not present in this owner admission`);
828
+ if (body.digest !== ref.digest || body.mediaType !== ref.mediaType || body.sizeBytes !== ref.sizeBytes) {
829
+ throw new TaskManagerError('artifact-conflict', `artifact ${ref.digest} metadata disagrees with its body`);
830
+ }
831
+ }
832
+ }
833
+
834
+ function effectivePredecessorIds(definition: TaskSpaceDefinition, taskId: TaskId): readonly TaskId[] {
835
+ const result = new Set(predecessorIds(definition, taskId));
836
+ const parentByChild = new Map<TaskId, TaskId | null>();
837
+ const childrenByParent = new Map<TaskId, { readonly taskId: TaskId; readonly order: number }[]>();
838
+ for (const relation of definition.relations) {
839
+ if (relation.kind !== 'parent-child') continue;
840
+ parentByChild.set(relation.childTaskId, relation.parentTaskId);
841
+ if (relation.parentTaskId !== null) {
842
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
843
+ children.push({ taskId: relation.childTaskId, order: relation.order });
844
+ childrenByParent.set(relation.parentTaskId, children);
845
+ }
846
+ }
847
+ let current: TaskId | null | undefined = taskId;
848
+ while (current !== null && current !== undefined) {
849
+ const parent = parentByChild.get(current);
850
+ if (parent === null || parent === undefined) break;
851
+ for (const predecessor of predecessorIds(definition, parent)) result.add(predecessor);
852
+ const parentDefinition = definition.tasks.find(({ taskId: candidate }) => candidate === parent);
853
+ if (parentDefinition?.kind === 'task-group' && parentDefinition.mode === 'sequential') {
854
+ const siblings = [...(childrenByParent.get(parent) ?? [])]
855
+ .sort((left, right) => left.order - right.order || left.taskId.localeCompare(right.taskId));
856
+ const index = siblings.findIndex(({ taskId: candidate }) => candidate === current);
857
+ if (index > 0) result.add(siblings[index - 1]!.taskId);
858
+ }
859
+ current = parent;
860
+ }
861
+ return [...result];
862
+ }
863
+
864
+ function stabilizeTaskProjection(
865
+ definition: TaskSpaceDefinition,
866
+ inputTasks: readonly TaskRecord[],
867
+ projectedAt?: TaskTimestamp,
868
+ ): TaskRecord[] {
869
+ let tasks = inputTasks.map((task) => ({ ...task }));
870
+ const childrenByParent = new Map<TaskId, TaskId[]>();
871
+ for (const relation of definition.relations) {
872
+ if (relation.kind !== 'parent-child' || relation.parentTaskId === null) continue;
873
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
874
+ children.push(relation.childTaskId);
875
+ childrenByParent.set(relation.parentTaskId, children);
876
+ }
877
+ for (let iteration = 0; iteration <= tasks.length * 2; iteration += 1) {
878
+ let changed = false;
879
+ const next = tasks.map((task) => {
880
+ let status = task.status;
881
+ if (task.definition.kind === 'task') {
882
+ if (status === 'Pending' || status === 'Ready') {
883
+ status = dependenciesSucceeded(definition, tasks, task.taskId) ? 'Ready' : 'Pending';
884
+ }
885
+ } else {
886
+ const childStatuses = (childrenByParent.get(task.taskId) ?? [])
887
+ .map((childId) => tasks.find(({ taskId }) => taskId === childId)!.status);
888
+ status = projectGroupStatus(task.definition.mode, childStatuses,
889
+ dependenciesSucceeded(definition, tasks, task.taskId));
890
+ if (isTerminal(task.status) && status !== task.status) {
891
+ throw new TaskManagerError('terminal-task-immutable', `task-group ${task.taskId} is terminal (${task.status})`);
892
+ }
893
+ }
894
+ if (status === task.status) return task;
895
+ changed = true;
896
+ return { ...task, status, ...(projectedAt === undefined ? {} : { updatedAt: projectedAt }) };
897
+ });
898
+ tasks = next;
899
+ if (!changed) return tasks;
900
+ }
901
+ throw new TaskManagerError('cyclic-task-forest', 'TaskGroup projection did not converge');
902
+ }
903
+
904
+ function projectGroupStatus(
905
+ mode: 'sequential' | 'all' | 'any',
906
+ childStatuses: readonly TaskStatus[],
907
+ dependenciesAreSatisfied: boolean,
908
+ ): TaskStatus {
909
+ if (!dependenciesAreSatisfied) return 'Pending';
910
+ if (mode === 'any') {
911
+ if (childStatuses.some((status) => status === 'Succeeded')) return 'Succeeded';
912
+ if (childStatuses.every(isTerminal)) return 'Failed';
913
+ } else {
914
+ if (childStatuses.some((status) => status === 'Failed' || status === 'Cancelled')) return 'Failed';
915
+ if (childStatuses.every((status) => status === 'Succeeded')) return 'Succeeded';
916
+ }
917
+ if (childStatuses.some((status) => status === 'Claimed' || status === 'Running')) return 'Running';
918
+ if (childStatuses.some((status) => status === 'Waiting')) return 'Waiting';
919
+ if (childStatuses.some((status) => status === 'Ready')) return 'Ready';
920
+ return 'Pending';
921
+ }
package/src/validation.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type {
2
+ TaskArtifactBody,
3
+ TaskArtifactRef,
2
4
  TaskClaimToken,
3
5
  TaskDefinition,
4
6
  TaskId,
@@ -10,6 +12,7 @@ import type {
10
12
  TaskSpaceSnapshot,
11
13
  TaskTimestamp,
12
14
  } from 'task-manager-contract';
15
+ import { deepFrozenCanonicalOwnDataClone, sha256Hex } from 'task-manager-contract';
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,33 @@ 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 authorityEdges = new Map<TaskId, TaskId[]>();
108
+ const addAuthorityEdge = (from: TaskId, to: TaskId): void => {
109
+ const outgoing = authorityEdges.get(from) ?? [];
110
+ outgoing.push(to);
111
+ authorityEdges.set(from, outgoing);
112
+ };
113
+ for (const [parent, children] of childrenByParent) {
114
+ for (const child of children) addAuthorityEdge(child, parent);
115
+ }
116
+ const descendants = (taskId: TaskId): TaskId[] => (childrenByParent.get(taskId) ?? [])
117
+ .flatMap((child) => [child, ...descendants(child)]);
118
+ for (const relation of definition.relations) {
119
+ if (relation.kind !== 'dependency') continue;
120
+ addAuthorityEdge(relation.predecessorTaskId, relation.successorTaskId);
121
+ if (tasks.get(relation.successorTaskId)?.kind === 'task-group') {
122
+ for (const descendant of descendants(relation.successorTaskId)) {
123
+ addAuthorityEdge(relation.predecessorTaskId, descendant);
124
+ }
125
+ }
126
+ }
127
+ assertAcyclic(tasks.keys(), (id) => authorityEdges.get(id) ?? []);
94
128
  }
95
129
 
96
130
  export function taskDefinitionById(
@@ -160,7 +194,11 @@ export function sameClaim(left: TaskClaimToken, right: TaskClaimToken): boolean
160
194
  }
161
195
 
162
196
  export function deepImmutableClone<Value>(value: Value): Value {
163
- return deepFreeze(structuredClone(value));
197
+ try {
198
+ return deepFrozenCanonicalOwnDataClone(value);
199
+ } catch (error) {
200
+ throw new TaskManagerError('canonical-data-invalid', String(error));
201
+ }
164
202
  }
165
203
 
166
204
  function assertTaskDefinition(task: TaskNodeDefinition): asserts task is TaskDefinition | Exclude<TaskNodeDefinition, TaskDefinition> {
@@ -171,13 +209,32 @@ function assertTaskDefinition(task: TaskNodeDefinition): asserts task is TaskDef
171
209
  invalid(`task-group ${task.taskId} has invalid mode`);
172
210
  }
173
211
  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
- }
212
+ for (const artifact of task.inputArtifacts) assertArtifactRef(artifact, `task ${task.taskId} input artifact`);
213
+ }
214
+
215
+ export function assertArtifactRef(artifact: TaskArtifactRef, field: string): void {
216
+ if (artifact.kind !== 'task-artifact-ref' || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest)) {
217
+ invalid(`${field} has invalid digest`);
218
+ }
219
+ assertNonEmpty(artifact.mediaType, `${field} mediaType`);
220
+ if (!Number.isSafeInteger(artifact.sizeBytes) || artifact.sizeBytes < 0) invalid(`${field} has invalid size`);
221
+ if (artifact.name !== undefined) assertNonEmpty(artifact.name, `${field} name`);
222
+ }
223
+
224
+ export function assertArtifactBody(body: TaskArtifactBody, field: string): void {
225
+ if (body.kind !== 'task-artifact-body' || body.encoding !== 'base64') invalid(`${field} has invalid encoding`);
226
+ assertNonEmpty(body.mediaType, `${field} mediaType`);
227
+ if (!Number.isSafeInteger(body.sizeBytes) || body.sizeBytes < 0) invalid(`${field} has invalid size`);
228
+ let binary: string;
229
+ try {
230
+ binary = atob(body.data);
231
+ } catch {
232
+ invalid(`${field} has invalid base64`);
233
+ }
234
+ if (btoa(binary) !== body.data) invalid(`${field} base64 is not canonical`);
235
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
236
+ if (bytes.byteLength !== body.sizeBytes || `sha256:${sha256Hex(bytes)}` !== body.digest) {
237
+ invalid(`${field} digest or size mismatch`);
181
238
  }
182
239
  }
183
240
 
@@ -219,12 +276,3 @@ function invalid(message: string): never {
219
276
  function cyclic(message: string): never {
220
277
  throw new TaskManagerError('cyclic-task-forest', message);
221
278
  }
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
-