@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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,459 @@
1
+ import { requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
2
+ import { validateTaskRecordReference } from "../task/taskRecordReference.js";
3
+ export const RELEASE_WORKFLOW_SCHEMA_VERSION = 1;
4
+ const RELEASE_STEP_KINDS = new Set([
5
+ "pr-create-or-reuse", "ci-confirm", "merge", "version-tag",
6
+ "npm-publish", "fresh-install-smoke", "cli-update",
7
+ "controller-replace", "project-migrate", "post-verify"
8
+ ]);
9
+ const RELEASE_STEP_STATUSES = new Set([
10
+ "pending", "running", "succeeded", "failed", "unknown", "skipped"
11
+ ]);
12
+ const IRREVERSIBILITY_LEVELS = new Set([
13
+ "none", "reversible", "irreversible"
14
+ ]);
15
+ /** The terminal set for resume purposes: succeeded and skipped never resume. */
16
+ const TERMINAL_STEP_STATUSES = new Set([
17
+ "succeeded", "skipped"
18
+ ]);
19
+ export function createReleaseWorkflow(id, taskId, input, now) {
20
+ const timestamp = now.toISOString();
21
+ const reference = validateTaskRecordReference({ taskId, localId: id }, "releaseWorkflow");
22
+ const grantId = requireText(input.grantId, "Release workflow grantId");
23
+ const source = normalizeSource(input.source);
24
+ const plan = normalizePlan(input.plan, reference.taskId, reference.localId);
25
+ const steps = {};
26
+ for (const entry of plan) {
27
+ steps[entry.id] = Object.freeze({
28
+ planId: entry.id,
29
+ status: "pending",
30
+ attempts: 0,
31
+ logs: Object.freeze([])
32
+ });
33
+ }
34
+ return validateReleaseWorkflow({
35
+ schemaVersion: RELEASE_WORKFLOW_SCHEMA_VERSION,
36
+ id: reference.localId,
37
+ taskId: reference.taskId,
38
+ grantId,
39
+ source,
40
+ plan,
41
+ steps: Object.freeze(steps),
42
+ createdAt: timestamp,
43
+ updatedAt: timestamp
44
+ });
45
+ }
46
+ export function validateReleaseWorkflow(workflow) {
47
+ if (workflow.schemaVersion !== RELEASE_WORKFLOW_SCHEMA_VERSION) {
48
+ throw new Error("Release workflow must use schemaVersion 1.");
49
+ }
50
+ validateTaskRecordReference({ taskId: workflow.taskId, localId: workflow.id }, "releaseWorkflow");
51
+ requireIdentity(workflow.taskId, "Task id");
52
+ requireText(workflow.grantId, "Release workflow grantId");
53
+ normalizeSource(workflow.source);
54
+ const plan = normalizePlan(workflow.plan, workflow.taskId, workflow.id);
55
+ // An npm-publish step is unrecoverable without a content-addressed source
56
+ // artifact: the immutable workflow source can never gain one later, and
57
+ // each resume would consume another grant use before the adapter fails.
58
+ // Reject such plans at creation (and on every load) rather than at run time.
59
+ if (plan.some((entry) => entry.kind === "npm-publish")
60
+ && workflow.source.artifact === undefined) {
61
+ throw new Error("Release workflow npm-publish step requires a source artifact.");
62
+ }
63
+ for (const entry of plan) {
64
+ const stored = workflow.plan.find((candidate) => candidate.id === entry.id);
65
+ if (stored?.idempotencyKey !== entry.idempotencyKey) {
66
+ throw new Error(`Release step idempotencyKey is invalid: ${entry.id}.`);
67
+ }
68
+ }
69
+ if (typeof workflow.steps !== "object" || workflow.steps === null || Array.isArray(workflow.steps)) {
70
+ throw new Error("Release workflow steps must be an object.");
71
+ }
72
+ const planIds = new Set(plan.map((entry) => entry.id));
73
+ const stepKeys = Object.keys(workflow.steps);
74
+ for (const planId of planIds) {
75
+ if (!Object.hasOwn(workflow.steps, planId)) {
76
+ throw new Error(`Release workflow step record is missing: ${planId}.`);
77
+ }
78
+ }
79
+ for (const key of stepKeys) {
80
+ if (!planIds.has(key)) {
81
+ throw new Error(`Release workflow step record has no plan entry: ${key}.`);
82
+ }
83
+ }
84
+ for (const entry of plan) {
85
+ validateStepRecord(workflow.steps[entry.id], entry);
86
+ }
87
+ requireTimestamp(workflow.createdAt, "Release workflow createdAt");
88
+ requireTimestamp(workflow.updatedAt, "Release workflow updatedAt");
89
+ return workflow;
90
+ }
91
+ /**
92
+ * The first plan-index step whose status is not terminal (succeeded/skipped);
93
+ * null when every step is terminal.
94
+ */
95
+ export function resumeCursor(workflow) {
96
+ validateReleaseWorkflow(workflow);
97
+ for (const entry of workflow.plan) {
98
+ const step = workflow.steps[entry.id];
99
+ if (!TERMINAL_STEP_STATUSES.has(step.status)) {
100
+ return entry.id;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+ /** The derived workflow status from its step statuses. */
106
+ export function workflowStatus(workflow) {
107
+ validateReleaseWorkflow(workflow);
108
+ let running = false;
109
+ for (const entry of workflow.plan) {
110
+ const status = workflow.steps[entry.id].status;
111
+ if (status === "failed")
112
+ return "failed";
113
+ if (status === "unknown")
114
+ return "unknown";
115
+ if (status === "running")
116
+ running = true;
117
+ }
118
+ if (running)
119
+ return "running";
120
+ const allTerminal = workflow.plan.every((entry) => TERMINAL_STEP_STATUSES.has(workflow.steps[entry.id].status));
121
+ return allTerminal ? "succeeded" : "pending";
122
+ }
123
+ /** pending|failed|unknown -> running; attempts+1; lastAttemptAt. Illegal otherwise. */
124
+ export function startStep(workflow, stepId, now) {
125
+ validateReleaseWorkflow(workflow);
126
+ const step = requireStep(workflow, stepId);
127
+ if (!["pending", "failed", "unknown"].includes(step.status)) {
128
+ throw new Error(`Release step cannot start from status ${step.status}: ${stepId}.`);
129
+ }
130
+ // Clear attempt-scoped effect/terminal evidence: a new attempt must not
131
+ // inherit the previous attempt's externalIdentity, or the engine would
132
+ // treat the retry as already-paid and skip the maxUses check.
133
+ const { externalId: _oldExternalId, externalIdentity: _oldIdentity, terminalAt: _oldTerminalAt, ...rest } = step;
134
+ return replaceStep(workflow, stepId, {
135
+ ...rest,
136
+ status: "running",
137
+ attempts: step.attempts + 1,
138
+ lastAttemptAt: now.toISOString()
139
+ }, now);
140
+ }
141
+ /** running -> succeeded; terminalAt; appends logs. */
142
+ export function completeStep(workflow, stepId, result, now) {
143
+ validateReleaseWorkflow(workflow);
144
+ const step = requireStep(workflow, stepId);
145
+ if (step.status !== "running") {
146
+ throw new Error(`Release step cannot complete from status ${step.status}: ${stepId}.`);
147
+ }
148
+ const externalId = result.externalId === undefined
149
+ ? undefined
150
+ : requireText(result.externalId, "Release step externalId");
151
+ return replaceStep(workflow, stepId, {
152
+ ...step,
153
+ status: "succeeded",
154
+ ...(externalId === undefined ? {} : { externalId }),
155
+ logs: appendLogs(step.logs, result.logs),
156
+ terminalAt: now.toISOString()
157
+ }, now);
158
+ }
159
+ /** running -> failed; terminalAt; appends logs. An ambiguous failure may carry the externalIdentity for a later authoritative query. */
160
+ export function failStep(workflow, stepId, result, now) {
161
+ validateReleaseWorkflow(workflow);
162
+ const step = requireStep(workflow, stepId);
163
+ if (step.status !== "running") {
164
+ throw new Error(`Release step cannot fail from status ${step.status}: ${stepId}.`);
165
+ }
166
+ const externalIdentity = result.externalIdentity === undefined
167
+ ? undefined
168
+ : normalizeExternalIdentity(result.externalIdentity);
169
+ return replaceStep(workflow, stepId, {
170
+ ...step,
171
+ status: "failed",
172
+ ...(externalIdentity === undefined ? {} : { externalIdentity }),
173
+ logs: appendLogs(step.logs, result.logs),
174
+ terminalAt: now.toISOString()
175
+ }, now);
176
+ }
177
+ /** running -> unknown; terminalAt NOT set (unknown is not terminal). An
178
+ * externalIdentity is recorded when known; without one this is a crash-recovery
179
+ * state (the effect may have landed but cannot be queried), and the engine
180
+ * fails closed (unconfirmed) on resume. */
181
+ export function markStepUnknown(workflow, stepId, result, now) {
182
+ validateReleaseWorkflow(workflow);
183
+ const step = requireStep(workflow, stepId);
184
+ if (step.status !== "running") {
185
+ throw new Error(`Release step cannot be unknown from status ${step.status}: ${stepId}.`);
186
+ }
187
+ const externalIdentity = result.externalIdentity === undefined
188
+ ? undefined
189
+ : normalizeExternalIdentity(result.externalIdentity);
190
+ return replaceStep(workflow, stepId, {
191
+ ...step,
192
+ status: "unknown",
193
+ ...(externalIdentity === undefined ? {} : { externalIdentity }),
194
+ logs: appendLogs(step.logs, result.logs)
195
+ }, now);
196
+ }
197
+ /** pending -> skipped; terminalAt; appends a log line with the reason. */
198
+ export function skipStep(workflow, stepId, result, now) {
199
+ validateReleaseWorkflow(workflow);
200
+ const step = requireStep(workflow, stepId);
201
+ if (step.status !== "pending") {
202
+ throw new Error(`Release step cannot skip from status ${step.status}: ${stepId}.`);
203
+ }
204
+ const reason = requireText(result.reason, "Release step skip reason");
205
+ return replaceStep(workflow, stepId, {
206
+ ...step,
207
+ status: "skipped",
208
+ logs: appendLogs(step.logs, [`Skipped: ${reason}`]),
209
+ terminalAt: now.toISOString()
210
+ }, now);
211
+ }
212
+ /**
213
+ * Authoritative confirmation after a timeout: an external query proved the
214
+ * effect exists, so an unknown step reaches terminal success WITHOUT a second
215
+ * submission. unknown -> succeeded; terminalAt; appends logs.
216
+ */
217
+ export function confirmStep(workflow, stepId, result, now) {
218
+ validateReleaseWorkflow(workflow);
219
+ const step = requireStep(workflow, stepId);
220
+ if (step.status !== "unknown") {
221
+ throw new Error(`Release step can only confirm from unknown: ${stepId} (${step.status}).`);
222
+ }
223
+ const externalId = result.externalId === undefined
224
+ ? step.externalId
225
+ : requireText(result.externalId, "Release step externalId");
226
+ return replaceStep(workflow, stepId, {
227
+ ...step,
228
+ status: "succeeded",
229
+ ...(externalId === undefined ? {} : { externalId }),
230
+ logs: appendLogs(step.logs, result.logs),
231
+ terminalAt: now.toISOString()
232
+ }, now);
233
+ }
234
+ /**
235
+ * Authoritative confirmation of an ambiguously failed step: a transport error
236
+ * left the step `failed` with an externalIdentity, and a later query proved the
237
+ * effect landed. failed -> succeeded; terminalAt; appends logs. This is the
238
+ * only transition out of `failed` that does not re-submit.
239
+ */
240
+ export function confirmFailedStep(workflow, stepId, result, now) {
241
+ validateReleaseWorkflow(workflow);
242
+ const step = requireStep(workflow, stepId);
243
+ if (step.status !== "failed") {
244
+ throw new Error(`Release step can only confirm-failed from failed: ${stepId} (${step.status}).`);
245
+ }
246
+ if (step.externalIdentity === undefined) {
247
+ throw new Error(`Release step confirm-failed requires an externalIdentity: ${stepId}.`);
248
+ }
249
+ const externalId = result.externalId === undefined
250
+ ? step.externalId
251
+ : requireText(result.externalId, "Release step externalId");
252
+ return replaceStep(workflow, stepId, {
253
+ ...step,
254
+ status: "succeeded",
255
+ ...(externalId === undefined ? {} : { externalId }),
256
+ logs: appendLogs(step.logs, result.logs),
257
+ terminalAt: now.toISOString()
258
+ }, now);
259
+ }
260
+ /**
261
+ * Crash recovery for a step left `running` by a dead process: the attempt may
262
+ * have landed, so the engine queries authoritatively first; only when the
263
+ * effect is confirmed absent does it re-attempt via this transition.
264
+ * running -> running; attempts+1; fresh lastAttemptAt.
265
+ */
266
+ export function recoverRunningStep(workflow, stepId, now) {
267
+ validateReleaseWorkflow(workflow);
268
+ const step = requireStep(workflow, stepId);
269
+ if (step.status !== "running") {
270
+ throw new Error(`Release step can only recover from running: ${stepId} (${step.status}).`);
271
+ }
272
+ // Clear attempt-scoped effect/terminal evidence: a recovery attempt must not
273
+ // inherit the previous attempt's externalIdentity, or the engine would treat
274
+ // the retry as already-paid and skip the maxUses check.
275
+ const { externalId: _oldExternalId, externalIdentity: _oldIdentity, terminalAt: _oldTerminalAt, ...rest } = step;
276
+ return replaceStep(workflow, stepId, {
277
+ ...rest,
278
+ status: "running",
279
+ attempts: step.attempts + 1,
280
+ lastAttemptAt: now.toISOString()
281
+ }, now);
282
+ }
283
+ /**
284
+ * Supersede the grant binding (the old grant was revoked or exhausted, or a
285
+ * broader grant was issued). The plan, source, and step evidence are immutable.
286
+ */
287
+ export function rebindGrant(workflow, grantId, now) {
288
+ validateReleaseWorkflow(workflow);
289
+ const next = requireText(grantId, "Release workflow grantId");
290
+ return validateReleaseWorkflow({
291
+ ...workflow,
292
+ grantId: next,
293
+ updatedAt: now.toISOString()
294
+ });
295
+ }
296
+ /** Appends a log line to any non-terminal step. */
297
+ export function appendStepLog(workflow, stepId, line) {
298
+ validateReleaseWorkflow(workflow);
299
+ const step = requireStep(workflow, stepId);
300
+ if (TERMINAL_STEP_STATUSES.has(step.status)) {
301
+ throw new Error(`Release step is terminal and cannot log: ${stepId}.`);
302
+ }
303
+ const entry = requireText(line, "Release step log line");
304
+ return replaceStep(workflow, stepId, {
305
+ ...step,
306
+ logs: appendLogs(step.logs, [entry])
307
+ }, undefined);
308
+ }
309
+ function replaceStep(workflow, stepId, next, now) {
310
+ const steps = Object.freeze({
311
+ ...workflow.steps,
312
+ [stepId]: Object.freeze({ ...next, logs: Object.freeze([...next.logs]) })
313
+ });
314
+ return validateReleaseWorkflow({
315
+ ...workflow,
316
+ steps,
317
+ ...(now === undefined ? {} : { updatedAt: now.toISOString() })
318
+ });
319
+ }
320
+ function requireStep(workflow, stepId) {
321
+ const step = workflow.steps[stepId];
322
+ if (step === undefined) {
323
+ throw new Error(`Release step is not part of the workflow plan: ${stepId}.`);
324
+ }
325
+ return step;
326
+ }
327
+ function appendLogs(logs, extra) {
328
+ if (extra === undefined)
329
+ return logs;
330
+ if (!Array.isArray(extra)) {
331
+ throw new Error("Release step logs must be an array.");
332
+ }
333
+ const lines = extra.map((line) => requireText(line, "Release step log line"));
334
+ return Object.freeze([...logs, ...lines]);
335
+ }
336
+ function normalizeSource(source) {
337
+ if (typeof source !== "object" || source === null || Array.isArray(source)) {
338
+ throw new Error("Release workflow source must be an object.");
339
+ }
340
+ if (typeof source.repository !== "object" || source.repository === null || Array.isArray(source.repository)) {
341
+ throw new Error("Release workflow source repository must be an object.");
342
+ }
343
+ const repository = Object.freeze({
344
+ owner: requireText(source.repository.owner, "Release workflow source repository owner"),
345
+ name: requireText(source.repository.name, "Release workflow source repository name")
346
+ });
347
+ const commit = requireText(source.commit, "Release workflow source commit");
348
+ if (!/^[0-9a-fA-F]{40}$/.test(commit)) {
349
+ throw new Error(`Release workflow source commit must be a full 40-hex commit sha: ${commit}`);
350
+ }
351
+ if (source.artifact === undefined) {
352
+ return Object.freeze({ repository, commit });
353
+ }
354
+ if (typeof source.artifact !== "object" || source.artifact === null || Array.isArray(source.artifact)) {
355
+ throw new Error("Release workflow source artifact must be an object.");
356
+ }
357
+ return Object.freeze({
358
+ repository,
359
+ commit,
360
+ artifact: Object.freeze({
361
+ name: requireText(source.artifact.name, "Release workflow source artifact name"),
362
+ integrity: requireText(source.artifact.integrity, "Release workflow source artifact integrity")
363
+ })
364
+ });
365
+ }
366
+ function normalizePlan(plan, taskId, workflowId) {
367
+ if (!Array.isArray(plan) || plan.length === 0) {
368
+ throw new Error("Release workflow plan must be a non-empty array.");
369
+ }
370
+ const normalized = plan.map((entry) => {
371
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
372
+ throw new Error("Release workflow plan entry must be an object.");
373
+ }
374
+ const id = requireIdentity(entry.id, "Release step id");
375
+ if (!RELEASE_STEP_KINDS.has(entry.kind)) {
376
+ throw new Error(`Release step kind is invalid: ${String(entry.kind)}.`);
377
+ }
378
+ const params = normalizeParams(entry.params);
379
+ if (entry.irreversibility !== undefined && !IRREVERSIBILITY_LEVELS.has(entry.irreversibility)) {
380
+ throw new Error(`Release step irreversibility is invalid: ${String(entry.irreversibility)}.`);
381
+ }
382
+ const frozen = Object.freeze({
383
+ id,
384
+ kind: entry.kind,
385
+ idempotencyKey: `${taskId}/${workflowId}/${id}`,
386
+ ...(params === undefined ? {} : { params }),
387
+ ...(entry.irreversibility === undefined ? {} : { irreversibility: entry.irreversibility })
388
+ });
389
+ return frozen;
390
+ });
391
+ const ids = new Set(normalized.map((entry) => entry.id));
392
+ if (ids.size !== normalized.length) {
393
+ throw new Error("Release workflow plan ids must be unique.");
394
+ }
395
+ return Object.freeze(normalized);
396
+ }
397
+ function normalizeParams(params) {
398
+ if (params === undefined)
399
+ return undefined;
400
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
401
+ throw new Error("Release step params must be an object.");
402
+ }
403
+ const normalized = {};
404
+ for (const [name, value] of Object.entries(params)) {
405
+ const key = requireIdentity(name, "Release step param");
406
+ normalized[key] = requireText(value, `Release step param ${name}`);
407
+ }
408
+ return Object.freeze(normalized);
409
+ }
410
+ function validateStepRecord(step, plan) {
411
+ if (typeof step !== "object" || step === null || Array.isArray(step)) {
412
+ throw new Error(`Release step record must be an object: ${plan.id}.`);
413
+ }
414
+ if (step.planId !== plan.id) {
415
+ throw new Error(`Release step record planId ${step.planId} does not match its key: ${plan.id}.`);
416
+ }
417
+ if (!RELEASE_STEP_STATUSES.has(step.status)) {
418
+ throw new Error(`Release step status is invalid: ${String(step.status)}.`);
419
+ }
420
+ if (!Number.isSafeInteger(step.attempts) || step.attempts < 0) {
421
+ throw new Error(`Release step attempts must be a non-negative integer: ${plan.id}.`);
422
+ }
423
+ if (step.externalId !== undefined) {
424
+ requireText(step.externalId, `Release step externalId ${plan.id}`);
425
+ }
426
+ if (step.externalIdentity !== undefined) {
427
+ normalizeExternalIdentity(step.externalIdentity);
428
+ }
429
+ // An unknown step without an externalIdentity is a crash-recovery state:
430
+ // the step may have produced an external effect, but we can't query it.
431
+ // The engine fails closed (unconfirmed) in this case.
432
+ if (!Array.isArray(step.logs)) {
433
+ throw new Error(`Release step logs must be an array: ${plan.id}.`);
434
+ }
435
+ for (const line of step.logs) {
436
+ requireText(line, `Release step log ${plan.id}`);
437
+ }
438
+ if (step.lastAttemptAt !== undefined) {
439
+ requireTimestamp(step.lastAttemptAt, `Release step lastAttemptAt ${plan.id}`);
440
+ }
441
+ if (step.status === "running" && step.lastAttemptAt === undefined) {
442
+ throw new Error(`Release step running status requires lastAttemptAt: ${plan.id}.`);
443
+ }
444
+ if (step.terminalAt !== undefined) {
445
+ requireTimestamp(step.terminalAt, `Release step terminalAt ${plan.id}`);
446
+ }
447
+ if (TERMINAL_STEP_STATUSES.has(step.status) && step.terminalAt === undefined) {
448
+ throw new Error(`Release step ${step.status} status requires terminalAt: ${plan.id}.`);
449
+ }
450
+ }
451
+ function normalizeExternalIdentity(value) {
452
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
453
+ throw new Error("Release step externalIdentity must be an object.");
454
+ }
455
+ return Object.freeze({
456
+ kind: requireText(value.kind, "Release step externalIdentity kind"),
457
+ value: requireText(value.value, "Release step externalIdentity value")
458
+ });
459
+ }