task-manager-logic 0.1.4 → 0.1.6
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/index.ts +1 -0
- package/src/memory-owner.ts +10 -97
- package/src/owner-admission.ts +189 -0
- package/src/processors.ts +35 -6
package/package.json
CHANGED
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,7 +55,7 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
|
|
|
52
55
|
|
|
53
56
|
async compareAndSwap(commit: TaskSpaceCommit): Promise<TaskSpaceCommitResult> {
|
|
54
57
|
commit = deepImmutableClone(commit);
|
|
55
|
-
const submittedCommandDigest =
|
|
58
|
+
const submittedCommandDigest = validateTaskSpaceCommitCommand(commit);
|
|
56
59
|
const current = this.#spaces.get(commit.taskSpaceId);
|
|
57
60
|
const replay = current?.receiptsByCommand.get(commit.commandId);
|
|
58
61
|
if (current && replay) {
|
|
@@ -78,7 +81,10 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
|
|
|
78
81
|
});
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
const receipt =
|
|
84
|
+
const receipt = validateTaskSpaceCommitAdmission(commit, {
|
|
85
|
+
...(current === undefined ? {} : { currentSnapshot: current.snapshot }),
|
|
86
|
+
existingArtifacts: current?.artifacts ?? new Map(),
|
|
87
|
+
});
|
|
82
88
|
const receiptsByCommand = new Map(current?.receiptsByCommand ?? []);
|
|
83
89
|
for (const candidate of commit.receipts) receiptsByCommand.set(candidate.commandId, deepImmutableClone(candidate));
|
|
84
90
|
const artifacts = new Map(current?.artifacts ?? []);
|
|
@@ -106,95 +112,6 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
|
|
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
|
-
|
|
125
|
-
function validateCommit(
|
|
126
|
-
commit: TaskSpaceCommit,
|
|
127
|
-
current: MemoryTaskSpaceState | undefined,
|
|
128
|
-
): TaskCommandReceipt {
|
|
129
|
-
const expectedNextRevision = commit.expectedRevision === null ? 0 : commit.expectedRevision + 1;
|
|
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
|
|
134
|
-
|| commit.snapshot.taskSpaceId !== commit.taskSpaceId
|
|
135
|
-
|| commit.snapshot.definition.taskSpaceId !== commit.taskSpaceId
|
|
136
|
-
|| commit.snapshot.revision !== expectedNextRevision) {
|
|
137
|
-
invalid('commit snapshot identity or revision is inconsistent');
|
|
138
|
-
}
|
|
139
|
-
if (commit.receipts.length !== 1) invalid('one command admission must contain exactly one receipt');
|
|
140
|
-
const receipt = commit.receipts[0];
|
|
141
|
-
if (!receipt
|
|
142
|
-
|| receipt.commandId !== commit.commandId
|
|
143
|
-
|| receipt.commandDigest !== commit.commandDigest
|
|
144
|
-
|| receipt.taskSpaceId !== commit.taskSpaceId) {
|
|
145
|
-
invalid('commit receipt does not match command identity');
|
|
146
|
-
}
|
|
147
|
-
if (receipt.toRevision !== expectedNextRevision) invalid('receipt toRevision does not match snapshot');
|
|
148
|
-
if (receipt.fromRevision !== (commit.expectedRevision ?? 0)) invalid('receipt fromRevision does not match CAS');
|
|
149
|
-
|
|
150
|
-
const priorHistoryIds = current?.snapshot.historyEventIds ?? [];
|
|
151
|
-
const expectedHistoryIds = [...priorHistoryIds, ...commit.events.map(({ eventId }) => eventId)];
|
|
152
|
-
if (!sameStrings(commit.snapshot.historyEventIds, expectedHistoryIds)) invalid('snapshot history is not append-only');
|
|
153
|
-
const priorReceiptIds = current?.snapshot.issuedReceiptIds ?? [];
|
|
154
|
-
const expectedReceiptIds = [...priorReceiptIds, ...commit.receipts.map(({ receiptId }) => receiptId)];
|
|
155
|
-
if (!sameStrings(commit.snapshot.issuedReceiptIds, expectedReceiptIds)) invalid('snapshot receipt history is not append-only');
|
|
156
|
-
|
|
157
|
-
const seenEvents = new Set(priorHistoryIds);
|
|
158
|
-
for (let index = 0; index < commit.events.length; index += 1) {
|
|
159
|
-
const event = commit.events[index]!;
|
|
160
|
-
if (event.taskSpaceId !== commit.taskSpaceId
|
|
161
|
-
|| event.commandId !== commit.commandId
|
|
162
|
-
|| event.sequence !== priorHistoryIds.length + index
|
|
163
|
-
|| event.fromRevision !== (commit.expectedRevision ?? 0)
|
|
164
|
-
|| event.toRevision !== expectedNextRevision
|
|
165
|
-
|| seenEvents.has(event.eventId)) {
|
|
166
|
-
invalid(`event ${event.eventId} violates append-only commit identity`);
|
|
167
|
-
}
|
|
168
|
-
seenEvents.add(event.eventId);
|
|
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
|
-
}
|
|
191
|
-
return receipt;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
|
195
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
115
|
function sameArtifact(left: TaskArtifactBody, right: TaskArtifactBody): boolean {
|
|
199
116
|
return left.digest === right.digest
|
|
200
117
|
&& left.mediaType === right.mediaType
|
|
@@ -202,7 +119,3 @@ function sameArtifact(left: TaskArtifactBody, right: TaskArtifactBody): boolean
|
|
|
202
119
|
&& left.encoding === right.encoding
|
|
203
120
|
&& left.data === right.data;
|
|
204
121
|
}
|
|
205
|
-
|
|
206
|
-
function invalid(message: string): never {
|
|
207
|
-
throw new TaskManagerError('invalid-commit', message);
|
|
208
|
-
}
|
|
@@ -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
|
@@ -497,15 +497,10 @@ export async function replanTask(
|
|
|
497
497
|
tasks: input.plan.tasks,
|
|
498
498
|
relations: input.plan.relations,
|
|
499
499
|
};
|
|
500
|
+
assertReplanDurableTasksImmutable(snapshot, definition);
|
|
500
501
|
validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
|
|
501
502
|
const artifactBodies = input.artifactBodies ?? [];
|
|
502
503
|
await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
|
|
503
|
-
const nextIds = new Set(definition.tasks.map(({ taskId }) => taskId));
|
|
504
|
-
for (const prior of snapshot.tasks) {
|
|
505
|
-
if (!nextIds.has(prior.taskId) && (isTerminal(prior.status) || prior.activeClaim)) {
|
|
506
|
-
throw new TaskManagerError('terminal-task-immutable', `replan cannot remove durable task ${prior.taskId}`);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
504
|
const provisional: TaskRecord[] = definition.tasks.map((nextDefinition) => {
|
|
510
505
|
const prior = taskRecordById(snapshot, nextDefinition.taskId);
|
|
511
506
|
if (prior) return { ...prior, definition: nextDefinition, updatedAt: input.replannedAt };
|
|
@@ -714,6 +709,40 @@ function replaceTask(snapshot: TaskSpaceSnapshot, replacement: TaskRecord): Task
|
|
|
714
709
|
return snapshot.tasks.map((task) => task.taskId === replacement.taskId ? replacement : task);
|
|
715
710
|
}
|
|
716
711
|
|
|
712
|
+
function assertReplanDurableTasksImmutable(
|
|
713
|
+
snapshot: TaskSpaceSnapshot,
|
|
714
|
+
nextDefinition: TaskSpaceDefinition,
|
|
715
|
+
): void {
|
|
716
|
+
for (const prior of snapshot.tasks) {
|
|
717
|
+
const next = nextDefinition.tasks.find(({ taskId }) => taskId === prior.taskId);
|
|
718
|
+
if (!next) {
|
|
719
|
+
if (isTerminal(prior.status) || prior.activeClaim) {
|
|
720
|
+
throw new TaskManagerError('terminal-task-immutable', `replan cannot remove durable task ${prior.taskId}`);
|
|
721
|
+
}
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (!isTerminal(prior.status)) continue;
|
|
725
|
+
const priorRelations = incidentRelations(snapshot.definition, prior.taskId);
|
|
726
|
+
const nextRelations = incidentRelations(nextDefinition, prior.taskId);
|
|
727
|
+
if (canonicalOwnDataDigest(prior.definition) !== canonicalOwnDataDigest(next)
|
|
728
|
+
|| canonicalOwnDataDigest(priorRelations) !== canonicalOwnDataDigest(nextRelations)) {
|
|
729
|
+
throw new TaskManagerError(
|
|
730
|
+
'terminal-task-immutable',
|
|
731
|
+
`replan cannot rewrite terminal task ${prior.taskId} definition, profile, identity or relations`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function incidentRelations(
|
|
738
|
+
definition: TaskSpaceDefinition,
|
|
739
|
+
taskId: TaskId,
|
|
740
|
+
): TaskSpaceDefinition['relations'] {
|
|
741
|
+
return definition.relations.filter((relation) => relation.kind === 'parent-child'
|
|
742
|
+
? relation.parentTaskId === taskId || relation.childTaskId === taskId
|
|
743
|
+
: relation.predecessorTaskId === taskId || relation.successorTaskId === taskId);
|
|
744
|
+
}
|
|
745
|
+
|
|
717
746
|
function dependenciesSucceeded(
|
|
718
747
|
completionGraph: TaskCompletionGraph,
|
|
719
748
|
tasks: readonly TaskRecord[],
|