akm-cli 0.9.11 → 0.9.13
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/CHANGELOG.md +227 -0
- package/STABILITY.md +6 -1
- package/dist/assets/hints/cli-hints-full.md +1 -1
- package/dist/assets/improve-strategies/consolidate.json +1 -1
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/thorough.json +1 -2
- package/dist/assets/workflows/workflow-template.md +4 -0
- package/dist/cli/shared.js +16 -4
- package/dist/cli.js +15 -13
- package/dist/commands/agent/agent-dispatch.js +8 -0
- package/dist/commands/command/execution-source-loader.js +25 -22
- package/dist/commands/command/portable-template.js +4 -26
- package/dist/commands/config-cli.js +10 -4
- package/dist/commands/env/env-binding.js +10 -3
- package/dist/commands/env/env-cli.js +7 -0
- package/dist/commands/env/secret-cli.js +15 -4
- package/dist/commands/health/checks.js +186 -71
- package/dist/commands/health.js +16 -4
- package/dist/commands/improve/distill/quality-gate.js +2 -2
- package/dist/commands/improve/distill.js +28 -12
- package/dist/commands/improve/execution.js +1 -2
- package/dist/commands/improve/extract.js +82 -56
- package/dist/commands/improve/improve-strategies.js +26 -8
- package/dist/commands/improve/improve.js +14 -0
- package/dist/commands/improve/preparation.js +9 -6
- package/dist/commands/improve/reflect.js +61 -77
- package/dist/commands/lint/base-linter.js +10 -0
- package/dist/commands/lint/index.js +3 -1
- package/dist/commands/migrate-cli.js +6 -4
- package/dist/commands/proposal/drain-policies.js +22 -2
- package/dist/commands/proposal/drain.js +48 -6
- package/dist/commands/proposal/proposal-cli.js +1 -0
- package/dist/commands/proposal/repository.js +4 -4
- package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
- package/dist/commands/proposal/validators/proposals.js +10 -19
- package/dist/commands/read/show.js +42 -31
- package/dist/commands/registry-cli.js +4 -2
- package/dist/commands/sources/init.js +4 -8
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-clone.js +5 -7
- package/dist/commands/sources/sources-cli.js +3 -5
- package/dist/commands/tasks/tasks-cli.js +4 -12
- package/dist/commands/tasks/tasks.js +38 -35
- package/dist/commands/workflow-cli.js +17 -15
- package/dist/core/activation-policy.js +31 -3
- package/dist/core/adapter/execution-source.js +39 -11
- package/dist/core/asset/stash-meta.js +7 -41
- package/dist/core/common.js +8 -17
- package/dist/core/config/config-schema.js +3 -23
- package/dist/core/config/config-walker.js +56 -6
- package/dist/core/config/config.js +42 -17
- package/dist/core/config/legacy-source-shape-shim.js +79 -0
- package/dist/core/config/schema/embedding.js +2 -2
- package/dist/core/config/schema/engines.js +2 -2
- package/dist/core/config/schema/index-config.js +19 -21
- package/dist/core/config/schema/primitives.js +27 -10
- package/dist/core/config/schema/sources-bundles.js +1 -6
- package/dist/core/errors.js +4 -3
- package/dist/core/improve-types.js +17 -0
- package/dist/core/json-schema.js +1 -11
- package/dist/core/maintenance-barrier.js +17 -2
- package/dist/core/paths.js +12 -15
- package/dist/core/state/migrations.js +28 -0
- package/dist/core/state-db.js +28 -1
- package/dist/core/write-source.js +6 -6
- package/dist/indexer/bundle-identity-guard.js +3 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/indexer.js +11 -3
- package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
- package/dist/indexer/passes/metadata.js +16 -5
- package/dist/indexer/search/search-fields.js +1 -30
- package/dist/integrations/agent/engine-resolution.js +15 -1
- package/dist/integrations/agent/model-map.js +16 -10
- package/dist/integrations/agent/prompts.js +13 -6
- package/dist/integrations/lockfile.js +22 -7
- package/dist/llm/client.js +28 -8
- package/dist/llm/embedders/remote.js +3 -2
- package/dist/llm/index-passes.js +3 -2
- package/dist/output/shapes/passthrough.js +9 -3
- package/dist/output/shapes.js +50 -3
- package/dist/output/text/proposal-format.js +5 -0
- package/dist/output/text/workflow-format.js +8 -1
- package/dist/scripts/akm-migrate-node.js +1737 -1392
- package/dist/scripts/akm-migrate.js +1736 -1391
- package/dist/setup/setup.js +14 -21
- package/dist/sources/include.js +150 -20
- package/dist/sources/providers/git-install.js +14 -12
- package/dist/sources/providers/git-provider.js +3 -3
- package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
- package/dist/sources/website-url.js +12 -4
- package/dist/storage/engines/sqlite-migrations.js +40 -10
- package/dist/storage/like-pattern.js +7 -0
- package/dist/storage/repositories/extract-sessions-repository.js +23 -0
- package/dist/storage/repositories/index-connection.js +27 -10
- package/dist/storage/repositories/index-entry-schema.js +19 -2
- package/dist/storage/repositories/index-schema.js +30 -9
- package/dist/storage/repositories/proposals-repository.js +2 -1
- package/dist/storage/repositories/task-history-repository.js +14 -7
- package/dist/storage/repositories/workflow-runs-repository.js +133 -11
- package/dist/storage/sqlite-read-snapshot.js +11 -9
- package/dist/tasks/backends/cron.js +34 -5
- package/dist/tasks/backends/launchd.js +23 -26
- package/dist/tasks/backends/schtasks.js +50 -3
- package/dist/tasks/frozen-script.js +2 -0
- package/dist/tasks/prepare/prepare.js +2 -7
- package/dist/tasks/prepare/script-capture.js +38 -6
- package/dist/tasks/schedule.js +154 -13
- package/dist/tasks/source/task-source-v3-frozen.js +0 -1
- package/dist/tasks/source/task-source-v4.js +0 -1
- package/dist/workflows/exec/child-workflow.js +2 -3
- package/dist/workflows/exec/exec-unit.js +3 -4
- package/dist/workflows/exec/run-workflow.js +20 -11
- package/dist/workflows/exec/step-work.js +76 -56
- package/dist/workflows/freeze/resolve-steps.js +19 -11
- package/dist/workflows/freeze/source-freeze.js +7 -0
- package/dist/workflows/freeze/targets/child-workflow.js +12 -18
- package/dist/workflows/freeze/targets/command.js +14 -2
- package/dist/workflows/ir/environment-v4.js +4 -2
- package/dist/workflows/ir/freeze-v4.js +2 -5
- package/dist/workflows/ir/plan-hash.js +0 -3
- package/dist/workflows/ir/schema-v4.js +14 -9
- package/dist/workflows/ir/schema.js +1 -3
- package/dist/workflows/parser.js +1 -1
- package/dist/workflows/resource-limits.js +35 -48
- package/dist/workflows/runtime/plan-classifier.js +89 -41
- package/dist/workflows/runtime/run-outputs.js +1 -21
- package/dist/workflows/runtime/runs.js +104 -154
- package/dist/workflows/source-files.js +28 -54
- package/dist/workflows/source-ir/program.js +2 -2
- package/dist/workflows/source-ir/semantics.js +5 -23
- package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
- package/docs/reference/cli.md +92 -17
- package/package.json +1 -1
- package/schemas/akm-config.json +5 -10
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import { UsageError } from "../../core/errors.js";
|
|
5
|
+
import { warnOnce } from "../../core/warn.js";
|
|
5
6
|
import { decodeCanonicalPlan } from "../ir/plan-hash.js";
|
|
6
7
|
import { WORKFLOW_IR_V5_VERSION } from "../ir/schema-v4.js";
|
|
7
8
|
/** Validate that a live run carries exactly the current frozen-plan format. */
|
|
@@ -21,31 +22,33 @@ export function classifyWorkflowRunPlan(row) {
|
|
|
21
22
|
support: "unsupported-version",
|
|
22
23
|
irVersion: row.plan_ir_version,
|
|
23
24
|
// §3.2's exact complete-or-abandon policy string (A-N2): pre-irVersion-5
|
|
24
|
-
// plans keep status/list/abandon working but can no longer execute.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
// plans keep status/list/abandon working but can no longer execute. A
|
|
26
|
+
// version ABOVE the current one (#919) is a distinct situation — never
|
|
27
|
+
// an "upgrade" problem — so it gets its own text below rather than
|
|
28
|
+
// being folded into the pre-5 wording.
|
|
29
|
+
error: row.plan_ir_version < WORKFLOW_IR_V5_VERSION
|
|
30
|
+
? // Issue 8: leads with the remedy available to a user who has
|
|
31
|
+
`Workflow run ${runId} was frozen as workflow plan irVersion ${row.plan_ir_version}; pre-irVersion-5 ` +
|
|
32
|
+
`plans cannot execute after the 0.9.2 upgrade. Run 'akm workflow abandon ${runId}' and start a new ` +
|
|
33
|
+
`run from the authored workflow to continue. 'akm workflow status' and 'akm workflow list' still ` +
|
|
34
|
+
`work on this run.`
|
|
35
|
+
: `Workflow run ${runId} was frozen with workflow plan irVersion ${row.plan_ir_version}, which this akm ` +
|
|
36
|
+
`(irVersion ${WORKFLOW_IR_V5_VERSION}) does not understand; it was probably written by a newer akm. ` +
|
|
37
|
+
`Complete it with that akm version, or run 'akm workflow abandon ${runId}' and start a new run from ` +
|
|
38
|
+
`the authored workflow. 'akm workflow status' and 'akm workflow list' still work on this run.`,
|
|
36
39
|
};
|
|
37
40
|
}
|
|
38
41
|
try {
|
|
39
42
|
return {
|
|
40
43
|
support: "supported",
|
|
41
|
-
irVersion:
|
|
44
|
+
irVersion: WORKFLOW_IR_V5_VERSION,
|
|
42
45
|
plan: decodeCanonicalPlan(runId, row.plan_json, row.plan_hash, row.plan_ir_version),
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
catch (cause) {
|
|
46
49
|
return {
|
|
47
50
|
support: "corrupt-plan",
|
|
48
|
-
irVersion: row.plan_ir_version,
|
|
51
|
+
irVersion: row.plan_ir_version ?? null,
|
|
49
52
|
error: cause instanceof Error ? cause.message : String(cause),
|
|
50
53
|
};
|
|
51
54
|
}
|
|
@@ -80,37 +83,82 @@ export function frozenStepRows(plan) {
|
|
|
80
83
|
sequenceIndex: step.sequenceIndex,
|
|
81
84
|
}));
|
|
82
85
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Verify the durable spine's STEP IDENTITY still agrees with the
|
|
88
|
+
* decoded/hash-verified plan: the same number of steps, and the same set of
|
|
89
|
+
* step ids. A published plan's own step ids are fixed forever at freeze
|
|
90
|
+
* time, so a mismatch here means the row set itself is wrong — genuine
|
|
91
|
+
* corruption, not something a later akm release could have caused by
|
|
92
|
+
* changing how a field is FORMATTED (that is
|
|
93
|
+
* {@link reconcileWorkflowSpineWithPlan}'s concern, issue 7).
|
|
94
|
+
*/
|
|
95
|
+
function assertSpineIdentityMatchesPlan(runId, expected, rows) {
|
|
86
96
|
if (rows.length !== expected.length)
|
|
87
|
-
corruptSpine(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
corruptSpine(runId, "step count differs from the frozen plan");
|
|
98
|
+
const expectedIds = new Set(expected.map((step) => step.stepId));
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
if (!expectedIds.has(row.step_id))
|
|
101
|
+
corruptSpine(runId, `step "${row.step_id}" is not in the frozen plan`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reconcile the durable spine's PURE-DERIVATION fields (title, instructions,
|
|
106
|
+
* completion criteria, sequence position) against the plan (issue 7).
|
|
107
|
+
*
|
|
108
|
+
* These are recomputed from the plan by {@link frozenStepRows} on every
|
|
109
|
+
* read; a later akm release changing how one of them is FORMATTED from the
|
|
110
|
+
* SAME plan data used to mark every in-flight run from the previous release
|
|
111
|
+
* "corrupt" the moment anything (`akm workflow status`, `resume`, a step
|
|
112
|
+
* completion) touched it. A mismatch here is warned about, once per run,
|
|
113
|
+
* rather than blocking the caller — never step identity (row count, which
|
|
114
|
+
* step ids exist), which stays a hard failure in
|
|
115
|
+
* {@link assertSpineIdentityMatchesPlan} because the plan cannot have
|
|
116
|
+
* produced a different step id for an already-frozen run.
|
|
117
|
+
*
|
|
118
|
+
* The durable row — not the plan — is what a driving agent actually acts
|
|
119
|
+
* on: `getNextWorkflowStep` reads `instructions` straight off this row
|
|
120
|
+
* (`toWorkflowRunStepState`/`projectNextResult` in `runtime/runs.ts`), and
|
|
121
|
+
* `exec/run-workflow.ts`/`exec/step-work.ts` dispatch from that result. So
|
|
122
|
+
* warning and proceeding with the STORED row (rather than rewriting it to
|
|
123
|
+
* match the plan's current formatting) is the conservative choice: the
|
|
124
|
+
* durable spine is the contract this run has been executing against since
|
|
125
|
+
* freeze, and a newer akm formatting the same plan data differently should
|
|
126
|
+
* not retroactively change an in-flight run's instructions out from under
|
|
127
|
+
* it mid-execution. This module has no write path for that anyway
|
|
128
|
+
* (`workflow_run_steps` is owned by
|
|
129
|
+
* `storage/repositories/workflow-runs-repository.ts`) — and it should not
|
|
130
|
+
* gain one for this purpose. Step
|
|
131
|
+
* identity (row count, which step ids exist) is the part that would
|
|
132
|
+
* actually desynchronize execution from the plan, which is exactly why
|
|
133
|
+
* {@link assertSpineIdentityMatchesPlan} keeps that a hard failure.
|
|
134
|
+
*/
|
|
135
|
+
export function reconcileWorkflowSpineWithPlan(plan, run, rows) {
|
|
136
|
+
const expected = frozenStepRows(plan);
|
|
137
|
+
assertSpineIdentityMatchesPlan(run.id, expected, rows);
|
|
138
|
+
const expectedById = new Map(expected.map((step) => [step.stepId, step]));
|
|
139
|
+
const drifted = [];
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
const planned = expectedById.get(row.step_id);
|
|
142
|
+
if (!planned)
|
|
143
|
+
continue; // unreachable after assertSpineIdentityMatchesPlan; kept defensive.
|
|
144
|
+
if (row.step_title !== planned.stepTitle ||
|
|
145
|
+
row.instructions !== planned.instructions ||
|
|
146
|
+
row.completion_json !== planned.completionJson ||
|
|
147
|
+
row.sequence_index !== planned.sequenceIndex) {
|
|
148
|
+
drifted.push(row.step_id);
|
|
109
149
|
}
|
|
110
150
|
}
|
|
111
|
-
if (
|
|
112
|
-
|
|
151
|
+
if (drifted.length > 0) {
|
|
152
|
+
warnOnce(`workflow-spine-drift:${run.id}`, `Workflow run ${run.id}: durable step row(s) [${drifted.join(", ")}] no longer match the frozen plan's ` +
|
|
153
|
+
"title/instructions/completion-criteria/sequence derivation (an akm upgrade likely changed how one of " +
|
|
154
|
+
"these is formatted from the same plan data). Continuing with the stored row(s) as-is — this run keeps " +
|
|
155
|
+
"executing against the instructions it was frozen with, rather than having them rewritten mid-flight.");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function assertRunStatusMatchesSpine(run, rows) {
|
|
113
159
|
const current = run.current_step_id ? rows.find((row) => row.step_id === run.current_step_id) : undefined;
|
|
160
|
+
if (run.current_step_id !== null && !current)
|
|
161
|
+
corruptSpine(run.id, `current step ${run.current_step_id} is not in the frozen plan`);
|
|
114
162
|
if (run.status === "active") {
|
|
115
163
|
const firstPending = rows.find((row) => row.status === "pending");
|
|
116
164
|
if (!current || current.status !== "pending" || firstPending?.step_id !== current.step_id)
|
|
@@ -19,13 +19,6 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { validateJsonSchemaSubset } from "../../core/json-schema.js";
|
|
21
21
|
import { parseReference, resolveReferenceString } from "../program/expressions.js";
|
|
22
|
-
/** Mirrors `runs.ts`'s `WORKFLOW_EVIDENCE_TRUNCATED_MARKER` byte-for-byte — see this file's header for why it is reproduced, not imported. */
|
|
23
|
-
const EVIDENCE_TRUNCATED_MARKER = "__akm_evidence_truncated__";
|
|
24
|
-
function isTruncatedEvidenceValue(value) {
|
|
25
|
-
return (typeof value === "object" &&
|
|
26
|
-
value !== null &&
|
|
27
|
-
value[EVIDENCE_TRUNCATED_MARKER] === true);
|
|
28
|
-
}
|
|
29
22
|
/** Project a step artifact out of its persisted evidence — mirrors `exec/step-work.ts`'s `projectStepOutput`. */
|
|
30
23
|
function projectStepOutput(evidence) {
|
|
31
24
|
return Object.hasOwn(evidence, "output") ? evidence.output : evidence;
|
|
@@ -60,22 +53,11 @@ export function resolveWorkflowRunOutputs(plan, steps) {
|
|
|
60
53
|
errors.push(`output "${name}": "${declaration.from}" is not a valid step-output reference.`);
|
|
61
54
|
continue;
|
|
62
55
|
}
|
|
63
|
-
const rootValue = Object.hasOwn(stepOutputs, parsed.expr.stepId) ? stepOutputs[parsed.expr.stepId] : undefined;
|
|
64
|
-
if (isTruncatedEvidenceValue(rootValue)) {
|
|
65
|
-
errors.push(`output "${name}" reads step "${parsed.expr.stepId}"'s artifact, which was truncated — it exceeded the ` +
|
|
66
|
-
`evidence persistence cap and was not stored.`);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
56
|
const resolved = resolveReferenceString(declaration.from, scope);
|
|
70
57
|
if (!resolved.ok) {
|
|
71
58
|
errors.push(`output "${name}": ${resolved.error.message}`);
|
|
72
59
|
continue;
|
|
73
60
|
}
|
|
74
|
-
if (isTruncatedEvidenceValue(resolved.value)) {
|
|
75
|
-
errors.push(`output "${name}" reads step "${parsed.expr.stepId}"'s artifact, which was truncated — it exceeded the ` +
|
|
76
|
-
`evidence persistence cap and was not stored.`);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
61
|
if (declaration.schema) {
|
|
80
62
|
const schemaErrors = validateJsonSchemaSubset(resolved.value, declaration.schema);
|
|
81
63
|
if (schemaErrors.length > 0) {
|
|
@@ -86,9 +68,7 @@ export function resolveWorkflowRunOutputs(plan, steps) {
|
|
|
86
68
|
}
|
|
87
69
|
outputs[name] = resolved.value;
|
|
88
70
|
}
|
|
89
|
-
|
|
90
|
-
return { ok: false, errors };
|
|
91
|
-
return { ok: true, outputs };
|
|
71
|
+
return { outputs, errors };
|
|
92
72
|
}
|
|
93
73
|
/**
|
|
94
74
|
* What a completed run EXPORTS: the resolved declared outputs, or
|
|
@@ -16,11 +16,11 @@ import { collectWorkflowWarnings } from "../ir/compile.js";
|
|
|
16
16
|
import { compileResolveFreezeWorkflowV4 } from "../ir/freeze-v4.js";
|
|
17
17
|
import { materializeWorkflowParameterFlags, validateWorkflowParams } from "../ir/params.js";
|
|
18
18
|
import { canonicalPlanJson, computePlanHash } from "../ir/plan-hash.js";
|
|
19
|
-
import { clip,
|
|
19
|
+
import { clip, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
|
|
20
20
|
import { validateStepSummary } from "../validate-summary.js";
|
|
21
21
|
import { resolveAgentIdentity } from "./agent-identity.js";
|
|
22
22
|
import { evaluateCheckin } from "./checkin.js";
|
|
23
|
-
import {
|
|
23
|
+
import { assertRunStatusMatchesSpine, classifyWorkflowRunPlan, frozenStepRows, reconcileWorkflowSpineWithPlan, requireExecutableWorkflowPlan, } from "./plan-classifier.js";
|
|
24
24
|
import { resolveWorkflowRunOutputs } from "./run-outputs.js";
|
|
25
25
|
import { evaluateStaleUnits } from "./unit-checkin.js";
|
|
26
26
|
import { canonicalizeWorkflowRefInput, loadWorkflowAsset, resolveWorkflowEntryId } from "./workflow-asset-loader.js";
|
|
@@ -192,9 +192,6 @@ export async function getWorkflowStatus(runId, opts) {
|
|
|
192
192
|
return detail;
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
|
-
export async function hasWorkflowRun(runId) {
|
|
196
|
-
return withWorkflowRunsRepo((repo) => repo.hasRun(runId));
|
|
197
|
-
}
|
|
198
195
|
export async function listWorkflowRuns(input) {
|
|
199
196
|
const scopeKey = getCurrentWorkflowScopeKey();
|
|
200
197
|
const activeOnly = input?.activeOnly === true;
|
|
@@ -248,13 +245,15 @@ export async function listWorkflowRuns(input) {
|
|
|
248
245
|
}
|
|
249
246
|
export async function getNextWorkflowStep(specifier, params, options) {
|
|
250
247
|
return withWorkflowRunsRepo(async (repo) => {
|
|
251
|
-
const { run, autoStarted, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags);
|
|
248
|
+
const { run, autoStarted, resumed, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags, options?.newRun);
|
|
252
249
|
const steps = readWorkflowRunSteps(repo, run.id);
|
|
253
250
|
const plan = requireExecutableWorkflowPlan(run);
|
|
254
|
-
|
|
251
|
+
reconcileWorkflowSpineWithPlan(plan, run, steps);
|
|
252
|
+
assertRunStatusMatchesSpine(run, steps);
|
|
255
253
|
return {
|
|
256
254
|
...projectNextResult(run, steps),
|
|
257
255
|
...(autoStarted ? { autoStarted: true } : {}),
|
|
256
|
+
...(resumed ? { resumed: true } : {}),
|
|
258
257
|
...(startWarnings?.length ? { startWarnings } : {}),
|
|
259
258
|
};
|
|
260
259
|
});
|
|
@@ -291,14 +290,15 @@ function projectNextResult(run, steps) {
|
|
|
291
290
|
}
|
|
292
291
|
export async function resumeWorkflowRun(runId) {
|
|
293
292
|
return withWorkflowRunsRepo((repo) => {
|
|
294
|
-
const run =
|
|
293
|
+
const run = readWorkflowRunOrPrefix(repo, runId);
|
|
295
294
|
const storedPlan = requireExecutableWorkflowPlan(run);
|
|
296
295
|
const steps = readWorkflowRunSteps(repo, run.id);
|
|
297
|
-
|
|
296
|
+
reconcileWorkflowSpineWithPlan(storedPlan, run, steps);
|
|
298
297
|
if (run.status === "completed") {
|
|
299
298
|
throw new UsageError(`Workflow run ${run.id} is already completed and cannot be resumed.`);
|
|
300
299
|
}
|
|
301
300
|
if (run.status === "active") {
|
|
301
|
+
assertRunStatusMatchesSpine(run, steps);
|
|
302
302
|
return buildWorkflowRunDetail(repo, run, steps);
|
|
303
303
|
}
|
|
304
304
|
// blocked or failed → flip back to active and re-open the current step so
|
|
@@ -312,6 +312,7 @@ export async function resumeWorkflowRun(runId) {
|
|
|
312
312
|
});
|
|
313
313
|
const updated = { ...run, status: "active", updated_at: now };
|
|
314
314
|
const refreshedSteps = readWorkflowRunSteps(repo, run.id);
|
|
315
|
+
assertRunStatusMatchesSpine(updated, refreshedSteps);
|
|
315
316
|
return buildWorkflowRunDetail(repo, updated, refreshedSteps);
|
|
316
317
|
});
|
|
317
318
|
}
|
|
@@ -325,7 +326,7 @@ export async function abandonWorkflowRun(runId) {
|
|
|
325
326
|
return withWorkflowRunsRepo((repo) => {
|
|
326
327
|
const now = new Date().toISOString();
|
|
327
328
|
const run = repo.immediateTransaction((db) => {
|
|
328
|
-
const current =
|
|
329
|
+
const current = readWorkflowRunOrPrefix(repo, runId);
|
|
329
330
|
if (current.status === "completed" || current.status === "failed") {
|
|
330
331
|
throw new UsageError(`Workflow run ${current.id} is already ${current.status}.`);
|
|
331
332
|
}
|
|
@@ -354,115 +355,6 @@ export async function abandonWorkflowRun(runId) {
|
|
|
354
355
|
return detail;
|
|
355
356
|
});
|
|
356
357
|
}
|
|
357
|
-
// ── Step-evidence persistence bound (issue C) ────────────────────────────────
|
|
358
|
-
/**
|
|
359
|
-
* Marker key stamped on every value this module replaced because it did not fit
|
|
360
|
-
* in `workflow_run_steps.evidence_json`. It is deliberately ugly and unique so a
|
|
361
|
-
* truncated value can NEVER be mistaken for real workflow data by a downstream
|
|
362
|
-
* `steps.<id>.output…` reference, by `akm workflow status`, or by a human
|
|
363
|
-
* reading the row.
|
|
364
|
-
*/
|
|
365
|
-
export const WORKFLOW_EVIDENCE_TRUNCATED_MARKER = "__akm_evidence_truncated__";
|
|
366
|
-
function truncatedEvidenceValue(json, what, limitBytes, withPreview) {
|
|
367
|
-
return {
|
|
368
|
-
[WORKFLOW_EVIDENCE_TRUNCATED_MARKER]: true,
|
|
369
|
-
reason: `${what} exceeded the ${limitBytes}-byte evidence_json persistence cap and was NOT stored. ` +
|
|
370
|
-
`The complete value existed only in the live step result; it cannot be recovered from this row. ` +
|
|
371
|
-
`Reduce the step's fan-out or have it emit a reference (path, id) instead of inline bulk data.`,
|
|
372
|
-
originalBytes: utf8Bytes(json),
|
|
373
|
-
limitBytes,
|
|
374
|
-
...(withPreview ? { preview: json.slice(0, WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS) } : {}),
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
/**
|
|
378
|
-
* True when `value` is the {@link TruncatedEvidenceValue} envelope persisted in
|
|
379
|
-
* place of an over-cap evidence entry.
|
|
380
|
-
*
|
|
381
|
-
* A LIVE invocation never sees one: the engine threads each step's complete
|
|
382
|
-
* in-memory evidence to the rest of its own run. A RESUMED invocation rebuilds
|
|
383
|
-
* the downstream scope from these rows, so `exec/step-work.ts` tests every
|
|
384
|
-
* whole-value reference with this predicate — otherwise a reference INTO the
|
|
385
|
-
* envelope reports a generic missing property and a reference AT it silently
|
|
386
|
-
* hands the envelope to a unit as if it were the artifact.
|
|
387
|
-
*/
|
|
388
|
-
export function isTruncatedEvidence(value) {
|
|
389
|
-
return (typeof value === "object" &&
|
|
390
|
-
value !== null &&
|
|
391
|
-
value[WORKFLOW_EVIDENCE_TRUNCATED_MARKER] === true);
|
|
392
|
-
}
|
|
393
|
-
/**
|
|
394
|
-
* Bound what a step's evidence costs in ONE SQLite row.
|
|
395
|
-
*
|
|
396
|
-
* `buildEvidence` (exec/step-work.ts) promotes `evidence.output` UNCLIPPED by
|
|
397
|
-
* design: gates judge the full promoted artifact and the in-memory
|
|
398
|
-
* {@link StepExecutionResult} carries it to the caller intact. Nothing bounded
|
|
399
|
-
* the PERSISTED form, though — a `collect` reducer over an unbounded fan-out
|
|
400
|
-
* can serialize to hundreds of megabytes. This is the write boundary, so the
|
|
401
|
-
* bound lives here rather than in the shared step-semantics module.
|
|
402
|
-
*
|
|
403
|
-
* Over-cap values are REPLACED (largest top-level entry BY UTF-8 BYTES first —
|
|
404
|
-
* the unit the cap is measured in — until the row fits) with a
|
|
405
|
-
* {@link TruncatedEvidenceValue} envelope. Nothing is silently shortened: a
|
|
406
|
-
* consumer either sees the real value or sees an object whose marker key says
|
|
407
|
-
* the data is gone. `preview` is intentionally not shaped like the original, so
|
|
408
|
-
* an expression reaching INTO a truncated artifact (`steps.x.output.files`)
|
|
409
|
-
* cannot quietly resolve against a half-array; a resumed run's reference is
|
|
410
|
-
* rejected by name through {@link isTruncatedEvidence}.
|
|
411
|
-
*
|
|
412
|
-
* Returns the JSON to persist plus the keys that were replaced (empty in the
|
|
413
|
-
* overwhelmingly common case, where nothing is copied or re-serialized twice).
|
|
414
|
-
*/
|
|
415
|
-
export function clipStepEvidenceForPersistence(evidence, limitBytes = WORKFLOW_MAX_EVIDENCE_JSON_BYTES) {
|
|
416
|
-
if (!evidence)
|
|
417
|
-
return { json: null, truncatedKeys: [] };
|
|
418
|
-
// Throws exactly as the previous inline `JSON.stringify` did on unserializable
|
|
419
|
-
// evidence — that contract is unchanged. Every stringify below operates on a
|
|
420
|
-
// subtree of a value already proven serializable here.
|
|
421
|
-
let json = JSON.stringify(evidence);
|
|
422
|
-
if (json === undefined)
|
|
423
|
-
return { json: null, truncatedKeys: [] };
|
|
424
|
-
let bytes = utf8Bytes(json);
|
|
425
|
-
if (bytes <= limitBytes)
|
|
426
|
-
return { json, truncatedKeys: [] };
|
|
427
|
-
const clipped = { ...evidence };
|
|
428
|
-
const truncatedKeys = [];
|
|
429
|
-
// Ordered by UTF-8 BYTES, the unit the cap itself is measured in: ordering by
|
|
430
|
-
// `json.length` (UTF-16 code units) sacrifices the char-largest key rather
|
|
431
|
-
// than the byte-largest one, so multibyte-heavy evidence loses extra keys the
|
|
432
|
-
// cap never required.
|
|
433
|
-
const bySizeDesc = Object.keys(evidence)
|
|
434
|
-
.map((key) => {
|
|
435
|
-
const json = JSON.stringify(evidence[key]) ?? "null";
|
|
436
|
-
return { key, json, bytes: utf8Bytes(json) };
|
|
437
|
-
})
|
|
438
|
-
.sort((a, b) => b.bytes - a.bytes);
|
|
439
|
-
for (const [index, entry] of bySizeDesc.entries()) {
|
|
440
|
-
const envelope = truncatedEvidenceValue(entry.json, `Step evidence "${entry.key}"`, limitBytes, true);
|
|
441
|
-
clipped[entry.key] = envelope;
|
|
442
|
-
truncatedKeys.push(entry.key);
|
|
443
|
-
// Track the row size arithmetically from the per-key sizes already computed
|
|
444
|
-
// for the sort, so a run of replacements costs ONE whole-object
|
|
445
|
-
// serialization rather than one per replaced key. The total is an ESTIMATE
|
|
446
|
-
// — a key whose value is `undefined` is charged the `"null"` the sort used
|
|
447
|
-
// but is OMITTED from the serialized row — so it decides only WHEN to
|
|
448
|
-
// measure. Whether the row FITS is settled by an exact serialization every
|
|
449
|
-
// time, the last key included, so an exhausted loop falls through to the
|
|
450
|
-
// whole-object marker on measurement rather than on drift.
|
|
451
|
-
bytes += utf8Bytes(JSON.stringify(envelope)) - entry.bytes;
|
|
452
|
-
if (bytes > limitBytes && index < bySizeDesc.length - 1)
|
|
453
|
-
continue;
|
|
454
|
-
json = JSON.stringify(clipped);
|
|
455
|
-
bytes = utf8Bytes(json);
|
|
456
|
-
if (bytes <= limitBytes)
|
|
457
|
-
return { json, truncatedKeys };
|
|
458
|
-
}
|
|
459
|
-
// Pathological shape (so many keys that even the envelopes overflow): persist
|
|
460
|
-
// ONE whole-object marker. Still unambiguous, still bounded.
|
|
461
|
-
return {
|
|
462
|
-
json: JSON.stringify(truncatedEvidenceValue(JSON.stringify(evidence), "Step evidence", limitBytes, false)),
|
|
463
|
-
truncatedKeys: Object.keys(evidence),
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
358
|
export async function completeWorkflowStep(input) {
|
|
467
359
|
// Read the step (read-only) up front so the LLM validation gate runs OUTSIDE
|
|
468
360
|
// the write transaction — a slow/hung LLM must never hold a db write lock.
|
|
@@ -470,7 +362,8 @@ export async function completeWorkflowStep(input) {
|
|
|
470
362
|
const run = readWorkflowRun(repo, input.runId);
|
|
471
363
|
const storedPlan = requireExecutableWorkflowPlan(run);
|
|
472
364
|
const steps = readWorkflowRunSteps(repo, run.id);
|
|
473
|
-
|
|
365
|
+
reconcileWorkflowSpineWithPlan(storedPlan, run, steps);
|
|
366
|
+
assertRunStatusMatchesSpine(run, steps);
|
|
474
367
|
if (run.status !== "active") {
|
|
475
368
|
throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
|
|
476
369
|
}
|
|
@@ -535,11 +428,13 @@ export async function completeWorkflowStep(input) {
|
|
|
535
428
|
return withWorkflowRunsRepo((repo) => {
|
|
536
429
|
let updatedRun;
|
|
537
430
|
let refreshedSteps = [];
|
|
431
|
+
let outputWarnings;
|
|
538
432
|
repo.transaction(() => {
|
|
539
433
|
const run = readWorkflowRun(repo, input.runId);
|
|
540
434
|
const plan = requireExecutableWorkflowPlan(run);
|
|
541
435
|
const spine = readWorkflowRunSteps(repo, run.id);
|
|
542
|
-
|
|
436
|
+
reconcileWorkflowSpineWithPlan(plan, run, spine);
|
|
437
|
+
assertRunStatusMatchesSpine(run, spine);
|
|
543
438
|
if (run.status !== "active") {
|
|
544
439
|
throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
|
|
545
440
|
}
|
|
@@ -560,26 +455,18 @@ export async function completeWorkflowStep(input) {
|
|
|
560
455
|
if (input.signal?.aborted)
|
|
561
456
|
throw interruptionReason(input.signal);
|
|
562
457
|
const completedAt = new Date().toISOString();
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
const
|
|
571
|
-
if (persistedEvidence.truncatedKeys.length > 0) {
|
|
572
|
-
warn(`Workflow run ${run.id} step "${input.stepId}": evidence exceeded the ` +
|
|
573
|
-
`${WORKFLOW_MAX_EVIDENCE_JSON_BYTES}-byte persistence cap; ` +
|
|
574
|
-
`${persistedEvidence.truncatedKeys.map((k) => `"${k}"`).join(", ")} ` +
|
|
575
|
-
`${persistedEvidence.truncatedKeys.length === 1 ? "was" : "were"} stored as a truncation marker. ` +
|
|
576
|
-
`The rest of THIS invocation still reads the complete value, but a run resumed from these rows will ` +
|
|
577
|
-
`fail loudly when a later step references this step's output rather than read partial data.`);
|
|
578
|
-
}
|
|
458
|
+
// The promoted artifact is persisted WHOLE, unclipped (issue C): a
|
|
459
|
+
// step artifact that does not fit some cap used to be replaced by a
|
|
460
|
+
// truncation marker at this exact write, and the run looked fine right
|
|
461
|
+
// up until a LATER invocation (a resume, or any downstream step
|
|
462
|
+
// referencing it) found the marker instead of the value and failed
|
|
463
|
+
// permanently, with every prior paid step now unrecoverable. Persisting
|
|
464
|
+
// the real value here is what makes it readable again on resume.
|
|
465
|
+
const evidenceJson = input.evidence ? JSON.stringify(input.evidence) : null;
|
|
579
466
|
repo.updateStepCompletion({
|
|
580
467
|
status: input.status,
|
|
581
468
|
notes: input.notes?.trim() || null,
|
|
582
|
-
evidenceJson
|
|
469
|
+
evidenceJson,
|
|
583
470
|
summary: summary || null,
|
|
584
471
|
completedAt,
|
|
585
472
|
runId: run.id,
|
|
@@ -597,12 +484,9 @@ export async function completeWorkflowStep(input) {
|
|
|
597
484
|
let outputsJson; // undefined = untouched, keep the row's existing value
|
|
598
485
|
if (state.status === "completed" && plan.outputs) {
|
|
599
486
|
const resolved = resolveWorkflowRunOutputs(plan, refreshedSteps);
|
|
600
|
-
if (!resolved.ok) {
|
|
601
|
-
throw new UsageError(`Workflow run ${run.id} completed its final step but its declared outputs could not be resolved:\n` +
|
|
602
|
-
resolved.errors.map((e) => ` - ${e}`).join("\n"), "WORKFLOW_OUTPUT_INVALID");
|
|
603
|
-
}
|
|
604
487
|
outputsJson = JSON.stringify(resolved.outputs);
|
|
605
488
|
repo.setRunOutputs(run.id, outputsJson);
|
|
489
|
+
outputWarnings = resolved.errors;
|
|
606
490
|
}
|
|
607
491
|
// Re-arm the check-in on every state change: a healthy, progressing run
|
|
608
492
|
// keeps pushing the stall window forward so the directive never fires.
|
|
@@ -625,6 +509,12 @@ export async function completeWorkflowStep(input) {
|
|
|
625
509
|
};
|
|
626
510
|
});
|
|
627
511
|
const detail = buildWorkflowRunDetail(repo, updatedRun, refreshedSteps);
|
|
512
|
+
if (outputWarnings?.length) {
|
|
513
|
+
const messages = outputWarnings.map((e) => `Workflow run ${input.runId} declared ${e}`);
|
|
514
|
+
for (const message of messages)
|
|
515
|
+
warn(message);
|
|
516
|
+
detail.warnings = [...(detail.warnings ?? []), ...messages];
|
|
517
|
+
}
|
|
628
518
|
// #11: emit `workflow_step_completed` ONLY for a genuine `completed`
|
|
629
519
|
// transition; every other non-pending status (failed/skipped/blocked)
|
|
630
520
|
// carries the honest `workflow_step_updated` name. The status is ALWAYS
|
|
@@ -642,10 +532,15 @@ export async function completeWorkflowStep(input) {
|
|
|
642
532
|
return detail;
|
|
643
533
|
});
|
|
644
534
|
}
|
|
645
|
-
async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
|
|
535
|
+
async function resolveRunSpecifier(repo, specifier, params, parameterFlags, forceNew) {
|
|
646
536
|
const hasParameters = (params && Object.keys(params).length > 0) || (parameterFlags?.length ?? 0) > 0;
|
|
647
|
-
const explicitRun = repo
|
|
537
|
+
const explicitRun = findRunByIdOrPrefix(repo, specifier);
|
|
648
538
|
if (explicitRun) {
|
|
539
|
+
// `--new` starts a fresh run FROM A REF; it never makes sense against a
|
|
540
|
+
// run id (or an id prefix), which already names the run to act on (#919).
|
|
541
|
+
if (forceNew) {
|
|
542
|
+
throw new UsageError(`--new starts a fresh run from a workflow ref; "${specifier}" already names a run id.`, "INVALID_FLAG_VALUE");
|
|
543
|
+
}
|
|
649
544
|
if (hasParameters) {
|
|
650
545
|
throw new UsageError(`Workflow parameter flags can only be used when starting a new run, not with existing run id "${specifier}".`);
|
|
651
546
|
}
|
|
@@ -655,7 +550,7 @@ async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
|
|
|
655
550
|
const exactRef = specifier.trim();
|
|
656
551
|
const parsedExact = parseBundleRef(exactRef);
|
|
657
552
|
const qualifiedExact = parsedExact.bundle !== undefined && parsedExact.fragment === undefined;
|
|
658
|
-
const detached = qualifiedExact ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined;
|
|
553
|
+
const detached = qualifiedExact && !forceNew ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined;
|
|
659
554
|
let ref;
|
|
660
555
|
try {
|
|
661
556
|
ref = await canonicalizeWorkflowSpecifier(specifier);
|
|
@@ -663,24 +558,29 @@ async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
|
|
|
663
558
|
catch (error) {
|
|
664
559
|
if (detached) {
|
|
665
560
|
if (hasParameters) {
|
|
666
|
-
throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active.`);
|
|
561
|
+
throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active.`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${detached.id}" to free up ${specifier} first.`);
|
|
667
562
|
}
|
|
668
|
-
return { run: detached, autoStarted: false };
|
|
563
|
+
return { run: detached, autoStarted: false, resumed: true };
|
|
669
564
|
}
|
|
670
565
|
if (error instanceof NotFoundError && !specifier.includes(":") && !specifier.includes("/")) {
|
|
671
566
|
throw new NotFoundError(`Workflow run or workflow "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
|
|
672
567
|
}
|
|
673
568
|
throw error;
|
|
674
569
|
}
|
|
675
|
-
const active = repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey);
|
|
570
|
+
const active = forceNew ? undefined : repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey);
|
|
676
571
|
if (active) {
|
|
677
572
|
if (hasParameters) {
|
|
678
|
-
throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active.`);
|
|
573
|
+
throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active.`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${active.id}" to free up ${ref} first.`);
|
|
679
574
|
}
|
|
680
|
-
return { run: active, autoStarted: false };
|
|
575
|
+
return { run: active, autoStarted: false, resumed: true };
|
|
681
576
|
}
|
|
577
|
+
// `force` (#485's own escape hatch) is what lets `--new` create a second
|
|
578
|
+
// active run for this (ref, scope) pair instead of the concurrency guard
|
|
579
|
+
// refusing it — the caller explicitly asked for a fresh run, so the guard
|
|
580
|
+
// that exists to catch an ACCIDENTAL second run does not apply (#919).
|
|
682
581
|
const started = await startWorkflowRun(ref, params ?? {}, {
|
|
683
582
|
...(parameterFlags !== undefined ? { parameterFlags } : {}),
|
|
583
|
+
...(forceNew ? { force: true } : {}),
|
|
684
584
|
});
|
|
685
585
|
return {
|
|
686
586
|
run: readWorkflowRun(repo, started.run.id),
|
|
@@ -713,12 +613,57 @@ async function workflowRunRefSet(canonicalRef, exactRef) {
|
|
|
713
613
|
}
|
|
714
614
|
return [...refs];
|
|
715
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* A workflow run id (or an accepted prefix of one, #919) is hex digits and
|
|
618
|
+
* hyphens, 8+ characters. A workflow ref always contains at least one
|
|
619
|
+
* character outside that set (a `/` path segment, at minimum), so this
|
|
620
|
+
* never mistakes a ref for an id — matching input is ALWAYS resolved as an
|
|
621
|
+
* id/prefix, never falls through to ref resolution.
|
|
622
|
+
*/
|
|
623
|
+
const RUN_ID_PREFIX_PATTERN = /^[0-9a-f-]{8,}$/;
|
|
624
|
+
/** The run `specifier` names: by exact id, or by unique id prefix (#919) when it is id-shaped; `undefined` for a workflow ref. */
|
|
625
|
+
function findRunByIdOrPrefix(repo, specifier) {
|
|
626
|
+
const exact = repo.getRunById(specifier);
|
|
627
|
+
if (exact || !RUN_ID_PREFIX_PATTERN.test(specifier))
|
|
628
|
+
return exact;
|
|
629
|
+
return repo.getRunById(repo.resolveRunIdPrefix(specifier));
|
|
630
|
+
}
|
|
631
|
+
/** For verbs that take a run id/prefix OR a workflow ref (`status`): the run id, or `undefined` to fall through to ref resolution. */
|
|
632
|
+
export async function resolveWorkflowRunTarget(specifier) {
|
|
633
|
+
return withWorkflowRunsRepo((repo) => findRunByIdOrPrefix(repo, specifier)?.id);
|
|
634
|
+
}
|
|
635
|
+
/** User-facing read: accepts an id prefix. Internal reads use {@link readWorkflowRun} with an exact id. */
|
|
636
|
+
function readWorkflowRunOrPrefix(repo, specifier) {
|
|
637
|
+
const run = findRunByIdOrPrefix(repo, specifier);
|
|
638
|
+
if (!run)
|
|
639
|
+
throw new NotFoundError(`Workflow run "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
|
|
640
|
+
return reclaimOrphanedEngineLease(repo, run);
|
|
641
|
+
}
|
|
716
642
|
function readWorkflowRun(repo, runId) {
|
|
717
643
|
const run = repo.getRunById(runId);
|
|
718
644
|
if (!run) {
|
|
719
645
|
throw new NotFoundError(`Workflow run "${runId}" not found.`, "WORKFLOW_NOT_FOUND");
|
|
720
646
|
}
|
|
721
|
-
return run;
|
|
647
|
+
return reclaimOrphanedEngineLease(repo, run);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Self-heal a run's engine lease once it has expired — the orphaned-lease
|
|
651
|
+
* case where an engine crashed without releasing it — mirroring the
|
|
652
|
+
* maintenance barrier's self-reclaim of a wedged sentinel, applied at the
|
|
653
|
+
* points a caller actually asks "what is this run's state". Never touches a
|
|
654
|
+
* live lease: {@link WorkflowRunsRepository.reclaimExpiredEngineLease} is a
|
|
655
|
+
* compare-and-swap on the exact (holder, until) this call observed, so a
|
|
656
|
+
* lease renewed or re-acquired between the read and this write is left alone.
|
|
657
|
+
*/
|
|
658
|
+
function reclaimOrphanedEngineLease(repo, run) {
|
|
659
|
+
const { engine_lease_holder: holder, engine_lease_until: until } = run;
|
|
660
|
+
if (!holder || !until)
|
|
661
|
+
return run;
|
|
662
|
+
const now = new Date().toISOString();
|
|
663
|
+
if (until >= now)
|
|
664
|
+
return run;
|
|
665
|
+
repo.reclaimExpiredEngineLease(run.id, holder, until, now);
|
|
666
|
+
return { ...run, engine_lease_holder: null, engine_lease_until: null };
|
|
722
667
|
}
|
|
723
668
|
function readWorkflowRunSteps(repo, runId) {
|
|
724
669
|
return repo.getStepsForRun(runId);
|
|
@@ -832,8 +777,13 @@ function toWorkflowRunSummary(run) {
|
|
|
832
777
|
executionSupport: plan.support,
|
|
833
778
|
// Surface the engine lease (holder id + expiry — never workflow-authored
|
|
834
779
|
// content) so `workflow run`/`status` show which native execution
|
|
835
|
-
// invocation currently holds the run lease.
|
|
836
|
-
|
|
780
|
+
// invocation currently holds the run lease. Gated on `until` still being
|
|
781
|
+
// in the future: a crashed engine's lease self-expires, and a run
|
|
782
|
+
// whose holder is provably gone must stop reading as engine-driven the
|
|
783
|
+
// instant that happens, not just once something next attempts to acquire
|
|
784
|
+
// it (`readWorkflowRun`/`readWorkflowRunOrPrefix` also reclaim the DB
|
|
785
|
+
// columns outright on the same condition).
|
|
786
|
+
...(run.engine_lease_holder && run.engine_lease_until && run.engine_lease_until >= new Date().toISOString()
|
|
837
787
|
? { engineLease: { holder: run.engine_lease_holder, until: run.engine_lease_until } }
|
|
838
788
|
: {}),
|
|
839
789
|
// P3b (spec §4.5): all three optional and conditionally spread, so every
|
|
@@ -860,7 +810,7 @@ function assertLeaseAllowsSpineAdvance(run, leaseHolder) {
|
|
|
860
810
|
return; // expired ⇒ claimable, not live
|
|
861
811
|
throw new UsageError(`Workflow run ${run.id} is being driven by engine ${run.engine_lease_holder} ` +
|
|
862
812
|
`(run lease expires ${run.engine_lease_until}). The engine owns the step spine while it runs — ` +
|
|
863
|
-
`wait for it to finish or for the lease to expire before advancing steps manually
|
|
813
|
+
`wait for it to finish or for the lease to expire before advancing steps manually.`, "RUN_LEASE_HELD");
|
|
864
814
|
}
|
|
865
815
|
function toWorkflowRunStepState(step) {
|
|
866
816
|
return {
|