task-manager-logic 0.1.3 → 0.1.5
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 +1 -1
- package/src/completionGraph.ts +88 -0
- package/src/index.ts +1 -0
- package/src/memory-owner.ts +11 -81
- package/src/owner-admission.ts +189 -0
- package/src/processors.ts +16 -38
- package/src/statusMachine.ts +3 -3
- package/src/validation.ts +4 -26
package/package.json
CHANGED
|
@@ -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/index.ts
CHANGED
package/src/memory-owner.ts
CHANGED
|
@@ -10,9 +10,12 @@ import type {
|
|
|
10
10
|
TaskSpaceOwnerPort,
|
|
11
11
|
TaskSpaceSnapshot,
|
|
12
12
|
} from 'task-manager-contract';
|
|
13
|
-
import { canonicalOwnDataDigest } from 'task-manager-contract';
|
|
14
13
|
import { TaskManagerError } from './errors.js';
|
|
15
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
validateTaskSpaceCommitAdmission,
|
|
16
|
+
validateTaskSpaceCommitCommand,
|
|
17
|
+
} from './owner-admission.js';
|
|
18
|
+
import { deepImmutableClone } from './validation.js';
|
|
16
19
|
|
|
17
20
|
interface MemoryTaskSpaceState {
|
|
18
21
|
readonly snapshot: TaskSpaceSnapshot;
|
|
@@ -52,10 +55,11 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
|
|
|
52
55
|
|
|
53
56
|
async compareAndSwap(commit: TaskSpaceCommit): Promise<TaskSpaceCommitResult> {
|
|
54
57
|
commit = deepImmutableClone(commit);
|
|
58
|
+
const submittedCommandDigest = validateTaskSpaceCommitCommand(commit);
|
|
55
59
|
const current = this.#spaces.get(commit.taskSpaceId);
|
|
56
60
|
const replay = current?.receiptsByCommand.get(commit.commandId);
|
|
57
61
|
if (current && replay) {
|
|
58
|
-
if (replay.commandDigest !==
|
|
62
|
+
if (replay.commandDigest !== submittedCommandDigest) {
|
|
59
63
|
throw new TaskManagerError('command-receipt-mismatch', `command ${commit.commandId} digest does not match prior admission`);
|
|
60
64
|
}
|
|
61
65
|
return deepImmutableClone({
|
|
@@ -77,7 +81,10 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
|
|
|
77
81
|
});
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
const receipt =
|
|
84
|
+
const receipt = validateTaskSpaceCommitAdmission(commit, {
|
|
85
|
+
...(current === undefined ? {} : { currentSnapshot: current.snapshot }),
|
|
86
|
+
existingArtifacts: current?.artifacts ?? new Map(),
|
|
87
|
+
});
|
|
81
88
|
const receiptsByCommand = new Map(current?.receiptsByCommand ?? []);
|
|
82
89
|
for (const candidate of commit.receipts) receiptsByCommand.set(candidate.commandId, deepImmutableClone(candidate));
|
|
83
90
|
const artifacts = new Map(current?.artifacts ?? []);
|
|
@@ -105,79 +112,6 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
|
|
|
105
112
|
}
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
function validateCommit(
|
|
109
|
-
commit: TaskSpaceCommit,
|
|
110
|
-
current: MemoryTaskSpaceState | undefined,
|
|
111
|
-
): TaskCommandReceipt {
|
|
112
|
-
const expectedNextRevision = commit.expectedRevision === null ? 0 : commit.expectedRevision + 1;
|
|
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
|
|
117
|
-
|| commit.snapshot.taskSpaceId !== commit.taskSpaceId
|
|
118
|
-
|| commit.snapshot.definition.taskSpaceId !== commit.taskSpaceId
|
|
119
|
-
|| commit.snapshot.revision !== expectedNextRevision) {
|
|
120
|
-
invalid('commit snapshot identity or revision is inconsistent');
|
|
121
|
-
}
|
|
122
|
-
if (commit.receipts.length !== 1) invalid('one command admission must contain exactly one receipt');
|
|
123
|
-
const receipt = commit.receipts[0];
|
|
124
|
-
if (!receipt
|
|
125
|
-
|| receipt.commandId !== commit.commandId
|
|
126
|
-
|| receipt.commandDigest !== commit.commandDigest
|
|
127
|
-
|| receipt.taskSpaceId !== commit.taskSpaceId) {
|
|
128
|
-
invalid('commit receipt does not match command identity');
|
|
129
|
-
}
|
|
130
|
-
if (receipt.toRevision !== expectedNextRevision) invalid('receipt toRevision does not match snapshot');
|
|
131
|
-
if (receipt.fromRevision !== (commit.expectedRevision ?? 0)) invalid('receipt fromRevision does not match CAS');
|
|
132
|
-
|
|
133
|
-
const priorHistoryIds = current?.snapshot.historyEventIds ?? [];
|
|
134
|
-
const expectedHistoryIds = [...priorHistoryIds, ...commit.events.map(({ eventId }) => eventId)];
|
|
135
|
-
if (!sameStrings(commit.snapshot.historyEventIds, expectedHistoryIds)) invalid('snapshot history is not append-only');
|
|
136
|
-
const priorReceiptIds = current?.snapshot.issuedReceiptIds ?? [];
|
|
137
|
-
const expectedReceiptIds = [...priorReceiptIds, ...commit.receipts.map(({ receiptId }) => receiptId)];
|
|
138
|
-
if (!sameStrings(commit.snapshot.issuedReceiptIds, expectedReceiptIds)) invalid('snapshot receipt history is not append-only');
|
|
139
|
-
|
|
140
|
-
const seenEvents = new Set(priorHistoryIds);
|
|
141
|
-
for (let index = 0; index < commit.events.length; index += 1) {
|
|
142
|
-
const event = commit.events[index]!;
|
|
143
|
-
if (event.taskSpaceId !== commit.taskSpaceId
|
|
144
|
-
|| event.commandId !== commit.commandId
|
|
145
|
-
|| event.sequence !== priorHistoryIds.length + index
|
|
146
|
-
|| event.fromRevision !== (commit.expectedRevision ?? 0)
|
|
147
|
-
|| event.toRevision !== expectedNextRevision
|
|
148
|
-
|| seenEvents.has(event.eventId)) {
|
|
149
|
-
invalid(`event ${event.eventId} violates append-only commit identity`);
|
|
150
|
-
}
|
|
151
|
-
seenEvents.add(event.eventId);
|
|
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
|
-
}
|
|
174
|
-
return receipt;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
|
178
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
115
|
function sameArtifact(left: TaskArtifactBody, right: TaskArtifactBody): boolean {
|
|
182
116
|
return left.digest === right.digest
|
|
183
117
|
&& left.mediaType === right.mediaType
|
|
@@ -185,7 +119,3 @@ function sameArtifact(left: TaskArtifactBody, right: TaskArtifactBody): boolean
|
|
|
185
119
|
&& left.encoding === right.encoding
|
|
186
120
|
&& left.data === right.data;
|
|
187
121
|
}
|
|
188
|
-
|
|
189
|
-
function invalid(message: string): never {
|
|
190
|
-
throw new TaskManagerError('invalid-commit', message);
|
|
191
|
-
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TaskArtifactBody,
|
|
3
|
+
TaskArtifactDigest,
|
|
4
|
+
TaskArtifactRef,
|
|
5
|
+
TaskCommandDigest,
|
|
6
|
+
TaskCommandReceipt,
|
|
7
|
+
TaskSpaceCommit,
|
|
8
|
+
TaskSpaceSnapshot,
|
|
9
|
+
} from 'task-manager-contract';
|
|
10
|
+
import { canonicalOwnDataDigest } from 'task-manager-contract';
|
|
11
|
+
import { TaskManagerError } from './errors.js';
|
|
12
|
+
import { assertArtifactBody, assertArtifactRef } from './validation.js';
|
|
13
|
+
|
|
14
|
+
export interface TaskSpaceCommitAdmissionContext {
|
|
15
|
+
readonly currentSnapshot?: TaskSpaceSnapshot;
|
|
16
|
+
readonly existingArtifacts: ReadonlyMap<TaskArtifactDigest, TaskArtifactBody>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validates the command envelope before either owner performs replay lookup.
|
|
21
|
+
* This keeps replay authentication identical across backend adapters.
|
|
22
|
+
*/
|
|
23
|
+
export function validateTaskSpaceCommitCommand(commit: TaskSpaceCommit): TaskCommandDigest {
|
|
24
|
+
if (commit.kind !== 'task-space-commit'
|
|
25
|
+
|| commit.command.commandId !== commit.commandId
|
|
26
|
+
|| commit.command.taskSpaceId !== commit.taskSpaceId) {
|
|
27
|
+
throw new TaskManagerError(
|
|
28
|
+
'command-receipt-mismatch',
|
|
29
|
+
'submitted command identity does not match its CAS envelope',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const commandDigest = canonicalOwnDataDigest(commit.command);
|
|
33
|
+
if (commandDigest !== commit.commandDigest) {
|
|
34
|
+
throw new TaskManagerError(
|
|
35
|
+
'command-receipt-mismatch',
|
|
36
|
+
`command ${commit.commandId} digest does not match the submitted canonical command`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return commandDigest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Backend-neutral authority for a non-replay TaskSpace CAS admission.
|
|
44
|
+
* Storage implementations add only their own lock, containment, material and
|
|
45
|
+
* recovery checks around this shared identity/append-only/artifact validator.
|
|
46
|
+
*/
|
|
47
|
+
export function validateTaskSpaceCommitAdmission(
|
|
48
|
+
commit: TaskSpaceCommit,
|
|
49
|
+
context: TaskSpaceCommitAdmissionContext,
|
|
50
|
+
): TaskCommandReceipt {
|
|
51
|
+
const commandDigest = validateTaskSpaceCommitCommand(commit);
|
|
52
|
+
const current = context.currentSnapshot;
|
|
53
|
+
const nextRevision = commit.expectedRevision === null ? 0 : commit.expectedRevision + 1;
|
|
54
|
+
if (commit.snapshot.taskSpaceId !== commit.taskSpaceId
|
|
55
|
+
|| commit.snapshot.definition.taskSpaceId !== commit.taskSpaceId
|
|
56
|
+
|| commit.snapshot.revision !== nextRevision) {
|
|
57
|
+
invalid('snapshot identity or revision disagrees with CAS');
|
|
58
|
+
}
|
|
59
|
+
assertSnapshotIdentity(commit.snapshot);
|
|
60
|
+
|
|
61
|
+
if (commit.receipts.length !== 1) invalid('one CAS command must issue exactly one receipt');
|
|
62
|
+
const receipt = commit.receipts[0]!;
|
|
63
|
+
if (receipt.commandId !== commit.commandId
|
|
64
|
+
|| receipt.commandDigest !== commandDigest
|
|
65
|
+
|| receipt.taskSpaceId !== commit.taskSpaceId
|
|
66
|
+
|| receipt.fromRevision !== (commit.expectedRevision ?? 0)
|
|
67
|
+
|| receipt.toRevision !== nextRevision) {
|
|
68
|
+
invalid('receipt identity or revision disagrees with CAS');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const priorHistoryIds = current?.historyEventIds ?? [];
|
|
72
|
+
const priorReceiptIds = current?.issuedReceiptIds ?? [];
|
|
73
|
+
const eventIds = commit.events.map(({ eventId }) => eventId);
|
|
74
|
+
if (!sameStrings(commit.snapshot.historyEventIds, [...priorHistoryIds, ...eventIds])) {
|
|
75
|
+
invalid('history prefix was rewritten');
|
|
76
|
+
}
|
|
77
|
+
if (!sameStrings(commit.snapshot.issuedReceiptIds, [...priorReceiptIds, receipt.receiptId])) {
|
|
78
|
+
invalid('receipt prefix was rewritten');
|
|
79
|
+
}
|
|
80
|
+
if (priorReceiptIds.includes(receipt.receiptId)) invalid(`receipt ${receipt.receiptId} is not append-only`);
|
|
81
|
+
if (!sameStrings(receipt.eventIds, eventIds)) invalid('receipt does not name the admitted event set');
|
|
82
|
+
|
|
83
|
+
const seenEvents = new Set(priorHistoryIds);
|
|
84
|
+
commit.events.forEach((event, index) => {
|
|
85
|
+
if (event.taskSpaceId !== commit.taskSpaceId
|
|
86
|
+
|| event.commandId !== commit.commandId
|
|
87
|
+
|| event.sequence !== priorHistoryIds.length + index
|
|
88
|
+
|| event.fromRevision !== (commit.expectedRevision ?? 0)
|
|
89
|
+
|| event.toRevision !== nextRevision
|
|
90
|
+
|| seenEvents.has(event.eventId)) {
|
|
91
|
+
invalid(`event ${event.eventId} violates append-only admission`);
|
|
92
|
+
}
|
|
93
|
+
seenEvents.add(event.eventId);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
validateArtifactAuthority(commit, context);
|
|
97
|
+
return receipt;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function taskArtifactRefsOfSnapshot(snapshot: TaskSpaceSnapshot): readonly TaskArtifactRef[] {
|
|
101
|
+
return [
|
|
102
|
+
...snapshot.definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts),
|
|
103
|
+
...snapshot.tasks.flatMap(({ outputArtifacts }) => outputArtifacts),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertSnapshotIdentity(snapshot: TaskSpaceSnapshot): void {
|
|
108
|
+
if (snapshot.kind !== 'task-space-snapshot') invalid('snapshot kind is invalid');
|
|
109
|
+
const definitions = new Map(snapshot.definition.tasks.map((task) => [task.taskId, task]));
|
|
110
|
+
if (definitions.size !== snapshot.definition.tasks.length || snapshot.tasks.length !== definitions.size) {
|
|
111
|
+
invalid('snapshot tree/task identity is invalid');
|
|
112
|
+
}
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
for (const task of snapshot.tasks) {
|
|
115
|
+
const definition = definitions.get(task.taskId);
|
|
116
|
+
if (task.taskSpaceId !== snapshot.taskSpaceId
|
|
117
|
+
|| !definition
|
|
118
|
+
|| seen.has(task.taskId)
|
|
119
|
+
|| canonicalOwnDataDigest(definition) !== canonicalOwnDataDigest(task.definition)) {
|
|
120
|
+
invalid(`snapshot task ${task.taskId} disagrees with its tree definition`);
|
|
121
|
+
}
|
|
122
|
+
seen.add(task.taskId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateArtifactAuthority(
|
|
127
|
+
commit: TaskSpaceCommit,
|
|
128
|
+
context: TaskSpaceCommitAdmissionContext,
|
|
129
|
+
): void {
|
|
130
|
+
const availableArtifacts = new Map(context.existingArtifacts);
|
|
131
|
+
for (const body of commit.artifacts) {
|
|
132
|
+
assertArtifactBody(body, `artifact body ${body.digest}`);
|
|
133
|
+
const prior = availableArtifacts.get(body.digest);
|
|
134
|
+
if (prior && canonicalOwnDataDigest(prior) !== canonicalOwnDataDigest(body)) {
|
|
135
|
+
conflict(`artifact ${body.digest} has conflicting content`);
|
|
136
|
+
}
|
|
137
|
+
availableArtifacts.set(body.digest, body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const priorRefs = refsByDigest(context.currentSnapshot ? taskArtifactRefsOfSnapshot(context.currentSnapshot) : []);
|
|
141
|
+
const nextRefs = refsByDigest(taskArtifactRefsOfSnapshot(commit.snapshot));
|
|
142
|
+
for (const [digest, nextRef] of nextRefs) {
|
|
143
|
+
const priorRef = priorRefs.get(digest);
|
|
144
|
+
if (priorRef && !sameArtifactRef(priorRef, nextRef)) {
|
|
145
|
+
conflict(`artifact ${digest} conflicts with the previously admitted reference`);
|
|
146
|
+
}
|
|
147
|
+
const body = availableArtifacts.get(digest);
|
|
148
|
+
if (!body) {
|
|
149
|
+
throw new TaskManagerError('artifact-not-found', `artifact ${digest} is not present in owner CAS`);
|
|
150
|
+
}
|
|
151
|
+
if (body.digest !== nextRef.digest
|
|
152
|
+
|| body.mediaType !== nextRef.mediaType
|
|
153
|
+
|| body.sizeBytes !== nextRef.sizeBytes) {
|
|
154
|
+
conflict(`artifact ${digest} metadata disagrees with owner body`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function refsByDigest(refs: readonly TaskArtifactRef[]): Map<TaskArtifactDigest, TaskArtifactRef> {
|
|
160
|
+
const result = new Map<TaskArtifactDigest, TaskArtifactRef>();
|
|
161
|
+
for (const ref of refs) {
|
|
162
|
+
assertArtifactRef(ref, `artifact ref ${ref.digest}`);
|
|
163
|
+
const prior = result.get(ref.digest);
|
|
164
|
+
if (prior && !sameArtifactRef(prior, ref)) {
|
|
165
|
+
conflict(`artifact ${ref.digest} has conflicting complete references`);
|
|
166
|
+
}
|
|
167
|
+
result.set(ref.digest, ref);
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sameArtifactRef(left: TaskArtifactRef, right: TaskArtifactRef): boolean {
|
|
173
|
+
return left.digest === right.digest
|
|
174
|
+
&& left.name === right.name
|
|
175
|
+
&& left.mediaType === right.mediaType
|
|
176
|
+
&& left.sizeBytes === right.sizeBytes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
|
180
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function invalid(message: string): never {
|
|
184
|
+
throw new TaskManagerError('invalid-commit', message);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function conflict(message: string): never {
|
|
188
|
+
throw new TaskManagerError('artifact-conflict', message);
|
|
189
|
+
}
|
package/src/processors.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
WaitTaskCommand,
|
|
32
32
|
} from 'task-manager-contract';
|
|
33
33
|
import { canonicalOwnDataDigest } from 'task-manager-contract';
|
|
34
|
+
import { buildTaskCompletionGraph, type TaskCompletionGraph } from './completionGraph.js';
|
|
34
35
|
import { TaskManagerError } from './errors.js';
|
|
35
36
|
import { assertTaskTransition, isTerminal } from './statusMachine.js';
|
|
36
37
|
import {
|
|
@@ -40,7 +41,6 @@ import {
|
|
|
40
41
|
assertArtifactRef,
|
|
41
42
|
deepImmutableClone,
|
|
42
43
|
normalizeTaskProcessorConfig,
|
|
43
|
-
predecessorIds,
|
|
44
44
|
taskRecordById,
|
|
45
45
|
validateTaskSpaceDefinition,
|
|
46
46
|
validateTimestamp,
|
|
@@ -144,10 +144,16 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
|
|
|
144
144
|
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
145
145
|
};
|
|
146
146
|
} else {
|
|
147
|
-
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
|
+
}
|
|
148
154
|
nextDefinition = {
|
|
149
155
|
...current.definition,
|
|
150
|
-
inputArtifacts:
|
|
156
|
+
inputArtifacts: priorArtifact
|
|
151
157
|
? current.definition.inputArtifacts
|
|
152
158
|
: [...current.definition.inputArtifacts, patch.artifact],
|
|
153
159
|
};
|
|
@@ -518,9 +524,10 @@ export async function replanTask(
|
|
|
518
524
|
updatedAt: input.replannedAt,
|
|
519
525
|
};
|
|
520
526
|
});
|
|
527
|
+
const completionGraph = buildTaskCompletionGraph(definition);
|
|
521
528
|
const nextTasks: TaskRecord[] = provisional.map((task) => {
|
|
522
529
|
if (isTerminal(task.status) || task.activeClaim || task.status === 'Running' || task.status === 'Waiting') return task;
|
|
523
|
-
const status: TaskStatus = dependenciesSucceeded(
|
|
530
|
+
const status: TaskStatus = dependenciesSucceeded(completionGraph, provisional, task.taskId) ? 'Ready' : 'Pending';
|
|
524
531
|
return { ...task, status };
|
|
525
532
|
});
|
|
526
533
|
const affectedTaskIds = [...new Set([...snapshot.tasks.map(({ taskId }) => taskId), ...nextTasks.map(({ taskId }) => taskId)])].sort();
|
|
@@ -708,11 +715,11 @@ function replaceTask(snapshot: TaskSpaceSnapshot, replacement: TaskRecord): Task
|
|
|
708
715
|
}
|
|
709
716
|
|
|
710
717
|
function dependenciesSucceeded(
|
|
711
|
-
|
|
718
|
+
completionGraph: TaskCompletionGraph,
|
|
712
719
|
tasks: readonly TaskRecord[],
|
|
713
720
|
taskId: TaskId,
|
|
714
721
|
): boolean {
|
|
715
|
-
const predecessors =
|
|
722
|
+
const predecessors = completionGraph.predecessorIdsByTask.get(taskId) ?? [];
|
|
716
723
|
return predecessors.length === 0 || predecessors.every((predecessorId) =>
|
|
717
724
|
tasks.find((task) => task.taskId === predecessorId)?.status === 'Succeeded');
|
|
718
725
|
}
|
|
@@ -831,41 +838,12 @@ async function assertArtifactAuthority(
|
|
|
831
838
|
}
|
|
832
839
|
}
|
|
833
840
|
|
|
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
841
|
function stabilizeTaskProjection(
|
|
865
842
|
definition: TaskSpaceDefinition,
|
|
866
843
|
inputTasks: readonly TaskRecord[],
|
|
867
844
|
projectedAt?: TaskTimestamp,
|
|
868
845
|
): TaskRecord[] {
|
|
846
|
+
const completionGraph = buildTaskCompletionGraph(definition);
|
|
869
847
|
let tasks = inputTasks.map((task) => ({ ...task }));
|
|
870
848
|
const childrenByParent = new Map<TaskId, TaskId[]>();
|
|
871
849
|
for (const relation of definition.relations) {
|
|
@@ -880,13 +858,13 @@ function stabilizeTaskProjection(
|
|
|
880
858
|
let status = task.status;
|
|
881
859
|
if (task.definition.kind === 'task') {
|
|
882
860
|
if (status === 'Pending' || status === 'Ready') {
|
|
883
|
-
status = dependenciesSucceeded(
|
|
861
|
+
status = dependenciesSucceeded(completionGraph, tasks, task.taskId) ? 'Ready' : 'Pending';
|
|
884
862
|
}
|
|
885
863
|
} else {
|
|
886
864
|
const childStatuses = (childrenByParent.get(task.taskId) ?? [])
|
|
887
865
|
.map((childId) => tasks.find(({ taskId }) => taskId === childId)!.status);
|
|
888
866
|
status = projectGroupStatus(task.definition.mode, childStatuses,
|
|
889
|
-
dependenciesSucceeded(
|
|
867
|
+
dependenciesSucceeded(completionGraph, tasks, task.taskId));
|
|
890
868
|
if (isTerminal(task.status) && status !== task.status) {
|
|
891
869
|
throw new TaskManagerError('terminal-task-immutable', `task-group ${task.taskId} is terminal (${task.status})`);
|
|
892
870
|
}
|
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
|
@@ -7,12 +7,12 @@ import type {
|
|
|
7
7
|
TaskNodeDefinition,
|
|
8
8
|
TaskProfileEnvelope,
|
|
9
9
|
TaskRecord,
|
|
10
|
-
TaskRelation,
|
|
11
10
|
TaskSpaceDefinition,
|
|
12
11
|
TaskSpaceSnapshot,
|
|
13
12
|
TaskTimestamp,
|
|
14
13
|
} from 'task-manager-contract';
|
|
15
14
|
import { deepFrozenCanonicalOwnDataClone, sha256Hex } from 'task-manager-contract';
|
|
15
|
+
import { buildTaskCompletionGraph } from './completionGraph.js';
|
|
16
16
|
import { TaskManagerError } from './errors.js';
|
|
17
17
|
|
|
18
18
|
export interface TaskProcessorConfig {
|
|
@@ -104,27 +104,8 @@ export function validateTaskSpaceDefinition(
|
|
|
104
104
|
invalid(`task-group ${task.taskId} must contain at least one child`);
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
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) ?? []);
|
|
107
|
+
const completionGraph = buildTaskCompletionGraph(definition);
|
|
108
|
+
assertAcyclic(tasks.keys(), (id) => completionGraph.authoritySuccessorIdsByTask.get(id) ?? []);
|
|
128
109
|
}
|
|
129
110
|
|
|
130
111
|
export function taskDefinitionById(
|
|
@@ -139,10 +120,7 @@ export function taskRecordById(snapshot: TaskSpaceSnapshot, taskId: TaskId): Tas
|
|
|
139
120
|
}
|
|
140
121
|
|
|
141
122
|
export function predecessorIds(definition: TaskSpaceDefinition, taskId: TaskId): readonly TaskId[] {
|
|
142
|
-
return definition.
|
|
143
|
-
.filter((relation): relation is Extract<TaskRelation, { kind: 'dependency' }> =>
|
|
144
|
-
relation.kind === 'dependency' && relation.successorTaskId === taskId)
|
|
145
|
-
.map((relation) => relation.predecessorTaskId);
|
|
123
|
+
return buildTaskCompletionGraph(definition).predecessorIdsByTask.get(taskId) ?? [];
|
|
146
124
|
}
|
|
147
125
|
|
|
148
126
|
export function validateTimestamp(value: TaskTimestamp, field: string): number {
|