@zq-silk/yui 0.6.0 → 0.6.2
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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { requireIdentity, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
3
|
+
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
4
|
+
export const CURRENT_DURABLE_JOB_SCHEMA_VERSION = 1;
|
|
5
|
+
export const DURABLE_JOB_TERMINAL_STATUSES = [
|
|
6
|
+
"succeeded",
|
|
7
|
+
"failed",
|
|
8
|
+
"timed-out",
|
|
9
|
+
"cancelled",
|
|
10
|
+
"unknown-needs-attention"
|
|
11
|
+
];
|
|
12
|
+
export function isDurableJobTerminal(status) {
|
|
13
|
+
return DURABLE_JOB_TERMINAL_STATUSES.includes(status);
|
|
14
|
+
}
|
|
15
|
+
export function createDurableJob(input, now) {
|
|
16
|
+
const timestamp = now.toISOString();
|
|
17
|
+
const idempotencyKey = input.retryOf === undefined
|
|
18
|
+
? durableJobIdempotencyKey({
|
|
19
|
+
owner: input.owner,
|
|
20
|
+
projectId: input.projectId,
|
|
21
|
+
head: input.head,
|
|
22
|
+
steps: input.steps,
|
|
23
|
+
workspace: input.workspace,
|
|
24
|
+
env: input.env
|
|
25
|
+
})
|
|
26
|
+
: retryDurableJobIdempotencyKey(durableJobIdempotencyKey({
|
|
27
|
+
owner: input.owner,
|
|
28
|
+
projectId: input.projectId,
|
|
29
|
+
head: input.head,
|
|
30
|
+
steps: input.steps,
|
|
31
|
+
workspace: input.workspace,
|
|
32
|
+
env: input.env
|
|
33
|
+
}), input.retryOf);
|
|
34
|
+
return validateDurableJob({
|
|
35
|
+
schemaVersion: CURRENT_DURABLE_JOB_SCHEMA_VERSION,
|
|
36
|
+
id: input.id,
|
|
37
|
+
taskId: input.taskId,
|
|
38
|
+
owner: input.owner,
|
|
39
|
+
projectId: input.projectId,
|
|
40
|
+
head: input.head,
|
|
41
|
+
workspace: input.workspace,
|
|
42
|
+
env: { ...input.env },
|
|
43
|
+
steps: input.steps.map((step) => ({ ...step })),
|
|
44
|
+
idempotencyKey,
|
|
45
|
+
status: "queued",
|
|
46
|
+
artifactsLocator: input.artifactsLocator,
|
|
47
|
+
createdAt: timestamp,
|
|
48
|
+
...(input.retryOf === undefined ? {} : { retryOf: input.retryOf }),
|
|
49
|
+
updatedAt: timestamp
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function startDurableJob(job, process, now) {
|
|
53
|
+
validateDurableJob(job);
|
|
54
|
+
if (job.status !== "queued") {
|
|
55
|
+
throw new Error(`DurableJob can only start from queued: ${job.status}.`);
|
|
56
|
+
}
|
|
57
|
+
const timestamp = now.toISOString();
|
|
58
|
+
return validateDurableJob({
|
|
59
|
+
...job,
|
|
60
|
+
status: "running",
|
|
61
|
+
process: { ...process },
|
|
62
|
+
startedAt: timestamp,
|
|
63
|
+
heartbeatAt: timestamp,
|
|
64
|
+
updatedAt: timestamp
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function touchDurableJobHeartbeat(job, now) {
|
|
68
|
+
validateDurableJob(job);
|
|
69
|
+
if (job.status !== "running") {
|
|
70
|
+
throw new Error(`DurableJob heartbeat only applies to running jobs: ${job.status}.`);
|
|
71
|
+
}
|
|
72
|
+
return validateDurableJob({
|
|
73
|
+
...job,
|
|
74
|
+
heartbeatAt: now.toISOString(),
|
|
75
|
+
updatedAt: now.toISOString()
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function completeDurableJob(job, result, now) {
|
|
79
|
+
validateDurableJob(job);
|
|
80
|
+
if (job.status !== "running") {
|
|
81
|
+
throw new Error(`DurableJob can only complete from running: ${job.status}.`);
|
|
82
|
+
}
|
|
83
|
+
if (!isDurableJobTerminal(result.outcome)) {
|
|
84
|
+
throw new Error(`DurableJob result outcome must be terminal: ${result.outcome}.`);
|
|
85
|
+
}
|
|
86
|
+
const timestamp = now.toISOString();
|
|
87
|
+
return validateDurableJob({
|
|
88
|
+
...job,
|
|
89
|
+
status: result.outcome,
|
|
90
|
+
result: normalizeDurableJobResult(result),
|
|
91
|
+
terminalAt: timestamp,
|
|
92
|
+
updatedAt: timestamp
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export function markDurableJobUnknown(job, unknownReason, completedSteps, now) {
|
|
96
|
+
validateDurableJob(job);
|
|
97
|
+
if (job.status !== "running") {
|
|
98
|
+
throw new Error(`DurableJob can only be marked unknown from running: ${job.status}.`);
|
|
99
|
+
}
|
|
100
|
+
const timestamp = now.toISOString();
|
|
101
|
+
return validateDurableJob({
|
|
102
|
+
...job,
|
|
103
|
+
status: "unknown-needs-attention",
|
|
104
|
+
result: {
|
|
105
|
+
outcome: "unknown-needs-attention",
|
|
106
|
+
exitCode: null,
|
|
107
|
+
signal: null,
|
|
108
|
+
unknownReason: requireText(unknownReason, "DurableJob unknown reason"),
|
|
109
|
+
steps: completedSteps.map((step) => ({ ...step }))
|
|
110
|
+
},
|
|
111
|
+
terminalAt: timestamp,
|
|
112
|
+
updatedAt: timestamp
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function requestDurableJobCancel(job, now) {
|
|
116
|
+
validateDurableJob(job);
|
|
117
|
+
// A terminal job is already past cancellation: return it unchanged so the
|
|
118
|
+
// caller (e.g. `task integration abort` after the job finished but before
|
|
119
|
+
// `integration continue`) gets an idempotent no-op instead of an error.
|
|
120
|
+
if (isDurableJobTerminal(job.status))
|
|
121
|
+
return job;
|
|
122
|
+
if (job.cancelRequestedAt !== undefined)
|
|
123
|
+
return job;
|
|
124
|
+
const timestamp = now.toISOString();
|
|
125
|
+
return validateDurableJob({
|
|
126
|
+
...job,
|
|
127
|
+
cancelRequestedAt: timestamp,
|
|
128
|
+
updatedAt: timestamp
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Cancel a queued job that never started. The supervisor calls this when a
|
|
133
|
+
* queued job has `cancelRequestedAt` so it converges to `cancelled` without
|
|
134
|
+
* spawning a runner. A queued job has no steps to prove, so the result is
|
|
135
|
+
* an empty cancelled outcome.
|
|
136
|
+
*/
|
|
137
|
+
export function cancelQueuedDurableJob(job, now) {
|
|
138
|
+
validateDurableJob(job);
|
|
139
|
+
if (job.status !== "queued") {
|
|
140
|
+
throw new Error(`DurableJob can only cancel-queued from queued: ${job.status}.`);
|
|
141
|
+
}
|
|
142
|
+
const timestamp = now.toISOString();
|
|
143
|
+
return validateDurableJob({
|
|
144
|
+
...job,
|
|
145
|
+
status: "cancelled",
|
|
146
|
+
result: {
|
|
147
|
+
outcome: "cancelled",
|
|
148
|
+
exitCode: null,
|
|
149
|
+
signal: null,
|
|
150
|
+
steps: []
|
|
151
|
+
},
|
|
152
|
+
terminalAt: timestamp,
|
|
153
|
+
updatedAt: timestamp
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
export function markDurableJobWakeupNotified(job, now) {
|
|
157
|
+
validateDurableJob(job);
|
|
158
|
+
if (!isDurableJobTerminal(job.status)) {
|
|
159
|
+
throw new Error("DurableJob wakeup notification requires a terminal job.");
|
|
160
|
+
}
|
|
161
|
+
if (job.wakeupNotified === true)
|
|
162
|
+
return job;
|
|
163
|
+
return validateDurableJob({
|
|
164
|
+
...job,
|
|
165
|
+
wakeupNotified: true,
|
|
166
|
+
updatedAt: now.toISOString()
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Acknowledge an unknown-needs-attention job. The Leader has inspected the
|
|
171
|
+
* runner's evidence and accepts responsibility for follow-up; the job no
|
|
172
|
+
* longer blocks Task lifecycle gates. Only unknown jobs can be acknowledged,
|
|
173
|
+
* and the flag is one-way (a terminal job stays terminal).
|
|
174
|
+
*/
|
|
175
|
+
export function acknowledgeUnknownDurableJob(job, now) {
|
|
176
|
+
validateDurableJob(job);
|
|
177
|
+
if (job.status !== "unknown-needs-attention") {
|
|
178
|
+
throw new Error(`DurableJob can only acknowledge from unknown-needs-attention: ${job.status}.`);
|
|
179
|
+
}
|
|
180
|
+
if (job.acknowledgedAt !== undefined)
|
|
181
|
+
return job;
|
|
182
|
+
return validateDurableJob({
|
|
183
|
+
...job,
|
|
184
|
+
acknowledgedAt: now.toISOString(),
|
|
185
|
+
updatedAt: now.toISOString()
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
export function durableJobIdempotencyKey(input) {
|
|
189
|
+
const canonical = canonicalJson({
|
|
190
|
+
owner: input.owner,
|
|
191
|
+
projectId: input.projectId,
|
|
192
|
+
head: input.head,
|
|
193
|
+
steps: input.steps,
|
|
194
|
+
workspace: input.workspace,
|
|
195
|
+
env: input.env
|
|
196
|
+
});
|
|
197
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
198
|
+
}
|
|
199
|
+
export function retryDurableJobIdempotencyKey(previousKey, previousJobId) {
|
|
200
|
+
return createHash("sha256")
|
|
201
|
+
.update(`${previousKey}|retry-of|${previousJobId}`)
|
|
202
|
+
.digest("hex");
|
|
203
|
+
}
|
|
204
|
+
export function validateDurableJob(job) {
|
|
205
|
+
if (job.schemaVersion !== CURRENT_DURABLE_JOB_SCHEMA_VERSION) {
|
|
206
|
+
throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
|
|
207
|
+
}
|
|
208
|
+
validateTaskRecordReference({
|
|
209
|
+
taskId: job.taskId,
|
|
210
|
+
localId: job.id
|
|
211
|
+
}, "durableJob");
|
|
212
|
+
validateDurableJobOwner(job.owner, job.taskId);
|
|
213
|
+
requireIdentity(job.projectId, "DurableJob project id");
|
|
214
|
+
requireCommit(job.head, "DurableJob head");
|
|
215
|
+
requireText(job.workspace, "DurableJob workspace");
|
|
216
|
+
if (!job.workspace.startsWith("/")) {
|
|
217
|
+
throw new Error("DurableJob workspace must be an absolute path.");
|
|
218
|
+
}
|
|
219
|
+
validateEnvMap(job.env);
|
|
220
|
+
if (!Array.isArray(job.steps) || job.steps.length === 0) {
|
|
221
|
+
throw new Error("DurableJob requires at least one step.");
|
|
222
|
+
}
|
|
223
|
+
const stepNames = new Set();
|
|
224
|
+
for (const step of job.steps) {
|
|
225
|
+
const name = requireText(step.name, "DurableJob step name");
|
|
226
|
+
if (stepNames.has(name)) {
|
|
227
|
+
throw new Error(`DurableJob step names must be unique: ${name}.`);
|
|
228
|
+
}
|
|
229
|
+
stepNames.add(name);
|
|
230
|
+
requireText(step.command, `DurableJob step command (${name})`);
|
|
231
|
+
if (step.argv !== undefined) {
|
|
232
|
+
if (!Array.isArray(step.argv) || step.argv.length === 0) {
|
|
233
|
+
throw new Error(`DurableJob step argv must be a non-empty array (${name}).`);
|
|
234
|
+
}
|
|
235
|
+
for (const value of step.argv) {
|
|
236
|
+
requireText(value, `DurableJob step argv (${name})`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (step.cwd !== undefined) {
|
|
240
|
+
requireText(step.cwd, `DurableJob step cwd (${name})`);
|
|
241
|
+
if (!step.cwd.startsWith("/")) {
|
|
242
|
+
throw new Error(`DurableJob step cwd must be absolute (${name}).`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (step.env !== undefined) {
|
|
246
|
+
validateEnvMap(step.env);
|
|
247
|
+
}
|
|
248
|
+
if (step.timeoutMs !== undefined) {
|
|
249
|
+
requirePositiveInteger(step.timeoutMs, `DurableJob step timeout (${name})`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
requireText(job.idempotencyKey, "DurableJob idempotency key");
|
|
253
|
+
if (!/^[a-f0-9]{64}$/u.test(job.idempotencyKey)) {
|
|
254
|
+
throw new Error("DurableJob idempotency key is invalid.");
|
|
255
|
+
}
|
|
256
|
+
if (![
|
|
257
|
+
"queued",
|
|
258
|
+
"running",
|
|
259
|
+
"succeeded",
|
|
260
|
+
"failed",
|
|
261
|
+
"timed-out",
|
|
262
|
+
"cancelled",
|
|
263
|
+
"unknown-needs-attention"
|
|
264
|
+
].includes(job.status)) {
|
|
265
|
+
throw new Error(`DurableJob status is invalid: ${String(job.status)}.`);
|
|
266
|
+
}
|
|
267
|
+
requireText(job.artifactsLocator, "DurableJob artifacts locator");
|
|
268
|
+
if (job.artifactsLocator.startsWith("/") || job.artifactsLocator.includes("..")) {
|
|
269
|
+
throw new Error("DurableJob artifacts locator must be a home-relative path.");
|
|
270
|
+
}
|
|
271
|
+
requireTimestamp(job.createdAt, "DurableJob createdAt");
|
|
272
|
+
requireTimestamp(job.updatedAt, "DurableJob updatedAt");
|
|
273
|
+
if (job.startedAt !== undefined) {
|
|
274
|
+
requireTimestamp(job.startedAt, "DurableJob startedAt");
|
|
275
|
+
}
|
|
276
|
+
if (job.heartbeatAt !== undefined) {
|
|
277
|
+
requireTimestamp(job.heartbeatAt, "DurableJob heartbeatAt");
|
|
278
|
+
}
|
|
279
|
+
if (job.terminalAt !== undefined) {
|
|
280
|
+
requireTimestamp(job.terminalAt, "DurableJob terminalAt");
|
|
281
|
+
}
|
|
282
|
+
if (job.process !== undefined) {
|
|
283
|
+
requirePositiveInteger(job.process.pid, "DurableJob process pid");
|
|
284
|
+
requireText(job.process.startIdentity, "DurableJob process startIdentity");
|
|
285
|
+
}
|
|
286
|
+
if (job.checkpoint !== undefined) {
|
|
287
|
+
requireTimestamp(job.checkpoint.updatedAt, "DurableJob checkpoint updatedAt");
|
|
288
|
+
for (const step of job.checkpoint.completedSteps) {
|
|
289
|
+
validateStepResult(step);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (job.result !== undefined) {
|
|
293
|
+
normalizeDurableJobResult(job.result);
|
|
294
|
+
}
|
|
295
|
+
if (job.retryOf !== undefined) {
|
|
296
|
+
validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
|
|
297
|
+
}
|
|
298
|
+
if (job.cancelRequestedAt !== undefined) {
|
|
299
|
+
requireTimestamp(job.cancelRequestedAt, "DurableJob cancelRequestedAt");
|
|
300
|
+
}
|
|
301
|
+
if (job.wakeupNotified !== undefined && typeof job.wakeupNotified !== "boolean") {
|
|
302
|
+
throw new Error("DurableJob wakeupNotified must be a boolean.");
|
|
303
|
+
}
|
|
304
|
+
if (job.acknowledgedAt !== undefined) {
|
|
305
|
+
requireTimestamp(job.acknowledgedAt, "DurableJob acknowledgedAt");
|
|
306
|
+
}
|
|
307
|
+
if (isDurableJobTerminal(job.status)) {
|
|
308
|
+
requireTimestamp(job.terminalAt ?? "", "DurableJob terminalAt");
|
|
309
|
+
}
|
|
310
|
+
if (job.status === "queued" && job.startedAt !== undefined) {
|
|
311
|
+
throw new Error("A queued DurableJob must not have startedAt.");
|
|
312
|
+
}
|
|
313
|
+
if (job.status === "running" && job.startedAt === undefined) {
|
|
314
|
+
throw new Error("A running DurableJob must have startedAt.");
|
|
315
|
+
}
|
|
316
|
+
return job;
|
|
317
|
+
}
|
|
318
|
+
export function validDurableJobTransition(before, after) {
|
|
319
|
+
if (before.id !== after.id
|
|
320
|
+
|| before.taskId !== after.taskId
|
|
321
|
+
|| before.projectId !== after.projectId
|
|
322
|
+
|| before.head !== after.head
|
|
323
|
+
|| before.workspace !== after.workspace
|
|
324
|
+
|| before.idempotencyKey !== after.idempotencyKey
|
|
325
|
+
|| before.artifactsLocator !== after.artifactsLocator
|
|
326
|
+
|| before.createdAt !== after.createdAt
|
|
327
|
+
|| !isDeepStrictEqual(before.owner, after.owner)
|
|
328
|
+
|| !isDeepStrictEqual(before.env, after.env)
|
|
329
|
+
|| !isDeepStrictEqual(before.steps, after.steps))
|
|
330
|
+
return false;
|
|
331
|
+
if (isDurableJobTerminal(before.status)) {
|
|
332
|
+
// A terminal job is immutable except for two one-way flags:
|
|
333
|
+
// wakeupNotified (set by the supervisor after enqueuing the Leader
|
|
334
|
+
// wakeup) and acknowledgedAt (set by the Leader on an unknown job so
|
|
335
|
+
// Task lifecycle gates can proceed). Without these exceptions the
|
|
336
|
+
// flags could never be recorded and every terminal/unknown job would
|
|
337
|
+
// block forever.
|
|
338
|
+
const wakeupFlip = before.wakeupNotified !== true && after.wakeupNotified === true;
|
|
339
|
+
const acknowledgeFlip = before.acknowledgedAt === undefined
|
|
340
|
+
&& after.acknowledgedAt !== undefined;
|
|
341
|
+
return before.status === after.status
|
|
342
|
+
&& isDeepStrictEqual(before.result, after.result)
|
|
343
|
+
&& before.terminalAt === after.terminalAt
|
|
344
|
+
&& (before.wakeupNotified === after.wakeupNotified || wakeupFlip)
|
|
345
|
+
&& (before.acknowledgedAt === after.acknowledgedAt || acknowledgeFlip);
|
|
346
|
+
}
|
|
347
|
+
const allowed = {
|
|
348
|
+
queued: ["queued", "running", "cancelled"],
|
|
349
|
+
running: [
|
|
350
|
+
"running",
|
|
351
|
+
"succeeded",
|
|
352
|
+
"failed",
|
|
353
|
+
"timed-out",
|
|
354
|
+
"cancelled",
|
|
355
|
+
"unknown-needs-attention"
|
|
356
|
+
]
|
|
357
|
+
};
|
|
358
|
+
return (allowed[before.status] ?? []).includes(after.status);
|
|
359
|
+
}
|
|
360
|
+
function validateDurableJobOwner(owner, taskId) {
|
|
361
|
+
if (owner.kind === "integration-attempt") {
|
|
362
|
+
validateTaskRecordReference({ taskId, localId: owner.integrationAttemptId }, "integrationAttempt");
|
|
363
|
+
}
|
|
364
|
+
else if (owner.kind === "work-item") {
|
|
365
|
+
validateTaskRecordReference({ taskId, localId: owner.workItemId }, "workItem");
|
|
366
|
+
}
|
|
367
|
+
else if (owner.kind !== "task") {
|
|
368
|
+
throw new Error(`DurableJob owner kind is invalid: ${String(owner.kind)}.`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function validateEnvMap(env) {
|
|
372
|
+
if (typeof env !== "object" || env === null || Array.isArray(env)) {
|
|
373
|
+
throw new Error("DurableJob env must be a map.");
|
|
374
|
+
}
|
|
375
|
+
for (const [key, value] of Object.entries(env)) {
|
|
376
|
+
if (typeof key !== "string" || key.length === 0 || key.includes("=") || key.includes("\0")) {
|
|
377
|
+
throw new Error(`DurableJob env key is invalid: ${key}.`);
|
|
378
|
+
}
|
|
379
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
380
|
+
throw new Error(`DurableJob env value is invalid: ${key}.`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function validateStepResult(step) {
|
|
385
|
+
requireText(step.name, "DurableJob step result name");
|
|
386
|
+
if (step.exitCode !== null && (!Number.isSafeInteger(step.exitCode) || step.exitCode < 0)) {
|
|
387
|
+
throw new Error(`DurableJob step exitCode is invalid: ${step.name}.`);
|
|
388
|
+
}
|
|
389
|
+
if (step.signal !== null && typeof step.signal !== "string") {
|
|
390
|
+
throw new Error(`DurableJob step signal is invalid: ${step.name}.`);
|
|
391
|
+
}
|
|
392
|
+
if (typeof step.timedOut !== "boolean") {
|
|
393
|
+
throw new Error(`DurableJob step timedOut must be boolean: ${step.name}.`);
|
|
394
|
+
}
|
|
395
|
+
requirePositiveInteger(step.durationMs, `DurableJob step durationMs (${step.name})`);
|
|
396
|
+
requireText(step.logPath, `DurableJob step logPath (${step.name})`);
|
|
397
|
+
requireCommit(step.head, `DurableJob step head (${step.name})`);
|
|
398
|
+
}
|
|
399
|
+
function normalizeDurableJobResult(result) {
|
|
400
|
+
if (!isDurableJobTerminal(result.outcome)) {
|
|
401
|
+
throw new Error(`DurableJob result outcome must be terminal: ${result.outcome}.`);
|
|
402
|
+
}
|
|
403
|
+
if (result.exitCode !== null && (!Number.isSafeInteger(result.exitCode) || result.exitCode < 0)) {
|
|
404
|
+
throw new Error("DurableJob result exitCode is invalid.");
|
|
405
|
+
}
|
|
406
|
+
if (result.signal !== null && typeof result.signal !== "string") {
|
|
407
|
+
throw new Error("DurableJob result signal is invalid.");
|
|
408
|
+
}
|
|
409
|
+
if (result.failedStep !== undefined) {
|
|
410
|
+
requireText(result.failedStep, "DurableJob result failedStep");
|
|
411
|
+
}
|
|
412
|
+
if (result.unknownReason !== undefined) {
|
|
413
|
+
requireText(result.unknownReason, "DurableJob result unknownReason");
|
|
414
|
+
}
|
|
415
|
+
if (result.evidenceSource !== undefined
|
|
416
|
+
&& result.evidenceSource !== "exit-artifact"
|
|
417
|
+
&& result.evidenceSource !== "checkpoint") {
|
|
418
|
+
throw new Error(`DurableJob result evidenceSource is invalid: ${String(result.evidenceSource)}.`);
|
|
419
|
+
}
|
|
420
|
+
if (!Array.isArray(result.steps)) {
|
|
421
|
+
throw new Error("DurableJob result steps must be an array.");
|
|
422
|
+
}
|
|
423
|
+
for (const step of result.steps)
|
|
424
|
+
validateStepResult(step);
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
function requireCommit(value, label) {
|
|
428
|
+
const normalized = requireText(value, label).toLowerCase();
|
|
429
|
+
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(normalized)) {
|
|
430
|
+
throw new Error(`${label} is invalid.`);
|
|
431
|
+
}
|
|
432
|
+
return normalized;
|
|
433
|
+
}
|
|
434
|
+
function canonicalJson(value) {
|
|
435
|
+
return JSON.stringify(canonicalize(value));
|
|
436
|
+
}
|
|
437
|
+
function canonicalize(value) {
|
|
438
|
+
if (Array.isArray(value))
|
|
439
|
+
return value.map(canonicalize);
|
|
440
|
+
if (typeof value === "object" && value !== null) {
|
|
441
|
+
const entries = Object.entries(value)
|
|
442
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
443
|
+
return Object.fromEntries(entries.map(([key, entry]) => [key, canonicalize(entry)]));
|
|
444
|
+
}
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
447
|
+
function isDeepStrictEqual(left, right) {
|
|
448
|
+
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
449
|
+
}
|