task-manager-logic 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/errors.ts +32 -0
- package/src/index.ts +4 -2
- package/src/memory-owner.ts +191 -0
- package/src/processors.ts +921 -0
- package/src/statusMachine.ts +26 -33
- package/src/validation.ts +278 -0
- package/src/stores.ts +0 -42
- package/src/tree.ts +0 -100
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CancelTaskCommand,
|
|
3
|
+
ClaimTaskCommand,
|
|
4
|
+
CreateTaskSpaceCommand,
|
|
5
|
+
ExpireTaskClaimCommand,
|
|
6
|
+
FailTaskCommand,
|
|
7
|
+
HeartbeatTaskClaimCommand,
|
|
8
|
+
PatchTaskSpaceCommand,
|
|
9
|
+
ReplanTaskCommand,
|
|
10
|
+
SettleTaskCommand,
|
|
11
|
+
StartTaskCommand,
|
|
12
|
+
TaskArtifactBody,
|
|
13
|
+
TaskArtifactRef,
|
|
14
|
+
TaskClaimReceipt,
|
|
15
|
+
TaskClaimToken,
|
|
16
|
+
TaskCommand,
|
|
17
|
+
TaskCommandReceipt,
|
|
18
|
+
TaskHistoryEvent,
|
|
19
|
+
TaskId,
|
|
20
|
+
TaskNodeDefinition,
|
|
21
|
+
TaskProfileEnvelope,
|
|
22
|
+
TaskRecord,
|
|
23
|
+
TaskReplanReceipt,
|
|
24
|
+
TaskSettlementReceipt,
|
|
25
|
+
TaskSpaceDefinition,
|
|
26
|
+
TaskSpaceOwnerPort,
|
|
27
|
+
TaskSpaceSnapshot,
|
|
28
|
+
TaskStatus,
|
|
29
|
+
TaskTimestamp,
|
|
30
|
+
TaskTransitionReceipt,
|
|
31
|
+
WaitTaskCommand,
|
|
32
|
+
} from 'task-manager-contract';
|
|
33
|
+
import { canonicalOwnDataDigest } from 'task-manager-contract';
|
|
34
|
+
import { TaskManagerError } from './errors.js';
|
|
35
|
+
import { assertTaskTransition, isTerminal } from './statusMachine.js';
|
|
36
|
+
import {
|
|
37
|
+
assertClaimIdentity,
|
|
38
|
+
assertClaimLive,
|
|
39
|
+
assertArtifactBody,
|
|
40
|
+
assertArtifactRef,
|
|
41
|
+
deepImmutableClone,
|
|
42
|
+
normalizeTaskProcessorConfig,
|
|
43
|
+
predecessorIds,
|
|
44
|
+
taskRecordById,
|
|
45
|
+
validateTaskSpaceDefinition,
|
|
46
|
+
validateTimestamp,
|
|
47
|
+
type TaskProcessorConfig,
|
|
48
|
+
} from './validation.js';
|
|
49
|
+
|
|
50
|
+
export interface TaskManagerRuntime {
|
|
51
|
+
readonly owner: TaskSpaceOwnerPort;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TaskProcessorResult<Receipt extends TaskCommandReceipt = TaskCommandReceipt> {
|
|
55
|
+
readonly snapshot: TaskSpaceSnapshot;
|
|
56
|
+
readonly receipt: Receipt;
|
|
57
|
+
readonly replayed: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function createTaskSpace<Profile extends TaskProfileEnvelope>(
|
|
61
|
+
runtime: TaskManagerRuntime,
|
|
62
|
+
input: CreateTaskSpaceCommand<Profile>,
|
|
63
|
+
config: TaskProcessorConfig,
|
|
64
|
+
): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
|
|
65
|
+
input = canonicalCommand(input);
|
|
66
|
+
const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
|
|
67
|
+
if (replay) return replay;
|
|
68
|
+
if (input.definition.taskSpaceId !== input.taskSpaceId) {
|
|
69
|
+
throw new TaskManagerError('cross-space-identity', 'definition and command TaskSpace identities differ');
|
|
70
|
+
}
|
|
71
|
+
if (await runtime.owner.readSnapshot(input.taskSpaceId)) {
|
|
72
|
+
throw new TaskManagerError('task-space-already-exists', `TaskSpace ${input.taskSpaceId} already exists`);
|
|
73
|
+
}
|
|
74
|
+
const normalized = normalizeTaskProcessorConfig(config);
|
|
75
|
+
validateTaskSpaceDefinition(input.definition, normalized);
|
|
76
|
+
validateTimestamp(input.createdAt, 'createdAt');
|
|
77
|
+
|
|
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) => ({
|
|
82
|
+
kind: 'task-record',
|
|
83
|
+
taskSpaceId: input.taskSpaceId,
|
|
84
|
+
taskId: definition.taskId,
|
|
85
|
+
definition,
|
|
86
|
+
profile: definition.profile,
|
|
87
|
+
status: 'Pending',
|
|
88
|
+
attempt: 0,
|
|
89
|
+
leaseEpoch: 0,
|
|
90
|
+
activeClaim: null,
|
|
91
|
+
outputArtifacts: [],
|
|
92
|
+
createdAt: input.createdAt,
|
|
93
|
+
updatedAt: input.createdAt,
|
|
94
|
+
}));
|
|
95
|
+
const tasks = stabilizeTaskProjection(input.definition, provisional);
|
|
96
|
+
const event = {
|
|
97
|
+
...eventBase(input, 0, 0, 0, input.createdAt),
|
|
98
|
+
kind: 'task-space.created',
|
|
99
|
+
} as const;
|
|
100
|
+
const receipt: TaskTransitionReceipt = {
|
|
101
|
+
...receiptBase(input, 0, 0, input.createdAt, [event.eventId]),
|
|
102
|
+
kind: 'task-transition-receipt',
|
|
103
|
+
};
|
|
104
|
+
const snapshot: TaskSpaceSnapshot = {
|
|
105
|
+
kind: 'task-space-snapshot',
|
|
106
|
+
taskSpaceId: input.taskSpaceId,
|
|
107
|
+
revision: 0,
|
|
108
|
+
definition: input.definition,
|
|
109
|
+
tasks,
|
|
110
|
+
historyEventIds: [event.eventId],
|
|
111
|
+
issuedReceiptIds: [receipt.receiptId],
|
|
112
|
+
createdAt: input.createdAt,
|
|
113
|
+
updatedAt: input.createdAt,
|
|
114
|
+
};
|
|
115
|
+
return admit(runtime, input, null, snapshot, [event], receipt, artifactBodies);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
|
|
119
|
+
runtime: TaskManagerRuntime,
|
|
120
|
+
input: PatchTaskSpaceCommand<Profile>,
|
|
121
|
+
config: TaskProcessorConfig,
|
|
122
|
+
): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
|
|
123
|
+
input = canonicalCommand(input);
|
|
124
|
+
const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
|
|
125
|
+
if (replay) return replay;
|
|
126
|
+
const snapshot = await loadExpected(runtime, input);
|
|
127
|
+
validateTimestamp(input.patchedAt, 'patchedAt');
|
|
128
|
+
const patch = input.patch;
|
|
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
|
+
}
|
|
139
|
+
let nextDefinition: TaskNodeDefinition;
|
|
140
|
+
if (patch.kind === 'rename-task') {
|
|
141
|
+
nextDefinition = {
|
|
142
|
+
...current.definition,
|
|
143
|
+
name: patch.name,
|
|
144
|
+
...(patch.description === undefined ? {} : { description: patch.description }),
|
|
145
|
+
};
|
|
146
|
+
} else {
|
|
147
|
+
const hasArtifact = current.definition.inputArtifacts.some(({ digest }) => digest === patch.artifact.digest);
|
|
148
|
+
nextDefinition = {
|
|
149
|
+
...current.definition,
|
|
150
|
+
inputArtifacts: hasArtifact
|
|
151
|
+
? current.definition.inputArtifacts
|
|
152
|
+
: [...current.definition.inputArtifacts, patch.artifact],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const definition: TaskSpaceDefinition = {
|
|
156
|
+
...snapshot.definition,
|
|
157
|
+
tasks: snapshot.definition.tasks.map((task) => task.taskId === current.taskId ? nextDefinition : task),
|
|
158
|
+
};
|
|
159
|
+
validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
|
|
160
|
+
const artifactBodies = input.artifactBodies ?? [];
|
|
161
|
+
await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
|
|
162
|
+
const nextTasks = snapshot.tasks.map((task) => task.taskId === current.taskId
|
|
163
|
+
? { ...task, definition: nextDefinition, profile: nextDefinition.profile, updatedAt: input.patchedAt }
|
|
164
|
+
: task);
|
|
165
|
+
const event = {
|
|
166
|
+
...nextEventBase(snapshot, input, 0, input.patchedAt),
|
|
167
|
+
kind: 'task-space.patched',
|
|
168
|
+
taskId: current.taskId,
|
|
169
|
+
} as const;
|
|
170
|
+
const receipt: TaskTransitionReceipt = {
|
|
171
|
+
...nextReceiptBase(snapshot, input, input.patchedAt, [event.eventId]),
|
|
172
|
+
kind: 'task-transition-receipt',
|
|
173
|
+
taskId: current.taskId,
|
|
174
|
+
};
|
|
175
|
+
return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt, artifactBodies);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function claimTask(
|
|
179
|
+
runtime: TaskManagerRuntime,
|
|
180
|
+
input: ClaimTaskCommand,
|
|
181
|
+
config: TaskProcessorConfig,
|
|
182
|
+
): Promise<TaskProcessorResult<TaskClaimReceipt>> {
|
|
183
|
+
input = canonicalCommand(input);
|
|
184
|
+
const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
|
|
185
|
+
if (replay) return replay;
|
|
186
|
+
const snapshot = await loadExpected(runtime, input);
|
|
187
|
+
const task = requireTask(snapshot, input.taskId);
|
|
188
|
+
if (task.definition.kind !== 'task' || task.status !== 'Ready') {
|
|
189
|
+
throw new TaskManagerError('task-not-ready', `task ${task.taskId} is not a claimable Ready task`);
|
|
190
|
+
}
|
|
191
|
+
const normalized = normalizeTaskProcessorConfig(config);
|
|
192
|
+
if (!Number.isSafeInteger(input.leaseDurationMs)
|
|
193
|
+
|| input.leaseDurationMs <= 0
|
|
194
|
+
|| input.leaseDurationMs > normalized.maxLeaseDurationMs) {
|
|
195
|
+
throw new TaskManagerError('lease-out-of-range', `leaseDurationMs must be within 1..${normalized.maxLeaseDurationMs}`);
|
|
196
|
+
}
|
|
197
|
+
const claimedAtMs = validateTimestamp(input.claimedAt, 'claimedAt');
|
|
198
|
+
const attempt = task.attempt === 0 ? 1 : task.attempt;
|
|
199
|
+
const leaseEpoch = task.leaseEpoch === 0 ? 1 : task.leaseEpoch;
|
|
200
|
+
const claim: TaskClaimToken = {
|
|
201
|
+
kind: 'task-claim-token',
|
|
202
|
+
claimId: stableId('claim', input.commandId),
|
|
203
|
+
taskSpaceId: input.taskSpaceId,
|
|
204
|
+
taskId: input.taskId,
|
|
205
|
+
assigneeRef: nonEmpty(input.assigneeRef, 'assigneeRef'),
|
|
206
|
+
attempt,
|
|
207
|
+
leaseEpoch,
|
|
208
|
+
claimedAt: input.claimedAt,
|
|
209
|
+
heartbeatAt: input.claimedAt,
|
|
210
|
+
expiresAt: new Date(claimedAtMs + input.leaseDurationMs).toISOString(),
|
|
211
|
+
};
|
|
212
|
+
assertTaskTransition(task.taskId, task.status, 'Claimed');
|
|
213
|
+
const nextTask: TaskRecord = {
|
|
214
|
+
...task,
|
|
215
|
+
status: 'Claimed',
|
|
216
|
+
attempt,
|
|
217
|
+
leaseEpoch,
|
|
218
|
+
activeClaim: claim,
|
|
219
|
+
updatedAt: input.claimedAt,
|
|
220
|
+
};
|
|
221
|
+
const event = {
|
|
222
|
+
...stateEventBase(snapshot, input, 0, input.claimedAt, task, 'Claimed'),
|
|
223
|
+
kind: 'task.claimed',
|
|
224
|
+
claim,
|
|
225
|
+
} as const;
|
|
226
|
+
const receipt: TaskClaimReceipt = {
|
|
227
|
+
...nextReceiptBase(snapshot, input, input.claimedAt, [event.eventId]),
|
|
228
|
+
kind: 'task-claim-receipt',
|
|
229
|
+
taskId: task.taskId,
|
|
230
|
+
claim,
|
|
231
|
+
};
|
|
232
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function heartbeatTaskClaim(
|
|
236
|
+
runtime: TaskManagerRuntime,
|
|
237
|
+
input: HeartbeatTaskClaimCommand,
|
|
238
|
+
config: TaskProcessorConfig,
|
|
239
|
+
): Promise<TaskProcessorResult<TaskClaimReceipt>> {
|
|
240
|
+
input = canonicalCommand(input);
|
|
241
|
+
const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
|
|
242
|
+
if (replay) return replay;
|
|
243
|
+
const snapshot = await loadExpected(runtime, input);
|
|
244
|
+
const task = assertClaimIdentity(snapshot, input.claim);
|
|
245
|
+
const heartbeatAtMs = validateTimestamp(input.heartbeatAt, 'heartbeatAt');
|
|
246
|
+
const priorHeartbeatAtMs = validateTimestamp(input.claim.heartbeatAt, 'claim.heartbeatAt');
|
|
247
|
+
const priorExpiresAtMs = validateTimestamp(input.claim.expiresAt, 'claim.expiresAt');
|
|
248
|
+
if (heartbeatAtMs <= priorHeartbeatAtMs) {
|
|
249
|
+
throw new TaskManagerError('claim-token-mismatch', 'heartbeat must advance the live token timestamp');
|
|
250
|
+
}
|
|
251
|
+
if (heartbeatAtMs >= priorExpiresAtMs) {
|
|
252
|
+
throw new TaskManagerError('claim-expired', `claim ${input.claim.claimId} is already expired`);
|
|
253
|
+
}
|
|
254
|
+
const normalized = normalizeTaskProcessorConfig(config);
|
|
255
|
+
if (!Number.isSafeInteger(input.extendByMs)
|
|
256
|
+
|| input.extendByMs <= 0
|
|
257
|
+
|| input.extendByMs > normalized.maxLeaseDurationMs) {
|
|
258
|
+
throw new TaskManagerError('lease-out-of-range', `extendByMs must be within 1..${normalized.maxLeaseDurationMs}`);
|
|
259
|
+
}
|
|
260
|
+
const claim: TaskClaimToken = {
|
|
261
|
+
...input.claim,
|
|
262
|
+
heartbeatAt: input.heartbeatAt,
|
|
263
|
+
expiresAt: new Date(heartbeatAtMs + input.extendByMs).toISOString(),
|
|
264
|
+
};
|
|
265
|
+
const nextTask: TaskRecord = { ...task, activeClaim: claim, updatedAt: input.heartbeatAt };
|
|
266
|
+
const event = {
|
|
267
|
+
...nextEventBase(snapshot, input, 0, input.heartbeatAt),
|
|
268
|
+
kind: 'task.claim-heartbeat-recorded',
|
|
269
|
+
taskId: task.taskId,
|
|
270
|
+
previousClaim: input.claim,
|
|
271
|
+
claim,
|
|
272
|
+
} as const;
|
|
273
|
+
const receipt: TaskClaimReceipt = {
|
|
274
|
+
...nextReceiptBase(snapshot, input, input.heartbeatAt, [event.eventId]),
|
|
275
|
+
kind: 'task-claim-receipt',
|
|
276
|
+
taskId: task.taskId,
|
|
277
|
+
claim,
|
|
278
|
+
};
|
|
279
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function expireTaskClaim(
|
|
283
|
+
runtime: TaskManagerRuntime,
|
|
284
|
+
input: ExpireTaskClaimCommand,
|
|
285
|
+
_config: TaskProcessorConfig,
|
|
286
|
+
): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
|
|
287
|
+
input = canonicalCommand(input);
|
|
288
|
+
const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
|
|
289
|
+
if (replay) return replay;
|
|
290
|
+
const snapshot = await loadExpected(runtime, input);
|
|
291
|
+
const task = assertClaimIdentity(snapshot, input.claim);
|
|
292
|
+
const observedAtMs = validateTimestamp(input.observedAt, 'observedAt');
|
|
293
|
+
const expiresAtMs = validateTimestamp(input.claim.expiresAt, 'claim.expiresAt');
|
|
294
|
+
if (observedAtMs < expiresAtMs) {
|
|
295
|
+
throw new TaskManagerError('claim-not-expired', `claim ${input.claim.claimId} remains live until ${input.claim.expiresAt}`);
|
|
296
|
+
}
|
|
297
|
+
assertTaskTransition(task.taskId, task.status, 'Ready');
|
|
298
|
+
const nextAttempt = task.attempt + 1;
|
|
299
|
+
const nextLeaseEpoch = task.leaseEpoch + 1;
|
|
300
|
+
const nextTask: TaskRecord = {
|
|
301
|
+
...task,
|
|
302
|
+
status: 'Ready',
|
|
303
|
+
attempt: nextAttempt,
|
|
304
|
+
leaseEpoch: nextLeaseEpoch,
|
|
305
|
+
activeClaim: null,
|
|
306
|
+
updatedAt: input.observedAt,
|
|
307
|
+
};
|
|
308
|
+
const event = {
|
|
309
|
+
...stateEventBase(snapshot, input, 0, input.observedAt, task, 'Ready'),
|
|
310
|
+
kind: 'task.claim-expired',
|
|
311
|
+
expiredClaim: input.claim,
|
|
312
|
+
nextAttempt,
|
|
313
|
+
nextLeaseEpoch,
|
|
314
|
+
} as const;
|
|
315
|
+
const receipt: TaskTransitionReceipt = {
|
|
316
|
+
...nextReceiptBase(snapshot, input, input.observedAt, [event.eventId]),
|
|
317
|
+
kind: 'task-transition-receipt',
|
|
318
|
+
taskId: task.taskId,
|
|
319
|
+
};
|
|
320
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function startTask(
|
|
324
|
+
runtime: TaskManagerRuntime,
|
|
325
|
+
input: StartTaskCommand,
|
|
326
|
+
_config: TaskProcessorConfig,
|
|
327
|
+
): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
|
|
328
|
+
input = canonicalCommand(input);
|
|
329
|
+
return claimedStateTransition(runtime, input, 'Running', 'task.started', 'task-transition-receipt');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function waitTask(
|
|
333
|
+
runtime: TaskManagerRuntime,
|
|
334
|
+
input: WaitTaskCommand,
|
|
335
|
+
_config: TaskProcessorConfig,
|
|
336
|
+
): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
|
|
337
|
+
input = canonicalCommand(input);
|
|
338
|
+
const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
|
|
339
|
+
if (replay) return replay;
|
|
340
|
+
const snapshot = await loadExpected(runtime, input);
|
|
341
|
+
const task = assertClaimIdentity(snapshot, input.claim);
|
|
342
|
+
assertClaimLive(input.claim, input.occurredAt);
|
|
343
|
+
assertTaskTransition(task.taskId, task.status, 'Waiting');
|
|
344
|
+
const nextTask: TaskRecord = {
|
|
345
|
+
...task,
|
|
346
|
+
status: 'Waiting',
|
|
347
|
+
profile: input.profile ?? task.profile,
|
|
348
|
+
updatedAt: input.occurredAt,
|
|
349
|
+
};
|
|
350
|
+
const event = {
|
|
351
|
+
...stateEventBase(snapshot, input, 0, input.occurredAt, task, 'Waiting'),
|
|
352
|
+
kind: 'task.waiting',
|
|
353
|
+
claimId: input.claim.claimId,
|
|
354
|
+
reason: nonEmpty(input.reason, 'reason'),
|
|
355
|
+
...(input.details === undefined ? {} : { details: input.details }),
|
|
356
|
+
} as const;
|
|
357
|
+
const receipt: TaskTransitionReceipt = {
|
|
358
|
+
...nextReceiptBase(snapshot, input, input.occurredAt, [event.eventId]),
|
|
359
|
+
kind: 'task-transition-receipt',
|
|
360
|
+
taskId: task.taskId,
|
|
361
|
+
};
|
|
362
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function settleTask(
|
|
366
|
+
runtime: TaskManagerRuntime,
|
|
367
|
+
input: SettleTaskCommand,
|
|
368
|
+
_config: TaskProcessorConfig,
|
|
369
|
+
): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
|
|
370
|
+
input = canonicalCommand(input);
|
|
371
|
+
const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
|
|
372
|
+
if (replay) return replay;
|
|
373
|
+
const snapshot = await loadExpected(runtime, input);
|
|
374
|
+
const task = assertClaimIdentity(snapshot, input.claim);
|
|
375
|
+
assertClaimLive(input.claim, input.occurredAt);
|
|
376
|
+
assertTaskTransition(task.taskId, task.status, 'Succeeded');
|
|
377
|
+
const artifactBodies = input.artifactBodies ?? [];
|
|
378
|
+
await assertArtifactAuthority(runtime.owner, input.taskSpaceId, input.outputArtifacts, artifactBodies);
|
|
379
|
+
const settled: TaskRecord = {
|
|
380
|
+
...task,
|
|
381
|
+
status: 'Succeeded',
|
|
382
|
+
profile: input.profile ?? task.profile,
|
|
383
|
+
activeClaim: null,
|
|
384
|
+
outputArtifacts: input.outputArtifacts,
|
|
385
|
+
updatedAt: input.occurredAt,
|
|
386
|
+
};
|
|
387
|
+
const tasks = replaceTask(snapshot, settled);
|
|
388
|
+
const events: TaskHistoryEvent[] = [{
|
|
389
|
+
...stateEventBase(snapshot, input, 0, input.occurredAt, task, 'Succeeded'),
|
|
390
|
+
kind: 'task.settled',
|
|
391
|
+
claimId: input.claim.claimId,
|
|
392
|
+
outputArtifacts: input.outputArtifacts,
|
|
393
|
+
...(input.result === undefined ? {} : { result: input.result }),
|
|
394
|
+
}];
|
|
395
|
+
const receipt: TaskSettlementReceipt = {
|
|
396
|
+
...nextReceiptBase(snapshot, input, input.occurredAt, events.map(({ eventId }) => eventId)),
|
|
397
|
+
kind: 'task-settlement-receipt',
|
|
398
|
+
taskId: task.taskId,
|
|
399
|
+
claimId: input.claim.claimId,
|
|
400
|
+
status: 'Succeeded',
|
|
401
|
+
};
|
|
402
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, tasks, events, receipt, artifactBodies);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function failTask(
|
|
406
|
+
runtime: TaskManagerRuntime,
|
|
407
|
+
input: FailTaskCommand,
|
|
408
|
+
_config: TaskProcessorConfig,
|
|
409
|
+
): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
|
|
410
|
+
input = canonicalCommand(input);
|
|
411
|
+
const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
|
|
412
|
+
if (replay) return replay;
|
|
413
|
+
const snapshot = await loadExpected(runtime, input);
|
|
414
|
+
const task = assertClaimIdentity(snapshot, input.claim);
|
|
415
|
+
assertClaimLive(input.claim, input.occurredAt);
|
|
416
|
+
assertTaskTransition(task.taskId, task.status, 'Failed');
|
|
417
|
+
const nextTask: TaskRecord = {
|
|
418
|
+
...task,
|
|
419
|
+
status: 'Failed',
|
|
420
|
+
profile: input.profile ?? task.profile,
|
|
421
|
+
activeClaim: null,
|
|
422
|
+
updatedAt: input.occurredAt,
|
|
423
|
+
};
|
|
424
|
+
const event = {
|
|
425
|
+
...stateEventBase(snapshot, input, 0, input.occurredAt, task, 'Failed'),
|
|
426
|
+
kind: 'task.failed',
|
|
427
|
+
claimId: input.claim.claimId,
|
|
428
|
+
failure: input.failure,
|
|
429
|
+
} as const;
|
|
430
|
+
const receipt: TaskSettlementReceipt = {
|
|
431
|
+
...nextReceiptBase(snapshot, input, input.occurredAt, [event.eventId]),
|
|
432
|
+
kind: 'task-settlement-receipt',
|
|
433
|
+
taskId: task.taskId,
|
|
434
|
+
claimId: input.claim.claimId,
|
|
435
|
+
status: 'Failed',
|
|
436
|
+
};
|
|
437
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function cancelTask(
|
|
441
|
+
runtime: TaskManagerRuntime,
|
|
442
|
+
input: CancelTaskCommand,
|
|
443
|
+
_config: TaskProcessorConfig,
|
|
444
|
+
): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
|
|
445
|
+
input = canonicalCommand(input);
|
|
446
|
+
const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
|
|
447
|
+
if (replay) return replay;
|
|
448
|
+
const snapshot = await loadExpected(runtime, input);
|
|
449
|
+
const task = requireTask(snapshot, input.taskId);
|
|
450
|
+
if (isTerminal(task.status)) {
|
|
451
|
+
throw new TaskManagerError('terminal-task-immutable', `task ${task.taskId} is terminal (${task.status})`);
|
|
452
|
+
}
|
|
453
|
+
validateTimestamp(input.cancelledAt, 'cancelledAt');
|
|
454
|
+
if (input.claim) assertClaimIdentity(snapshot, input.claim, task.taskId);
|
|
455
|
+
assertTaskTransition(task.taskId, task.status, 'Cancelled');
|
|
456
|
+
const nextTask: TaskRecord = {
|
|
457
|
+
...task,
|
|
458
|
+
status: 'Cancelled',
|
|
459
|
+
profile: input.profile ?? task.profile,
|
|
460
|
+
activeClaim: null,
|
|
461
|
+
updatedAt: input.cancelledAt,
|
|
462
|
+
};
|
|
463
|
+
const event = {
|
|
464
|
+
...stateEventBase(snapshot, input, 0, input.cancelledAt, task, 'Cancelled'),
|
|
465
|
+
kind: 'task.cancelled',
|
|
466
|
+
...(input.claim === undefined ? {} : { claimId: input.claim.claimId }),
|
|
467
|
+
reason: nonEmpty(input.reason, 'reason'),
|
|
468
|
+
} as const;
|
|
469
|
+
const receipt: TaskSettlementReceipt = {
|
|
470
|
+
...nextReceiptBase(snapshot, input, input.cancelledAt, [event.eventId]),
|
|
471
|
+
kind: 'task-settlement-receipt',
|
|
472
|
+
taskId: task.taskId,
|
|
473
|
+
...(input.claim === undefined ? {} : { claimId: input.claim.claimId }),
|
|
474
|
+
status: 'Cancelled',
|
|
475
|
+
};
|
|
476
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function replanTask(
|
|
480
|
+
runtime: TaskManagerRuntime,
|
|
481
|
+
input: ReplanTaskCommand,
|
|
482
|
+
config: TaskProcessorConfig,
|
|
483
|
+
): Promise<TaskProcessorResult<TaskReplanReceipt>> {
|
|
484
|
+
input = canonicalCommand(input);
|
|
485
|
+
const replay = await replayed<TaskReplanReceipt>(runtime, input, 'task-replan-receipt');
|
|
486
|
+
if (replay) return replay;
|
|
487
|
+
const snapshot = await loadExpected(runtime, input);
|
|
488
|
+
validateTimestamp(input.replannedAt, 'replannedAt');
|
|
489
|
+
const definition: TaskSpaceDefinition = {
|
|
490
|
+
...snapshot.definition,
|
|
491
|
+
tasks: input.plan.tasks,
|
|
492
|
+
relations: input.plan.relations,
|
|
493
|
+
};
|
|
494
|
+
validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
|
|
495
|
+
const artifactBodies = input.artifactBodies ?? [];
|
|
496
|
+
await assertArtifactAuthority(runtime.owner, input.taskSpaceId, definition.tasks.flatMap(({ inputArtifacts }) => inputArtifacts), artifactBodies);
|
|
497
|
+
const nextIds = new Set(definition.tasks.map(({ taskId }) => taskId));
|
|
498
|
+
for (const prior of snapshot.tasks) {
|
|
499
|
+
if (!nextIds.has(prior.taskId) && (isTerminal(prior.status) || prior.activeClaim)) {
|
|
500
|
+
throw new TaskManagerError('terminal-task-immutable', `replan cannot remove durable task ${prior.taskId}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const provisional: TaskRecord[] = definition.tasks.map((nextDefinition) => {
|
|
504
|
+
const prior = taskRecordById(snapshot, nextDefinition.taskId);
|
|
505
|
+
if (prior) return { ...prior, definition: nextDefinition, updatedAt: input.replannedAt };
|
|
506
|
+
return {
|
|
507
|
+
kind: 'task-record',
|
|
508
|
+
taskSpaceId: snapshot.taskSpaceId,
|
|
509
|
+
taskId: nextDefinition.taskId,
|
|
510
|
+
definition: nextDefinition,
|
|
511
|
+
profile: nextDefinition.profile,
|
|
512
|
+
status: 'Pending',
|
|
513
|
+
attempt: 0,
|
|
514
|
+
leaseEpoch: 0,
|
|
515
|
+
activeClaim: null,
|
|
516
|
+
outputArtifacts: [],
|
|
517
|
+
createdAt: input.replannedAt,
|
|
518
|
+
updatedAt: input.replannedAt,
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
const nextTasks: TaskRecord[] = provisional.map((task) => {
|
|
522
|
+
if (isTerminal(task.status) || task.activeClaim || task.status === 'Running' || task.status === 'Waiting') return task;
|
|
523
|
+
const status: TaskStatus = dependenciesSucceeded(definition, provisional, task.taskId) ? 'Ready' : 'Pending';
|
|
524
|
+
return { ...task, status };
|
|
525
|
+
});
|
|
526
|
+
const affectedTaskIds = [...new Set([...snapshot.tasks.map(({ taskId }) => taskId), ...nextTasks.map(({ taskId }) => taskId)])].sort();
|
|
527
|
+
const event = {
|
|
528
|
+
...nextEventBase(snapshot, input, 0, input.replannedAt),
|
|
529
|
+
kind: 'task.replanned',
|
|
530
|
+
planId: input.plan.planId,
|
|
531
|
+
affectedTaskIds,
|
|
532
|
+
} as const;
|
|
533
|
+
const receipt: TaskReplanReceipt = {
|
|
534
|
+
...nextReceiptBase(snapshot, input, input.replannedAt, [event.eventId]),
|
|
535
|
+
kind: 'task-replan-receipt',
|
|
536
|
+
planId: input.plan.planId,
|
|
537
|
+
affectedTaskIds,
|
|
538
|
+
};
|
|
539
|
+
return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt, artifactBodies);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function claimedStateTransition(
|
|
543
|
+
runtime: TaskManagerRuntime,
|
|
544
|
+
input: StartTaskCommand,
|
|
545
|
+
toStatus: 'Running',
|
|
546
|
+
eventKind: 'task.started',
|
|
547
|
+
receiptKind: 'task-transition-receipt',
|
|
548
|
+
): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
|
|
549
|
+
const replay = await replayed<TaskTransitionReceipt>(runtime, input, receiptKind);
|
|
550
|
+
if (replay) return replay;
|
|
551
|
+
const snapshot = await loadExpected(runtime, input);
|
|
552
|
+
const task = assertClaimIdentity(snapshot, input.claim);
|
|
553
|
+
assertClaimLive(input.claim, input.occurredAt);
|
|
554
|
+
assertTaskTransition(task.taskId, task.status, toStatus);
|
|
555
|
+
const nextTask: TaskRecord = {
|
|
556
|
+
...task,
|
|
557
|
+
status: toStatus,
|
|
558
|
+
profile: input.profile ?? task.profile,
|
|
559
|
+
updatedAt: input.occurredAt,
|
|
560
|
+
};
|
|
561
|
+
const event = {
|
|
562
|
+
...stateEventBase(snapshot, input, 0, input.occurredAt, task, toStatus),
|
|
563
|
+
kind: eventKind,
|
|
564
|
+
claimId: input.claim.claimId,
|
|
565
|
+
} as const;
|
|
566
|
+
const receipt: TaskTransitionReceipt = {
|
|
567
|
+
...nextReceiptBase(snapshot, input, input.occurredAt, [event.eventId]),
|
|
568
|
+
kind: receiptKind,
|
|
569
|
+
taskId: task.taskId,
|
|
570
|
+
};
|
|
571
|
+
return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function replayed<Receipt extends TaskCommandReceipt>(
|
|
575
|
+
runtime: TaskManagerRuntime,
|
|
576
|
+
input: TaskCommand,
|
|
577
|
+
receiptKind: Receipt['kind'],
|
|
578
|
+
): Promise<TaskProcessorResult<Receipt> | undefined> {
|
|
579
|
+
const receipt = await runtime.owner.readReceiptByCommand(input.taskSpaceId, input.commandId);
|
|
580
|
+
if (!receipt) return undefined;
|
|
581
|
+
const commandDigest = canonicalOwnDataDigest(input);
|
|
582
|
+
if (receipt.kind !== receiptKind || receipt.commandDigest !== commandDigest) {
|
|
583
|
+
throw new TaskManagerError(
|
|
584
|
+
'command-receipt-mismatch',
|
|
585
|
+
`command ${input.commandId} was already used for a different canonical command`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
const snapshot = await runtime.owner.readSnapshot(input.taskSpaceId);
|
|
589
|
+
if (!snapshot) {
|
|
590
|
+
throw new TaskManagerError('task-space-not-found', `receipt exists without TaskSpace ${input.taskSpaceId}`);
|
|
591
|
+
}
|
|
592
|
+
return deepImmutableClone({ snapshot, receipt: receipt as Receipt, replayed: true });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function loadExpected(
|
|
596
|
+
runtime: TaskManagerRuntime,
|
|
597
|
+
input: Exclude<TaskCommand, CreateTaskSpaceCommand>,
|
|
598
|
+
): Promise<TaskSpaceSnapshot> {
|
|
599
|
+
const snapshot = await runtime.owner.readSnapshot(input.taskSpaceId);
|
|
600
|
+
if (!snapshot) throw new TaskManagerError('task-space-not-found', `TaskSpace ${input.taskSpaceId} not found`);
|
|
601
|
+
if (snapshot.taskSpaceId !== input.taskSpaceId || snapshot.definition.taskSpaceId !== input.taskSpaceId) {
|
|
602
|
+
throw new TaskManagerError('cross-space-identity', 'snapshot contains a cross-space identity');
|
|
603
|
+
}
|
|
604
|
+
if (snapshot.revision !== input.expectedRevision) {
|
|
605
|
+
throw new TaskManagerError(
|
|
606
|
+
'revision-conflict',
|
|
607
|
+
`TaskSpace ${input.taskSpaceId} expected revision ${input.expectedRevision}, actual ${snapshot.revision}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return snapshot;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function admitExisting<Receipt extends TaskCommandReceipt>(
|
|
614
|
+
runtime: TaskManagerRuntime,
|
|
615
|
+
input: Exclude<TaskCommand, CreateTaskSpaceCommand>,
|
|
616
|
+
prior: TaskSpaceSnapshot,
|
|
617
|
+
definition: TaskSpaceDefinition,
|
|
618
|
+
tasks: readonly TaskRecord[],
|
|
619
|
+
events: readonly TaskHistoryEvent[],
|
|
620
|
+
receipt: Receipt,
|
|
621
|
+
artifacts: readonly TaskArtifactBody[] = [],
|
|
622
|
+
): Promise<TaskProcessorResult<Receipt>> {
|
|
623
|
+
const occurredAt = events.at(-1)?.occurredAt ?? prior.updatedAt;
|
|
624
|
+
const stabilizedTasks = stabilizeTaskProjection(definition, tasks, occurredAt);
|
|
625
|
+
const projectedEvents: TaskHistoryEvent[] = [];
|
|
626
|
+
for (const next of stabilizedTasks) {
|
|
627
|
+
const previous = prior.tasks.find(({ taskId }) => taskId === next.taskId);
|
|
628
|
+
if (!previous || previous.status === next.status) continue;
|
|
629
|
+
if (events.some((event) => 'taskId' in event && event.taskId === next.taskId && 'toStatus' in event && event.toStatus === next.status)) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const index = events.length + projectedEvents.length;
|
|
633
|
+
projectedEvents.push(next.definition.kind === 'task-group'
|
|
634
|
+
? {
|
|
635
|
+
...stateEventBase(prior, input, index, occurredAt, previous, next.status),
|
|
636
|
+
kind: 'task.group-status-projected',
|
|
637
|
+
}
|
|
638
|
+
: {
|
|
639
|
+
...stateEventBase(prior, input, index, occurredAt, previous, next.status),
|
|
640
|
+
kind: 'task.readied',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const admittedEvents = [...events, ...projectedEvents];
|
|
644
|
+
const admittedReceipt = {
|
|
645
|
+
...receipt,
|
|
646
|
+
eventIds: admittedEvents.map(({ eventId }) => eventId),
|
|
647
|
+
} as Receipt;
|
|
648
|
+
const snapshot: TaskSpaceSnapshot = {
|
|
649
|
+
...prior,
|
|
650
|
+
revision: prior.revision + 1,
|
|
651
|
+
definition,
|
|
652
|
+
tasks: stabilizedTasks,
|
|
653
|
+
historyEventIds: [...prior.historyEventIds, ...admittedEvents.map(({ eventId }) => eventId)],
|
|
654
|
+
issuedReceiptIds: [...prior.issuedReceiptIds, admittedReceipt.receiptId],
|
|
655
|
+
updatedAt: occurredAt,
|
|
656
|
+
};
|
|
657
|
+
return admit(runtime, input, prior.revision, snapshot, admittedEvents, admittedReceipt, artifacts);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function admit<Receipt extends TaskCommandReceipt>(
|
|
661
|
+
runtime: TaskManagerRuntime,
|
|
662
|
+
input: TaskCommand,
|
|
663
|
+
expectedRevision: number | null,
|
|
664
|
+
snapshot: TaskSpaceSnapshot,
|
|
665
|
+
events: readonly TaskHistoryEvent[],
|
|
666
|
+
receipt: Receipt,
|
|
667
|
+
artifacts: readonly TaskArtifactBody[],
|
|
668
|
+
): Promise<TaskProcessorResult<Receipt>> {
|
|
669
|
+
const result = await runtime.owner.compareAndSwap({
|
|
670
|
+
kind: 'task-space-commit',
|
|
671
|
+
taskSpaceId: input.taskSpaceId,
|
|
672
|
+
expectedRevision,
|
|
673
|
+
commandId: input.commandId,
|
|
674
|
+
commandDigest: canonicalOwnDataDigest(input),
|
|
675
|
+
command: input,
|
|
676
|
+
snapshot,
|
|
677
|
+
events,
|
|
678
|
+
receipts: [receipt],
|
|
679
|
+
artifacts,
|
|
680
|
+
});
|
|
681
|
+
if (result.kind === 'revision-conflict') {
|
|
682
|
+
throw new TaskManagerError(
|
|
683
|
+
'revision-conflict',
|
|
684
|
+
`TaskSpace ${input.taskSpaceId} expected revision ${String(result.expectedRevision)}, actual ${String(result.actualRevision)}`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
if (result.receipt.kind !== receipt.kind) {
|
|
688
|
+
throw new TaskManagerError('command-receipt-mismatch', `owner returned ${result.receipt.kind}, expected ${receipt.kind}`);
|
|
689
|
+
}
|
|
690
|
+
return deepImmutableClone({
|
|
691
|
+
snapshot: result.snapshot,
|
|
692
|
+
receipt: result.receipt as Receipt,
|
|
693
|
+
replayed: result.kind === 'command-replayed',
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function requireTask(snapshot: TaskSpaceSnapshot, taskId: TaskId): TaskRecord {
|
|
698
|
+
const task = taskRecordById(snapshot, taskId);
|
|
699
|
+
if (!task) throw new TaskManagerError('task-not-found', `task ${taskId} not found`);
|
|
700
|
+
if (task.taskSpaceId !== snapshot.taskSpaceId) {
|
|
701
|
+
throw new TaskManagerError('cross-space-identity', `task ${taskId} belongs to ${task.taskSpaceId}`);
|
|
702
|
+
}
|
|
703
|
+
return task;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function replaceTask(snapshot: TaskSpaceSnapshot, replacement: TaskRecord): TaskRecord[] {
|
|
707
|
+
return snapshot.tasks.map((task) => task.taskId === replacement.taskId ? replacement : task);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function dependenciesSucceeded(
|
|
711
|
+
definition: TaskSpaceDefinition,
|
|
712
|
+
tasks: readonly TaskRecord[],
|
|
713
|
+
taskId: TaskId,
|
|
714
|
+
): boolean {
|
|
715
|
+
const predecessors = effectivePredecessorIds(definition, taskId);
|
|
716
|
+
return predecessors.length === 0 || predecessors.every((predecessorId) =>
|
|
717
|
+
tasks.find((task) => task.taskId === predecessorId)?.status === 'Succeeded');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function eventBase(
|
|
721
|
+
input: TaskCommand,
|
|
722
|
+
index: number,
|
|
723
|
+
fromRevision: number,
|
|
724
|
+
toRevision: number,
|
|
725
|
+
occurredAt: TaskTimestamp,
|
|
726
|
+
) {
|
|
727
|
+
return {
|
|
728
|
+
eventId: stableId('event', input.commandId, index),
|
|
729
|
+
commandId: input.commandId,
|
|
730
|
+
taskSpaceId: input.taskSpaceId,
|
|
731
|
+
sequence: index,
|
|
732
|
+
fromRevision,
|
|
733
|
+
toRevision,
|
|
734
|
+
occurredAt,
|
|
735
|
+
} as const;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function nextEventBase(
|
|
739
|
+
snapshot: TaskSpaceSnapshot,
|
|
740
|
+
input: TaskCommand,
|
|
741
|
+
index: number,
|
|
742
|
+
occurredAt: TaskTimestamp,
|
|
743
|
+
) {
|
|
744
|
+
return {
|
|
745
|
+
...eventBase(input, index, snapshot.revision, snapshot.revision + 1, occurredAt),
|
|
746
|
+
sequence: snapshot.historyEventIds.length + index,
|
|
747
|
+
} as const;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function stateEventBase(
|
|
751
|
+
snapshot: TaskSpaceSnapshot,
|
|
752
|
+
input: TaskCommand,
|
|
753
|
+
index: number,
|
|
754
|
+
occurredAt: TaskTimestamp,
|
|
755
|
+
task: TaskRecord,
|
|
756
|
+
toStatus: TaskStatus,
|
|
757
|
+
) {
|
|
758
|
+
return {
|
|
759
|
+
...nextEventBase(snapshot, input, index, occurredAt),
|
|
760
|
+
taskId: task.taskId,
|
|
761
|
+
fromStatus: task.status,
|
|
762
|
+
toStatus,
|
|
763
|
+
} as const;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function receiptBase(
|
|
767
|
+
input: TaskCommand,
|
|
768
|
+
fromRevision: number,
|
|
769
|
+
toRevision: number,
|
|
770
|
+
committedAt: TaskTimestamp,
|
|
771
|
+
eventIds: readonly string[],
|
|
772
|
+
) {
|
|
773
|
+
return {
|
|
774
|
+
receiptId: stableId('receipt', input.commandId),
|
|
775
|
+
commandId: input.commandId,
|
|
776
|
+
commandDigest: canonicalOwnDataDigest(input),
|
|
777
|
+
taskSpaceId: input.taskSpaceId,
|
|
778
|
+
fromRevision,
|
|
779
|
+
toRevision,
|
|
780
|
+
eventIds,
|
|
781
|
+
committedAt,
|
|
782
|
+
} as const;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function nextReceiptBase(
|
|
786
|
+
snapshot: TaskSpaceSnapshot,
|
|
787
|
+
input: TaskCommand,
|
|
788
|
+
committedAt: TaskTimestamp,
|
|
789
|
+
eventIds: readonly string[],
|
|
790
|
+
) {
|
|
791
|
+
return receiptBase(input, snapshot.revision, snapshot.revision + 1, committedAt, eventIds);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function stableId(namespace: string, commandId: string, index?: number): string {
|
|
795
|
+
return `${namespace}:${encodeURIComponent(commandId)}${index === undefined ? '' : `:${index}`}`;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function nonEmpty(value: string, field: string): string {
|
|
799
|
+
if (value.length === 0 || value.trim() !== value) {
|
|
800
|
+
throw new TaskManagerError('invalid-task-definition', `${field} must be non-empty and trimmed`);
|
|
801
|
+
}
|
|
802
|
+
return value;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function canonicalCommand<Command extends TaskCommand>(input: Command): Command {
|
|
806
|
+
return deepImmutableClone(input);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function assertArtifactAuthority(
|
|
810
|
+
owner: TaskSpaceOwnerPort,
|
|
811
|
+
taskSpaceId: string,
|
|
812
|
+
refs: readonly TaskArtifactRef[],
|
|
813
|
+
bodies: readonly TaskArtifactBody[],
|
|
814
|
+
): Promise<void> {
|
|
815
|
+
const supplied = new Map<string, TaskArtifactBody>();
|
|
816
|
+
for (const body of bodies) {
|
|
817
|
+
assertArtifactBody(body, `artifact body ${body.digest}`);
|
|
818
|
+
const prior = supplied.get(body.digest);
|
|
819
|
+
if (prior && canonicalOwnDataDigest(prior) !== canonicalOwnDataDigest(body)) {
|
|
820
|
+
throw new TaskManagerError('artifact-conflict', `artifact ${body.digest} has conflicting bodies`);
|
|
821
|
+
}
|
|
822
|
+
supplied.set(body.digest, body);
|
|
823
|
+
}
|
|
824
|
+
for (const ref of refs) {
|
|
825
|
+
assertArtifactRef(ref, `artifact ref ${ref.digest}`);
|
|
826
|
+
const body = supplied.get(ref.digest) ?? await owner.readArtifact(taskSpaceId, ref.digest);
|
|
827
|
+
if (!body) throw new TaskManagerError('artifact-not-found', `artifact ${ref.digest} is not present in this owner admission`);
|
|
828
|
+
if (body.digest !== ref.digest || body.mediaType !== ref.mediaType || body.sizeBytes !== ref.sizeBytes) {
|
|
829
|
+
throw new TaskManagerError('artifact-conflict', `artifact ${ref.digest} metadata disagrees with its body`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function effectivePredecessorIds(definition: TaskSpaceDefinition, taskId: TaskId): readonly TaskId[] {
|
|
835
|
+
const result = new Set(predecessorIds(definition, taskId));
|
|
836
|
+
const parentByChild = new Map<TaskId, TaskId | null>();
|
|
837
|
+
const childrenByParent = new Map<TaskId, { readonly taskId: TaskId; readonly order: number }[]>();
|
|
838
|
+
for (const relation of definition.relations) {
|
|
839
|
+
if (relation.kind !== 'parent-child') continue;
|
|
840
|
+
parentByChild.set(relation.childTaskId, relation.parentTaskId);
|
|
841
|
+
if (relation.parentTaskId !== null) {
|
|
842
|
+
const children = childrenByParent.get(relation.parentTaskId) ?? [];
|
|
843
|
+
children.push({ taskId: relation.childTaskId, order: relation.order });
|
|
844
|
+
childrenByParent.set(relation.parentTaskId, children);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
let current: TaskId | null | undefined = taskId;
|
|
848
|
+
while (current !== null && current !== undefined) {
|
|
849
|
+
const parent = parentByChild.get(current);
|
|
850
|
+
if (parent === null || parent === undefined) break;
|
|
851
|
+
for (const predecessor of predecessorIds(definition, parent)) result.add(predecessor);
|
|
852
|
+
const parentDefinition = definition.tasks.find(({ taskId: candidate }) => candidate === parent);
|
|
853
|
+
if (parentDefinition?.kind === 'task-group' && parentDefinition.mode === 'sequential') {
|
|
854
|
+
const siblings = [...(childrenByParent.get(parent) ?? [])]
|
|
855
|
+
.sort((left, right) => left.order - right.order || left.taskId.localeCompare(right.taskId));
|
|
856
|
+
const index = siblings.findIndex(({ taskId: candidate }) => candidate === current);
|
|
857
|
+
if (index > 0) result.add(siblings[index - 1]!.taskId);
|
|
858
|
+
}
|
|
859
|
+
current = parent;
|
|
860
|
+
}
|
|
861
|
+
return [...result];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function stabilizeTaskProjection(
|
|
865
|
+
definition: TaskSpaceDefinition,
|
|
866
|
+
inputTasks: readonly TaskRecord[],
|
|
867
|
+
projectedAt?: TaskTimestamp,
|
|
868
|
+
): TaskRecord[] {
|
|
869
|
+
let tasks = inputTasks.map((task) => ({ ...task }));
|
|
870
|
+
const childrenByParent = new Map<TaskId, TaskId[]>();
|
|
871
|
+
for (const relation of definition.relations) {
|
|
872
|
+
if (relation.kind !== 'parent-child' || relation.parentTaskId === null) continue;
|
|
873
|
+
const children = childrenByParent.get(relation.parentTaskId) ?? [];
|
|
874
|
+
children.push(relation.childTaskId);
|
|
875
|
+
childrenByParent.set(relation.parentTaskId, children);
|
|
876
|
+
}
|
|
877
|
+
for (let iteration = 0; iteration <= tasks.length * 2; iteration += 1) {
|
|
878
|
+
let changed = false;
|
|
879
|
+
const next = tasks.map((task) => {
|
|
880
|
+
let status = task.status;
|
|
881
|
+
if (task.definition.kind === 'task') {
|
|
882
|
+
if (status === 'Pending' || status === 'Ready') {
|
|
883
|
+
status = dependenciesSucceeded(definition, tasks, task.taskId) ? 'Ready' : 'Pending';
|
|
884
|
+
}
|
|
885
|
+
} else {
|
|
886
|
+
const childStatuses = (childrenByParent.get(task.taskId) ?? [])
|
|
887
|
+
.map((childId) => tasks.find(({ taskId }) => taskId === childId)!.status);
|
|
888
|
+
status = projectGroupStatus(task.definition.mode, childStatuses,
|
|
889
|
+
dependenciesSucceeded(definition, tasks, task.taskId));
|
|
890
|
+
if (isTerminal(task.status) && status !== task.status) {
|
|
891
|
+
throw new TaskManagerError('terminal-task-immutable', `task-group ${task.taskId} is terminal (${task.status})`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (status === task.status) return task;
|
|
895
|
+
changed = true;
|
|
896
|
+
return { ...task, status, ...(projectedAt === undefined ? {} : { updatedAt: projectedAt }) };
|
|
897
|
+
});
|
|
898
|
+
tasks = next;
|
|
899
|
+
if (!changed) return tasks;
|
|
900
|
+
}
|
|
901
|
+
throw new TaskManagerError('cyclic-task-forest', 'TaskGroup projection did not converge');
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function projectGroupStatus(
|
|
905
|
+
mode: 'sequential' | 'all' | 'any',
|
|
906
|
+
childStatuses: readonly TaskStatus[],
|
|
907
|
+
dependenciesAreSatisfied: boolean,
|
|
908
|
+
): TaskStatus {
|
|
909
|
+
if (!dependenciesAreSatisfied) return 'Pending';
|
|
910
|
+
if (mode === 'any') {
|
|
911
|
+
if (childStatuses.some((status) => status === 'Succeeded')) return 'Succeeded';
|
|
912
|
+
if (childStatuses.every(isTerminal)) return 'Failed';
|
|
913
|
+
} else {
|
|
914
|
+
if (childStatuses.some((status) => status === 'Failed' || status === 'Cancelled')) return 'Failed';
|
|
915
|
+
if (childStatuses.every((status) => status === 'Succeeded')) return 'Succeeded';
|
|
916
|
+
}
|
|
917
|
+
if (childStatuses.some((status) => status === 'Claimed' || status === 'Running')) return 'Running';
|
|
918
|
+
if (childStatuses.some((status) => status === 'Waiting')) return 'Waiting';
|
|
919
|
+
if (childStatuses.some((status) => status === 'Ready')) return 'Ready';
|
|
920
|
+
return 'Pending';
|
|
921
|
+
}
|