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 +2 -2
- package/src/completionGraph.ts +88 -0
- package/src/errors.ts +2 -1
- package/src/memory-owner.ts +53 -5
- package/src/processors.ts +182 -30
- package/src/statusMachine.ts +3 -3
- package/src/validation.ts +48 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-manager-logic",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
|
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,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
|
|
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
|
|
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,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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
559
|
-
|
|
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, ...
|
|
601
|
-
issuedReceiptIds: [...prior.issuedReceiptIds,
|
|
602
|
-
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,
|
|
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
|
-
|
|
718
|
+
completionGraph: TaskCompletionGraph,
|
|
657
719
|
tasks: readonly TaskRecord[],
|
|
658
720
|
taskId: TaskId,
|
|
659
721
|
): boolean {
|
|
660
|
-
const predecessors =
|
|
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
|
+
}
|
package/src/statusMachine.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|