@kontextmind/kxm 0.7.23 → 0.7.25
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/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +59 -5
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +507 -27
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +500 -26
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/vnext.ts +65 -3
- package/plugins/kxm/src/cli.ts +1 -1
- package/plugins/kxm/src/database.ts +3 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/vnext-config.ts +31 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +87 -2
- package/plugins/kxm/src/vnext-engine-gate-records.ts +19 -6
- package/plugins/kxm/src/vnext-engine.ts +325 -9
- package/plugins/kxm/src/vnext-runtime-owner.ts +2 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +158 -2
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +24 -3
- package/plugins/kxm/src/vnext-runtime.ts +17 -3
- package/schemas/vnext/common.schema.json +1 -0
- package/schemas/vnext/drive-receipt.schema.json +95 -0
- package/schemas/vnext/run-event.schema.json +39 -0
package/plugins/kxm/package.json
CHANGED
|
@@ -473,6 +473,61 @@ export async function cmdVnextRun(runtime: Runtime, workflow: string | undefined
|
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
interface VnextStatusDrive {
|
|
477
|
+
driveId?: string;
|
|
478
|
+
mode?: string;
|
|
479
|
+
openedAt?: string;
|
|
480
|
+
receipt?: {
|
|
481
|
+
settlement?: { kind?: string; status?: string; reason?: string };
|
|
482
|
+
} | null;
|
|
483
|
+
verified?: boolean;
|
|
484
|
+
divergence?: string;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function formatCancelledStatus(reason: string): string {
|
|
488
|
+
return reason.length > 0 ? `cancelled (${reason})` : "cancelled";
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function formatDriveStatusLine(runStatus: string, drive: VnextStatusDrive | undefined): string | undefined {
|
|
492
|
+
if (!drive || typeof drive.driveId !== "string" || drive.driveId.length === 0) return undefined;
|
|
493
|
+
const receipt = drive.receipt ?? null;
|
|
494
|
+
if (receipt === null) {
|
|
495
|
+
if (runStatus === "running" || runStatus === "blocked_uncertain") {
|
|
496
|
+
return `drive ${drive.driveId}: open`;
|
|
497
|
+
}
|
|
498
|
+
return `drive ${drive.driveId}: no receipt (orphaned)`;
|
|
499
|
+
}
|
|
500
|
+
const kind = receipt.settlement?.kind;
|
|
501
|
+
const reason = typeof receipt.settlement?.reason === "string" ? receipt.settlement.reason : "";
|
|
502
|
+
if (kind === "unsettled") {
|
|
503
|
+
return `drive ${drive.driveId}: unsettled ${reason}`.trimEnd();
|
|
504
|
+
}
|
|
505
|
+
if (kind === "handoff") {
|
|
506
|
+
return drive.verified === true
|
|
507
|
+
? `drive ${drive.driveId}: handoff (receipt verified)`
|
|
508
|
+
: `drive ${drive.driveId}: handoff`;
|
|
509
|
+
}
|
|
510
|
+
if (receipt.settlement?.status === "cancelled") {
|
|
511
|
+
const label = formatCancelledStatus(reason);
|
|
512
|
+
return drive.verified === true
|
|
513
|
+
? `drive ${drive.driveId}: ${label} (receipt verified)`
|
|
514
|
+
: `drive ${drive.driveId}: ${label}`;
|
|
515
|
+
}
|
|
516
|
+
if (drive.verified === true) {
|
|
517
|
+
return `drive ${drive.driveId}: completed (receipt verified)`;
|
|
518
|
+
}
|
|
519
|
+
return `drive ${drive.driveId}: completed`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function formatRunStatusLine(
|
|
523
|
+
run: { runId: string; status: string; workflowId: string; updatedAt: string },
|
|
524
|
+
drive: VnextStatusDrive | undefined,
|
|
525
|
+
): string {
|
|
526
|
+
const reason = typeof drive?.receipt?.settlement?.reason === "string" ? drive.receipt.settlement.reason : "";
|
|
527
|
+
const statusLabel = run.status === "cancelled" ? formatCancelledStatus(reason) : run.status;
|
|
528
|
+
return `run ${run.runId}: ${statusLabel} (workflow ${run.workflowId}, updated ${run.updatedAt})`;
|
|
529
|
+
}
|
|
530
|
+
|
|
476
531
|
export async function cmdVnextRunStatus(runtime: Runtime, runId: string): Promise<number> {
|
|
477
532
|
try {
|
|
478
533
|
const projectRoot = discoverVnextProjectRoot(runtime.cwd);
|
|
@@ -480,10 +535,17 @@ export async function cmdVnextRunStatus(runtime: Runtime, runId: string): Promis
|
|
|
480
535
|
print(runtime.io, runtime.json, { ok: false, command: "runs status", error: "project_required" }, "kxm runs status requires a vNext project (run kxm init first)");
|
|
481
536
|
return 1;
|
|
482
537
|
}
|
|
483
|
-
const supervisor = await ensureVnextSupervisor({ env: runtime.env });
|
|
484
|
-
const result = await vnextRuntimeRequest(supervisor, "GET", `/v1/runs/${encodeURIComponent(runId)}?projectRoot=${encodeURIComponent(projectRoot)}`);
|
|
538
|
+
const supervisor = await (vnextDriveCliSeams.ensureSupervisor ?? ensureVnextSupervisor)({ env: runtime.env });
|
|
539
|
+
const result = await (vnextDriveCliSeams.runtimeRequest ?? vnextRuntimeRequest)(supervisor, "GET", `/v1/runs/${encodeURIComponent(runId)}?projectRoot=${encodeURIComponent(projectRoot)}`);
|
|
485
540
|
const run = result.run as { runId: string; status: string; workflowId: string; configRevision: string; updatedAt: string };
|
|
486
|
-
|
|
541
|
+
const drive = result.drive as VnextStatusDrive | undefined;
|
|
542
|
+
const driveLine = formatDriveStatusLine(run.status, drive);
|
|
543
|
+
print(
|
|
544
|
+
runtime.io,
|
|
545
|
+
runtime.json,
|
|
546
|
+
{ ok: true, command: "runs status", run, ...(drive !== undefined ? { drive } : {}) },
|
|
547
|
+
`${formatRunStatusLine(run, drive)}${driveLine ? `\n${driveLine}` : ""}`,
|
|
548
|
+
);
|
|
487
549
|
return 0;
|
|
488
550
|
} catch (error) {
|
|
489
551
|
if (error instanceof VnextConfigError) {
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -382,7 +382,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
382
382
|
}));
|
|
383
383
|
const runCmd = addGlobalOptions(program.command("runs").description("Inspect vNext runs"));
|
|
384
384
|
runCmd.helpCommand("help", "Show runs help");
|
|
385
|
-
addGlobalOptions(runCmd.command("status").description("Show the projected status of a run"))
|
|
385
|
+
addGlobalOptions(runCmd.command("status").description("Show the projected status of a run, including durable drive receipt state (open / receipt verified / unsettled / orphaned)"))
|
|
386
386
|
.argument("<runId>", "Run id")
|
|
387
387
|
.action(async function runStatusAction(this: Command, runId: string) {
|
|
388
388
|
result.code = await cmdVnextRunStatus(runtimeFrom(ctx, this), runId);
|
|
@@ -420,7 +420,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
|
|
|
420
420
|
stores.push({
|
|
421
421
|
storeId: `events:${key}`,
|
|
422
422
|
sourcePath: join(eventsDir, entry.name),
|
|
423
|
-
maxSupportedVersion:
|
|
423
|
+
maxSupportedVersion: 4,
|
|
424
424
|
});
|
|
425
425
|
}
|
|
426
426
|
}
|
|
@@ -537,6 +537,8 @@ export function restoreBackup(
|
|
|
537
537
|
let maxSupported = 3;
|
|
538
538
|
if (store.storeId === "registry" || store.storeId === "binding-store") {
|
|
539
539
|
maxSupported = 1;
|
|
540
|
+
} else if (store.storeId.startsWith("events:")) {
|
|
541
|
+
maxSupported = 4;
|
|
540
542
|
}
|
|
541
543
|
|
|
542
544
|
let targetPath = store.sourcePath;
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.25";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -166,6 +166,7 @@ export class VnextSchemaRegistry {
|
|
|
166
166
|
readonly migrationReceiptValidator: ValidateFunction;
|
|
167
167
|
readonly permissionDiffValidator: ValidateFunction;
|
|
168
168
|
readonly runEventValidator: ValidateFunction;
|
|
169
|
+
readonly driveReceiptValidator: ValidateFunction;
|
|
169
170
|
|
|
170
171
|
constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
|
|
171
172
|
this.schemasDir = resolve(schemasDir);
|
|
@@ -183,6 +184,7 @@ export class VnextSchemaRegistry {
|
|
|
183
184
|
const migrationReceiptFile = "migration-receipt.schema.json";
|
|
184
185
|
const permissionDiffFile = "permission-diff.schema.json";
|
|
185
186
|
const runEventFile = "run-event.schema.json";
|
|
187
|
+
const driveReceiptFile = "drive-receipt.schema.json";
|
|
186
188
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
|
|
187
189
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
|
|
188
190
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
|
|
@@ -191,6 +193,7 @@ export class VnextSchemaRegistry {
|
|
|
191
193
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, migrationReceiptFile)));
|
|
192
194
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, permissionDiffFile)));
|
|
193
195
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, runEventFile)));
|
|
196
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
|
|
194
197
|
for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [VnextResourceKind, { identity: string; file: string }][]) {
|
|
195
198
|
const validator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${definition.file}`);
|
|
196
199
|
if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
|
|
@@ -204,6 +207,7 @@ export class VnextSchemaRegistry {
|
|
|
204
207
|
const migrationReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${migrationReceiptFile}`);
|
|
205
208
|
const permissionDiffValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${permissionDiffFile}`);
|
|
206
209
|
const runEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${runEventFile}`);
|
|
210
|
+
const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${driveReceiptFile}`);
|
|
207
211
|
if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
|
|
208
212
|
if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
|
|
209
213
|
if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
|
|
@@ -212,6 +216,7 @@ export class VnextSchemaRegistry {
|
|
|
212
216
|
if (!migrationReceiptValidator) throw new Error(`schema did not compile: ${migrationReceiptFile}`);
|
|
213
217
|
if (!permissionDiffValidator) throw new Error(`schema did not compile: ${permissionDiffFile}`);
|
|
214
218
|
if (!runEventValidator) throw new Error(`schema did not compile: ${runEventFile}`);
|
|
219
|
+
if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
|
|
215
220
|
this.localBindingsValidator = localBindingsValidator;
|
|
216
221
|
this.templateProvenanceValidator = templateProvenanceValidator;
|
|
217
222
|
this.initOperationValidator = initOperationValidator;
|
|
@@ -220,6 +225,7 @@ export class VnextSchemaRegistry {
|
|
|
220
225
|
this.migrationReceiptValidator = migrationReceiptValidator;
|
|
221
226
|
this.permissionDiffValidator = permissionDiffValidator;
|
|
222
227
|
this.runEventValidator = runEventValidator;
|
|
228
|
+
this.driveReceiptValidator = driveReceiptValidator;
|
|
223
229
|
}
|
|
224
230
|
|
|
225
231
|
validate(kind: VnextResourceKind, value: JsonObject, file: string): VnextConfigIssue[] {
|
|
@@ -261,6 +267,10 @@ export class VnextSchemaRegistry {
|
|
|
261
267
|
return this.validateAuxiliary(value, file, "kxm.permission-diff.v1", this.permissionDiffValidator);
|
|
262
268
|
}
|
|
263
269
|
|
|
270
|
+
validateDriveReceipt(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
271
|
+
return this.validateAuxiliary(value, file, "kxm.drive-receipt.v1", this.driveReceiptValidator);
|
|
272
|
+
}
|
|
273
|
+
|
|
264
274
|
private validateAuxiliary(value: JsonObject, file: string, identity: string, validator: ValidateFunction): VnextConfigIssue[] {
|
|
265
275
|
if (value.schema !== identity) {
|
|
266
276
|
return [issue("schema", "schema_identity_mismatch", file, `expected ${identity}, received ${String(value.schema)}`)];
|
|
@@ -285,6 +295,27 @@ export function validateRunEvent(value: unknown, file: string): void {
|
|
|
285
295
|
}
|
|
286
296
|
}
|
|
287
297
|
|
|
298
|
+
/** Validate a drive receipt against `kxm.drive-receipt.v1`. Throws `drive_receipt_invalid`. */
|
|
299
|
+
export function validateDriveReceipt(value: unknown, file: string): void {
|
|
300
|
+
const registry = (cachedRunEventRegistry ??= new VnextSchemaRegistry());
|
|
301
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.drive-receipt.v1") {
|
|
302
|
+
throw new VnextConfigError([issue(
|
|
303
|
+
"schema",
|
|
304
|
+
"drive_receipt_invalid",
|
|
305
|
+
file,
|
|
306
|
+
"expected kxm.drive-receipt.v1",
|
|
307
|
+
)]);
|
|
308
|
+
}
|
|
309
|
+
if (!registry.driveReceiptValidator(value)) {
|
|
310
|
+
throw new VnextConfigError([issue(
|
|
311
|
+
"schema",
|
|
312
|
+
"drive_receipt_invalid",
|
|
313
|
+
file,
|
|
314
|
+
registry.ajv.errorsText(registry.driveReceiptValidator.errors, { separator: "; " }),
|
|
315
|
+
)]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
288
319
|
function schemaIssue(file: string, error: ErrorObject): VnextConfigIssue {
|
|
289
320
|
const location = error.instancePath || "/";
|
|
290
321
|
const suffix = error.params && "additionalProperty" in error.params
|
|
@@ -62,6 +62,30 @@ export interface VnextFoldOptions {
|
|
|
62
62
|
observationId: string,
|
|
63
63
|
) => { completeness: "complete" | "incomplete" | "no-start" } | undefined;
|
|
64
64
|
readonly gateKind?: (stepId: string) => string | undefined;
|
|
65
|
+
readonly projectLimits?: {
|
|
66
|
+
readonly maxRunDurationMs?: number;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type VnextRunDurationBudgetSource = "workflow" | "project" | "both";
|
|
71
|
+
|
|
72
|
+
export interface VnextRunDurationBudget {
|
|
73
|
+
readonly budgetMs: number;
|
|
74
|
+
readonly source: VnextRunDurationBudgetSource;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function effectiveRunDurationBudget(
|
|
78
|
+
planLimits: { readonly maxRunDurationMs?: number } | undefined,
|
|
79
|
+
projectLimits: { readonly maxRunDurationMs?: number } | undefined,
|
|
80
|
+
): VnextRunDurationBudget | undefined {
|
|
81
|
+
const workflow = planLimits?.maxRunDurationMs;
|
|
82
|
+
const project = projectLimits?.maxRunDurationMs;
|
|
83
|
+
if (workflow === undefined && project === undefined) return undefined;
|
|
84
|
+
if (workflow !== undefined && project !== undefined) {
|
|
85
|
+
return { budgetMs: Math.min(workflow, project), source: "both" };
|
|
86
|
+
}
|
|
87
|
+
if (workflow !== undefined) return { budgetMs: workflow, source: "workflow" };
|
|
88
|
+
return { budgetMs: project!, source: "project" };
|
|
65
89
|
}
|
|
66
90
|
|
|
67
91
|
export interface VnextFoldAttemptState {
|
|
@@ -107,6 +131,12 @@ export interface VnextRunCurrentStep {
|
|
|
107
131
|
readonly evidenceRefs?: readonly { id: string; kind: string; hash: string; status: string }[] | undefined;
|
|
108
132
|
}
|
|
109
133
|
|
|
134
|
+
export interface VnextRunDriveBinding {
|
|
135
|
+
readonly driveId: string;
|
|
136
|
+
readonly mode: "simulated" | "live";
|
|
137
|
+
readonly openedSequence: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
110
140
|
export interface VnextRunState {
|
|
111
141
|
readonly schema: typeof VNEXT_RUN_STATE_SCHEMA;
|
|
112
142
|
readonly runId: string;
|
|
@@ -120,6 +150,8 @@ export interface VnextRunState {
|
|
|
120
150
|
readonly cancelRequested: boolean;
|
|
121
151
|
readonly terminalReason?: string | undefined;
|
|
122
152
|
readonly failureReason?: string | undefined;
|
|
153
|
+
readonly drive?: VnextRunDriveBinding | undefined;
|
|
154
|
+
readonly runningSince?: string | undefined;
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
interface MutableAttempt {
|
|
@@ -171,6 +203,8 @@ interface MutableState {
|
|
|
171
203
|
awaitingTransition: boolean;
|
|
172
204
|
cancelCommandId?: string | undefined;
|
|
173
205
|
lastTerminalTransition?: VnextTerminalStatus | undefined;
|
|
206
|
+
drive?: VnextRunDriveBinding | undefined;
|
|
207
|
+
runningSince?: string | undefined;
|
|
174
208
|
}
|
|
175
209
|
|
|
176
210
|
function emptyPanel(): MutablePanel {
|
|
@@ -460,7 +494,10 @@ export function foldVnextRunState(
|
|
|
460
494
|
foldRunStatus(state, plan, event);
|
|
461
495
|
break;
|
|
462
496
|
case "run.cancel_requested":
|
|
463
|
-
foldCancelRequested(state, event);
|
|
497
|
+
foldCancelRequested(state, event, plan, options);
|
|
498
|
+
break;
|
|
499
|
+
case "run.drive_opened":
|
|
500
|
+
foldDriveOpened(state, event);
|
|
464
501
|
break;
|
|
465
502
|
case "step.entered":
|
|
466
503
|
foldStepEntered(state, plan!, event);
|
|
@@ -595,6 +632,9 @@ function foldRunStatus(state: MutableState, plan: VnextCompiledPlan | undefined,
|
|
|
595
632
|
if ((status === "running" || status === "cancelling") && !plan) {
|
|
596
633
|
throw runtimeError("run_plan_missing", state.runId, `status ${status} requires a pinned run plan`);
|
|
597
634
|
}
|
|
635
|
+
if (status === "running" && state.runningSince === undefined) {
|
|
636
|
+
state.runningSince = event.occurredAt;
|
|
637
|
+
}
|
|
598
638
|
if (TERMINAL_RUN.has(status)) {
|
|
599
639
|
assertTerminalRunStatus(state, plan, status, event);
|
|
600
640
|
if (typeof event.payload.reason === "string") state.terminalReason = event.payload.reason;
|
|
@@ -694,10 +734,53 @@ function isProvenFailure(state: MutableState, plan: VnextCompiledPlan | undefine
|
|
|
694
734
|
return Boolean(step && used >= step.maxAttempts);
|
|
695
735
|
}
|
|
696
736
|
|
|
697
|
-
function
|
|
737
|
+
function foldDriveOpened(state: MutableState, event: VnextRunEvent): void {
|
|
738
|
+
if (state.status !== "running" && state.status !== "blocked_uncertain") {
|
|
739
|
+
throw runtimeError("run_events_illegal", state.runId, "run.drive_opened is only legal while running or blocked_uncertain");
|
|
740
|
+
}
|
|
741
|
+
const driveId = stringPayload(event, "driveId");
|
|
742
|
+
const mode = stringPayload(event, "mode");
|
|
743
|
+
if (mode !== "simulated" && mode !== "live") {
|
|
744
|
+
throw runtimeError("run_events_illegal", state.runId, "run.drive_opened mode must be simulated or live");
|
|
745
|
+
}
|
|
746
|
+
state.drive = { driveId, mode, openedSequence: event.sequence };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function foldCancelRequested(
|
|
750
|
+
state: MutableState,
|
|
751
|
+
event: VnextRunEvent,
|
|
752
|
+
plan: VnextCompiledPlan | undefined,
|
|
753
|
+
options: VnextFoldOptions,
|
|
754
|
+
): void {
|
|
698
755
|
if (state.cancelRequested || TERMINAL_RUN.has(state.status)) {
|
|
699
756
|
throw runtimeError("run_events_illegal", state.runId, "duplicate run.cancel_requested is not legal");
|
|
700
757
|
}
|
|
758
|
+
if (event.payload.reason === "budget_run_duration") {
|
|
759
|
+
const budget = event.payload.budget;
|
|
760
|
+
if (!budget || typeof budget !== "object" || Array.isArray(budget)) {
|
|
761
|
+
throw runtimeError("run_events_illegal", state.runId, "budget_run_duration requires a budget payload");
|
|
762
|
+
}
|
|
763
|
+
const body = budget as Record<string, unknown>;
|
|
764
|
+
const budgetMs = body.budgetMs;
|
|
765
|
+
const elapsedMs = body.elapsedMs;
|
|
766
|
+
const source = body.source;
|
|
767
|
+
if (typeof budgetMs !== "number" || !Number.isInteger(budgetMs) || budgetMs < 0) {
|
|
768
|
+
throw runtimeError("run_events_illegal", state.runId, "budget_run_duration budgetMs is invalid");
|
|
769
|
+
}
|
|
770
|
+
if (typeof elapsedMs !== "number" || !Number.isInteger(elapsedMs) || elapsedMs < 0) {
|
|
771
|
+
throw runtimeError("run_events_illegal", state.runId, "budget_run_duration elapsedMs is invalid");
|
|
772
|
+
}
|
|
773
|
+
if (source !== "workflow" && source !== "project" && source !== "both") {
|
|
774
|
+
throw runtimeError("run_events_illegal", state.runId, "budget_run_duration source is invalid");
|
|
775
|
+
}
|
|
776
|
+
const expected = effectiveRunDurationBudget(plan?.limits, options.projectLimits);
|
|
777
|
+
if (!expected || expected.budgetMs !== budgetMs || expected.source !== source) {
|
|
778
|
+
throw runtimeError("run_events_illegal", state.runId, "budget_run_duration budgetMs does not match the plan");
|
|
779
|
+
}
|
|
780
|
+
if (elapsedMs < budgetMs) {
|
|
781
|
+
throw runtimeError("run_events_illegal", state.runId, "budget_run_duration elapsedMs is below budgetMs");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
701
784
|
state.cancelRequested = true;
|
|
702
785
|
if (event.commandId) state.cancelCommandId = event.commandId;
|
|
703
786
|
}
|
|
@@ -1379,6 +1462,8 @@ function freezeState(state: MutableState): VnextRunState {
|
|
|
1379
1462
|
cancelRequested: state.cancelRequested,
|
|
1380
1463
|
...(state.terminalReason !== undefined ? { terminalReason: state.terminalReason } : {}),
|
|
1381
1464
|
...(state.terminalReason !== undefined && state.status === "failed" ? { failureReason: state.terminalReason } : {}),
|
|
1465
|
+
...(state.drive !== undefined ? { drive: Object.freeze({ ...state.drive }) } : {}),
|
|
1466
|
+
...(state.runningSince !== undefined ? { runningSince: state.runningSince } : {}),
|
|
1382
1467
|
};
|
|
1383
1468
|
return Object.freeze(frozen);
|
|
1384
1469
|
}
|
|
@@ -257,7 +257,7 @@ export function recordGateNoStartInTransaction(
|
|
|
257
257
|
const cancelled = observation.stopCause === "cancel";
|
|
258
258
|
const now = new Date().toISOString();
|
|
259
259
|
const seq = sequencer(context, run, now);
|
|
260
|
-
|
|
260
|
+
const cancelReason = cancelled ? ensureOperatorCancel(seq, ids.state, run, context) : undefined;
|
|
261
261
|
const observationId = newVnextObservationId();
|
|
262
262
|
const observed = seq.push("effect.observed", {
|
|
263
263
|
effect: { id: ids.effectId, policy: { class: ids.gateKind === "artifacts-exist" ? "read-only" : "unknown", sharedMutable: false } },
|
|
@@ -289,7 +289,7 @@ export function recordGateNoStartInTransaction(
|
|
|
289
289
|
seq.push("attempt.status_changed", { attemptId: ids.attemptId, status: "terminal" });
|
|
290
290
|
seq.push("assignment.terminal", { assignmentId: ids.assignmentId, outcome: "cancelled", status: "terminal" });
|
|
291
291
|
seq.push("step.status_changed", { stepId, status: "cancelled", previousStatus: "running" });
|
|
292
|
-
seq.push("run.status_changed", { status: "cancelled", reason: "operator_cancel", stepId });
|
|
292
|
+
seq.push("run.status_changed", { status: "cancelled", reason: cancelReason ?? "operator_cancel", stepId });
|
|
293
293
|
} else {
|
|
294
294
|
seq.push("assignment.result_recorded", { assignmentId: ids.assignmentId, resultClass: "producer_rejected", status: "result_recorded" });
|
|
295
295
|
seq.push("attempt.status_changed", { attemptId: ids.attemptId, status: "terminal" });
|
|
@@ -385,7 +385,7 @@ export function recordGateCancelObservedInTransaction(
|
|
|
385
385
|
}
|
|
386
386
|
const now = new Date().toISOString();
|
|
387
387
|
const seq = sequencer(context, run, now);
|
|
388
|
-
ensureOperatorCancel(seq, ids.state, run);
|
|
388
|
+
const cancelReason = ensureOperatorCancel(seq, ids.state, run, context);
|
|
389
389
|
if (ids.gateKind === "artifacts-exist") {
|
|
390
390
|
seq.push("assignment.executing", { assignmentId: ids.assignmentId, stepId, stepAttempt: ids.stepAttempt, attemptId: ids.attemptId, status: "executing" });
|
|
391
391
|
seq.push("attempt.status_changed", { attemptId: ids.attemptId, assignmentId: ids.assignmentId, stepId, stepAttempt: ids.stepAttempt, status: "executing" });
|
|
@@ -428,7 +428,7 @@ export function recordGateCancelObservedInTransaction(
|
|
|
428
428
|
seq.push("attempt.status_changed", { attemptId: ids.attemptId, status: "terminal" });
|
|
429
429
|
seq.push("assignment.terminal", { assignmentId: ids.assignmentId, outcome: "cancelled", status: "terminal" });
|
|
430
430
|
seq.push("step.status_changed", { stepId, status: "cancelled", previousStatus: "running" });
|
|
431
|
-
seq.push("run.status_changed", { status: "cancelled", reason:
|
|
431
|
+
seq.push("run.status_changed", { status: "cancelled", reason: cancelReason, stepId });
|
|
432
432
|
for (const event of seq.events) context.eventStore.appendEvent(event);
|
|
433
433
|
context.eventStore.insertGateObservation(observationRow);
|
|
434
434
|
context.eventStore.settleCapability(ids.attemptId, "settled");
|
|
@@ -536,8 +536,9 @@ function ensureOperatorCancel(
|
|
|
536
536
|
seq: ReturnType<typeof sequencer>,
|
|
537
537
|
state: ReturnType<typeof foldStoredVnextRun>,
|
|
538
538
|
run: VnextRunRecord,
|
|
539
|
-
|
|
540
|
-
|
|
539
|
+
context: VnextRuntimeContext,
|
|
540
|
+
): string {
|
|
541
|
+
if (state.status === "cancelling") return cancelReasonFromLog(context, run.runId);
|
|
541
542
|
if (state.status !== "running") {
|
|
542
543
|
throw runtimeError("run_events_illegal", run.runId, "cancelled gate proof requires a running or cancelling run");
|
|
543
544
|
}
|
|
@@ -546,6 +547,18 @@ function ensureOperatorCancel(
|
|
|
546
547
|
reason: "operator_cancel",
|
|
547
548
|
});
|
|
548
549
|
seq.push("run.status_changed", { status: "cancelling", reason: "operator_cancel" });
|
|
550
|
+
return "operator_cancel";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function cancelReasonFromLog(context: VnextRuntimeContext, runId: string): string {
|
|
554
|
+
const events = context.eventStore.events(runId, 0, 1_000_000);
|
|
555
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
556
|
+
const event = events[index]!;
|
|
557
|
+
if (event.eventType === "run.cancel_requested" && typeof event.payload.reason === "string") {
|
|
558
|
+
return event.payload.reason;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return "operator_cancel";
|
|
549
562
|
}
|
|
550
563
|
|
|
551
564
|
function sequencer(context: VnextRuntimeContext, run: VnextRunRecord, now: string) {
|