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 +2 -2
- package/src/errors.ts +2 -1
- package/src/memory-owner.ts +36 -5
- package/src/processors.ts +199 -25
- package/src/validation.ts +65 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-manager-logic",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
|
package/src/memory-owner.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
|
|
559
|
-
|
|
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, ...
|
|
601
|
-
issuedReceiptIds: [...prior.issuedReceiptIds,
|
|
602
|
-
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|