@kontextmind/kxm 0.7.5 → 0.7.7

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.
@@ -0,0 +1,699 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { DatabaseSync } from "../sqlite.ts";
5
+ import { buildRetrospective, writeRetrospective } from "../retrospective.ts";
6
+ import { redactSecrets } from "../redact.ts";
7
+ import { postWorkflowSignal, watchGithubChecks } from "../github-watch.ts";
8
+ import {
9
+ listWorkflowDefinitions,
10
+ addWorkflowDefinition,
11
+ removeWorkflowDefinition,
12
+ modifyWorkflowDefinition,
13
+ WORKFLOW_TEMPLATES,
14
+ } from "../workflow-manager.ts";
15
+ import {
16
+ canonicalWorkflowEvidenceKey,
17
+ parseWorkflowDefinitions,
18
+ type WorkflowEvidenceInput,
19
+ type WorkflowJournalEntry,
20
+ type WorkflowRun,
21
+ } from "../workflow.ts";
22
+ import { discoverVnextProjectRoot } from "../vnext-config.ts";
23
+ import { ensureVnextSupervisor, vnextRuntimeRequest } from "../vnext-runtime-supervisor.ts";
24
+ import type { WorkerOutcome } from "../envelope.ts";
25
+ import {
26
+ print,
27
+ printWorker,
28
+ gateOf,
29
+ parseEvidencePairs,
30
+ maskEnvName,
31
+ type CliIo,
32
+ type Runtime,
33
+ } from "./types.ts";
34
+ import { resolvePickItem, type PickCandidate } from "./roles.ts";
35
+
36
+ const CLI_NAME = "kxm";
37
+
38
+ export function localWorkflowSnapshot(dataPath: string, runId?: string): { runs: WorkflowRun[]; journal: WorkflowJournalEntry[] } {
39
+ if (!existsSync(dataPath)) throw new Error("state_database_not_found");
40
+ const database = new DatabaseSync(dataPath, { readOnly: true });
41
+ try {
42
+ const rows = runId
43
+ ? database.prepare("SELECT record FROM workflow_runs WHERE id = ?").all(runId)
44
+ : database.prepare("SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 200").all();
45
+ const runs = (rows as Array<{ record: string }>).map((row) => JSON.parse(row.record) as WorkflowRun);
46
+ const journal = runId
47
+ ? (database.prepare("SELECT record FROM workflow_journal WHERE run_id = ? ORDER BY rowid").all(runId) as Array<{ record: string }>).map((row) => JSON.parse(row.record) as WorkflowJournalEntry)
48
+ : [];
49
+ return { runs, journal };
50
+ } finally {
51
+ database.close();
52
+ }
53
+ }
54
+
55
+ export async function postWorkflowStart(input: {
56
+ serverUrl: string;
57
+ definitionId: string;
58
+ secret: string;
59
+ deliveryId: string;
60
+ event?: string | undefined;
61
+ payload: Record<string, unknown>;
62
+ fetchImpl: typeof fetch;
63
+ }): Promise<{ status: number; runId?: string | undefined; duplicate: boolean }> {
64
+ const payload = input.event && input.payload["event"] === undefined
65
+ ? { ...input.payload, event: input.event }
66
+ : input.payload;
67
+ const body = JSON.stringify(payload);
68
+ const signature = `sha256=${createHmac("sha256", input.secret).update(body).digest("hex")}`;
69
+ const response = await input.fetchImpl(`${input.serverUrl.replace(/\/$/, "")}/v1/webhooks/${encodeURIComponent(input.definitionId)}`, {
70
+ method: "POST",
71
+ headers: {
72
+ "content-type": "application/json",
73
+ "x-hub-signature-256": signature,
74
+ "x-kxm-delivery-id": input.deliveryId,
75
+ ...(input.event ? { "x-github-event": input.event } : {}),
76
+ },
77
+ body,
78
+ });
79
+ const responseText = (await response.text()).slice(0, 8_000);
80
+ let parsed: { run?: { id?: string }; duplicate?: boolean } = {};
81
+ try {
82
+ parsed = JSON.parse(responseText);
83
+ } catch {
84
+ // fallback
85
+ }
86
+ if (!response.ok) throw new Error(`workflow_start_http_${response.status}`);
87
+ return {
88
+ status: response.status,
89
+ ...(parsed.run?.id ? { runId: parsed.run.id } : {}),
90
+ duplicate: parsed.duplicate === true,
91
+ };
92
+ }
93
+
94
+ export async function postWorkflowDegradation(input: {
95
+ serverUrl: string;
96
+ authToken: string;
97
+ runId: string;
98
+ stageId: string;
99
+ requirementKey: string;
100
+ reason: string;
101
+ fetchImpl: typeof fetch;
102
+ }): Promise<{ status: number; duplicate: boolean; approvalId?: string | undefined }> {
103
+ const response = await input.fetchImpl(
104
+ `${input.serverUrl.replace(/\/$/, "")}/v1/workflows/${encodeURIComponent(input.runId)}/degradations`,
105
+ {
106
+ method: "POST",
107
+ headers: {
108
+ authorization: `Bearer ${input.authToken}`,
109
+ "content-type": "application/json",
110
+ },
111
+ body: JSON.stringify({
112
+ stageId: input.stageId,
113
+ requirementKey: input.requirementKey,
114
+ reason: input.reason,
115
+ }),
116
+ },
117
+ );
118
+ const responseText = (await response.text()).slice(0, 8_000);
119
+ let parsed: { duplicate?: boolean; approval?: { id?: string } } = {};
120
+ try {
121
+ parsed = JSON.parse(responseText);
122
+ } catch {
123
+ // fallback
124
+ }
125
+ if (!response.ok) throw new Error(`workflow_degradation_http_${response.status}`);
126
+ return {
127
+ status: response.status,
128
+ duplicate: parsed.duplicate === true,
129
+ ...(parsed.approval?.id ? { approvalId: parsed.approval.id } : {}),
130
+ };
131
+ }
132
+
133
+ export function activeWorkflowDefinition(runtime: Runtime, definitionId: string) {
134
+ const inline = runtime.env.KXM_WEBHOOK_WORKFLOWS?.trim();
135
+ const file = runtime.env.KXM_WEBHOOK_WORKFLOWS_FILE?.trim();
136
+ if (inline && file) {
137
+ throw new Error("configure only one of KXM_WEBHOOK_WORKFLOWS or KXM_WEBHOOK_WORKFLOWS_FILE");
138
+ }
139
+ if (!inline && !file) return undefined;
140
+ let raw: string;
141
+ if (file) {
142
+ try {
143
+ raw = readFileSync(resolve(runtime.cwd, file), "utf8");
144
+ } catch {
145
+ throw new Error("workflow definition file is unavailable");
146
+ }
147
+ } else {
148
+ raw = inline!;
149
+ }
150
+ const definition = parseWorkflowDefinitions(raw, runtime.env).find((candidate) => candidate.id === definitionId);
151
+ if (!definition) throw new Error(`workflow definition not found: ${definitionId}`);
152
+ return definition;
153
+ }
154
+
155
+ export function workflowCredential(runtime: Runtime, definitionId: string, kind: "start" | "signal"): string | undefined {
156
+ const definition = activeWorkflowDefinition(runtime, definitionId);
157
+ if (definition) return kind === "start" ? definition.secret : definition.signalSecret ?? definition.secret;
158
+ return kind === "start"
159
+ ? runtime.env.KXM_WORKFLOW_SECRET?.trim()
160
+ : runtime.env.KXM_WORKFLOW_SIGNAL_SECRET?.trim();
161
+ }
162
+
163
+ export function reportWorkflowConfigError(runtime: Runtime, error: unknown): number {
164
+ const message = error instanceof Error ? redactSecrets(error.message) : "invalid workflow configuration";
165
+ runtime.io.stderr(`${message}\n`);
166
+ return 2;
167
+ }
168
+
169
+ export async function cmdWorkflowDefinitions(
170
+ runtime: Runtime,
171
+ options: { scope?: "all" | "global" | "local" | undefined },
172
+ ): Promise<number> {
173
+ const workflows = listWorkflowDefinitions({
174
+ scope: options.scope,
175
+ repoRoot: runtime.cwd,
176
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
177
+ });
178
+ if (runtime.json) {
179
+ print(runtime.io, true, { ok: true, command: "workflow definitions", workflows }, "");
180
+ return 0;
181
+ }
182
+ if (workflows.length === 0) {
183
+ runtime.io.stdout("No workflow definitions found.\n");
184
+ return 0;
185
+ }
186
+ const lines: string[] = ["WORKFLOW DEFINITIONS:"];
187
+ for (const w of workflows) {
188
+ const scopeTag = w.scope === "overridden" ? "[local override]" : `[${w.scope}]`;
189
+ const rolesTag = w.roles.length > 0 ? `(roles: ${w.roles.join(", ")})` : "";
190
+ lines.push(` ${w.id.padEnd(24)} ${scopeTag.padEnd(16)} ${w.stepCount} steps ${rolesTag} ${w.description}`);
191
+ }
192
+ print(runtime.io, false, {}, `${lines.join("\n")}\n`);
193
+ return 0;
194
+ }
195
+
196
+ export async function cmdWorkflowAdd(
197
+ runtime: Runtime,
198
+ workflowId: string | undefined,
199
+ options: {
200
+ file?: string | undefined;
201
+ description?: string | undefined;
202
+ scope?: "global" | "local" | undefined;
203
+ overwrite?: boolean | undefined;
204
+ pick?: string | boolean | undefined;
205
+ },
206
+ ): Promise<number> {
207
+ const scope = options.scope ?? "local";
208
+ let content: Record<string, unknown> | string | undefined;
209
+ if (!workflowId || options.pick) {
210
+ const candidates: PickCandidate[] = Object.entries(WORKFLOW_TEMPLATES).map(([id, tmpl]) => ({
211
+ id,
212
+ description: String(tmpl.description ?? id),
213
+ label: "template",
214
+ payload: tmpl,
215
+ }));
216
+ if (scope === "local") {
217
+ const globalDefs = listWorkflowDefinitions({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
218
+ for (const gd of globalDefs) {
219
+ if (!candidates.some((c) => c.id === gd.id)) {
220
+ candidates.push({ id: gd.id, description: gd.description, label: "global" });
221
+ }
222
+ }
223
+ }
224
+ const picked = await resolvePickItem(runtime.io, `Select a workflow template to add (${scope})`, candidates, options.pick, runtime.env);
225
+ if (!picked) {
226
+ if (!workflowId) {
227
+ runtime.io.stderr("workflow add failed: missing workflowId or pick selection\n");
228
+ return 1;
229
+ }
230
+ } else {
231
+ workflowId = picked.id;
232
+ if (!options.file && picked.payload) {
233
+ content = {
234
+ ...picked.payload,
235
+ ...(options.description ? { description: options.description } : {}),
236
+ };
237
+ }
238
+ }
239
+ }
240
+
241
+ if (options.file) {
242
+ const filePath = resolve(runtime.cwd, options.file);
243
+ content = readFileSync(filePath, "utf8");
244
+ } else if (!content) {
245
+ content = {
246
+ schema: "kxm.workflow.v1",
247
+ id: workflowId!,
248
+ description: options.description || `Workflow ${workflowId}`,
249
+ coordinator: "coordinator",
250
+ limits: { maxTransitions: 8 },
251
+ steps: [
252
+ {
253
+ id: "step-1",
254
+ kind: "agent",
255
+ role: "writer",
256
+ on: { passed: { target: "$terminal", terminalStatus: "completed" } },
257
+ },
258
+ ],
259
+ };
260
+ }
261
+
262
+ try {
263
+ const res = addWorkflowDefinition(workflowId!, content, {
264
+ scope,
265
+ repoRoot: runtime.cwd,
266
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
267
+ overwrite: options.overwrite,
268
+ });
269
+ print(
270
+ runtime.io,
271
+ runtime.json,
272
+ { ok: true, command: "workflow add", workflowId, ...res },
273
+ `Added workflow '${workflowId}' to ${res.scope} (${res.filePath})\n`,
274
+ );
275
+ return 0;
276
+ } catch (err: unknown) {
277
+ runtime.io.stderr(`workflow add failed: ${(err as Error).message}\n`);
278
+ return 1;
279
+ }
280
+ }
281
+
282
+ export async function cmdWorkflowRemove(
283
+ runtime: Runtime,
284
+ workflowId: string | undefined,
285
+ options: { scope?: "global" | "local" | undefined; pick?: string | boolean | undefined },
286
+ ): Promise<number> {
287
+ const scope = options.scope ?? "local";
288
+ if (!workflowId || options.pick) {
289
+ const workflows = listWorkflowDefinitions({
290
+ scope,
291
+ repoRoot: runtime.cwd,
292
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
293
+ });
294
+ if (workflows.length === 0) {
295
+ runtime.io.stdout(`No workflow definitions found in ${scope} scope to remove.\n`);
296
+ return 0;
297
+ }
298
+ const candidates: PickCandidate[] = workflows.map((w) => ({
299
+ id: w.id,
300
+ description: w.description,
301
+ label: w.scope,
302
+ }));
303
+ const picked = await resolvePickItem(runtime.io, `Select a workflow to remove (${scope})`, candidates, options.pick, runtime.env);
304
+ if (!picked) {
305
+ if (!workflowId) {
306
+ runtime.io.stderr("workflow remove failed: missing workflowId or pick selection\n");
307
+ return 1;
308
+ }
309
+ } else {
310
+ workflowId = picked.id;
311
+ }
312
+ }
313
+
314
+ try {
315
+ const res = removeWorkflowDefinition(workflowId!, {
316
+ scope,
317
+ repoRoot: runtime.cwd,
318
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
319
+ });
320
+ print(
321
+ runtime.io,
322
+ runtime.json,
323
+ { ok: true, command: "workflow remove", workflowId, ...res },
324
+ `Removed workflow '${workflowId}' from ${res.scope}\n`,
325
+ );
326
+ return 0;
327
+ } catch (err: unknown) {
328
+ runtime.io.stderr(`workflow remove failed: ${(err as Error).message}\n`);
329
+ return 1;
330
+ }
331
+ }
332
+
333
+ export async function cmdWorkflowModify(
334
+ runtime: Runtime,
335
+ workflowId: string | undefined,
336
+ options: {
337
+ description?: string | undefined;
338
+ scope?: "global" | "local" | undefined;
339
+ pick?: string | boolean | undefined;
340
+ },
341
+ ): Promise<number> {
342
+ if (!workflowId || options.pick) {
343
+ const workflows = listWorkflowDefinitions({
344
+ scope: options.scope,
345
+ repoRoot: runtime.cwd,
346
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
347
+ });
348
+ if (workflows.length === 0) {
349
+ runtime.io.stdout("No workflow definitions found to modify.\n");
350
+ return 0;
351
+ }
352
+ const candidates: PickCandidate[] = workflows.map((w) => ({
353
+ id: w.id,
354
+ description: w.description,
355
+ label: w.scope,
356
+ }));
357
+ const picked = await resolvePickItem(runtime.io, "Select a workflow to modify", candidates, options.pick, runtime.env);
358
+ if (!picked) {
359
+ if (!workflowId) {
360
+ runtime.io.stderr("workflow modify failed: missing workflowId or pick selection\n");
361
+ return 1;
362
+ }
363
+ } else {
364
+ workflowId = picked.id;
365
+ }
366
+ }
367
+
368
+ try {
369
+ const res = modifyWorkflowDefinition(
370
+ workflowId!,
371
+ { ...(options.description ? { description: options.description } : {}) },
372
+ {
373
+ scope: options.scope,
374
+ repoRoot: runtime.cwd,
375
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
376
+ },
377
+ );
378
+ print(
379
+ runtime.io,
380
+ runtime.json,
381
+ { ok: true, command: "workflow modify", workflowId, ...res },
382
+ `Modified workflow '${workflowId}' in ${res.scope}\n`,
383
+ );
384
+ return 0;
385
+ } catch (err: unknown) {
386
+ runtime.io.stderr(`workflow modify failed: ${(err as Error).message}\n`);
387
+ return 1;
388
+ }
389
+ }
390
+
391
+ export async function cmdWorkflowStart(runtime: Runtime, definitionIdArg: string | undefined, options: { payload?: string | undefined; deliveryId?: string | undefined; event?: string | undefined }): Promise<number> {
392
+ const definitionId = definitionIdArg || runtime.env.KXM_WORKFLOW_ID?.trim();
393
+ const deliveryId = String(options.deliveryId || `cli-${randomUUID()}`);
394
+ const event = options.event;
395
+ const payloadFlag = options.payload ?? "{}";
396
+ if (!definitionId) {
397
+ runtime.io.stderr("workflow start requires <definitionId> (or KXM_WORKFLOW_ID)\n");
398
+ return 2;
399
+ }
400
+ let secret: string | undefined;
401
+ try {
402
+ secret = workflowCredential(runtime, definitionId, "start");
403
+ } catch (error) {
404
+ return reportWorkflowConfigError(runtime, error);
405
+ }
406
+ if (!secret) {
407
+ runtime.io.stderr("workflow start requires KXM_WORKFLOW_SECRET when no active definition source is configured\n");
408
+ return 2;
409
+ }
410
+ let payload: Record<string, unknown>;
411
+ try {
412
+ const raw = payloadFlag.startsWith("@") ? readFileSync(resolve(runtime.cwd, payloadFlag.slice(1)), "utf8") : payloadFlag;
413
+ const value = JSON.parse(raw) as unknown;
414
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("object required");
415
+ payload = value as Record<string, unknown>;
416
+ } catch {
417
+ print(runtime.io, runtime.json, { ok: false, command: "workflow start", error: "invalid_payload" }, "workflow payload must be a JSON object or @file");
418
+ return 2;
419
+ }
420
+ if (runtime.dryRun) {
421
+ print(runtime.io, runtime.json, { ok: true, command: "workflow start", dryRun: true, definitionId, deliveryId, event }, "would POST a signed workflow webhook");
422
+ return 0;
423
+ }
424
+ try {
425
+ const response = await postWorkflowStart({ serverUrl: runtime.serverUrl, definitionId, secret, deliveryId, ...(event ? { event } : {}), payload, fetchImpl: runtime.fetchImpl });
426
+ print(runtime.io, runtime.json, { ok: true, command: "workflow start", definitionId, deliveryId, ...response }, `started workflow ${response.runId ?? "accepted"}`);
427
+ return 0;
428
+ } catch {
429
+ print(runtime.io, runtime.json, { ok: false, command: "workflow start", error: "workflow_start_failed" }, "signed workflow start failed");
430
+ return 1;
431
+ }
432
+ }
433
+
434
+ export async function cmdWorkflowDegrade(runtime: Runtime, runId: string, stageId: string, options: { requirement?: string | undefined; reason?: string | undefined }): Promise<number> {
435
+ const requirementKey = options.requirement?.trim() ?? "";
436
+ const reason = options.reason?.trim() ?? "";
437
+ const adminToken = runtime.env.KXM_AUTH_TOKEN?.trim();
438
+ if (!runId || !stageId || !requirementKey || !reason || !adminToken) {
439
+ runtime.io.stderr("workflow degrade requires <runId> <stageId>, --requirement, --reason, and KXM_AUTH_TOKEN\n");
440
+ return 2;
441
+ }
442
+ const worker = gateOf(runtime, "degrade");
443
+ if (runtime.dryRun) {
444
+ printWorker(
445
+ runtime,
446
+ worker,
447
+ { ok: true, command: "workflow degrade", dryRun: true, runId, stageId, requirementKey },
448
+ `would approve configured degraded quorum for ${runId}/${stageId}/${requirementKey}`,
449
+ );
450
+ return 0;
451
+ }
452
+ try {
453
+ const result = await postWorkflowDegradation({
454
+ serverUrl: runtime.serverUrl,
455
+ authToken: adminToken,
456
+ runId,
457
+ stageId,
458
+ requirementKey,
459
+ reason,
460
+ fetchImpl: runtime.fetchImpl,
461
+ });
462
+ printWorker(
463
+ runtime,
464
+ worker,
465
+ { ok: true, command: "workflow degrade", runId, stageId, requirementKey, ...result },
466
+ result.duplicate ? "degraded quorum was already approved" : "approved configured degraded quorum",
467
+ );
468
+ return 0;
469
+ } catch {
470
+ printWorker(
471
+ runtime,
472
+ worker,
473
+ { ok: false, command: "workflow degrade", error: "workflow_degradation_failed", runId, stageId, requirementKey },
474
+ "workflow degradation approval failed",
475
+ );
476
+ return 1;
477
+ }
478
+ }
479
+
480
+ export async function cmdWorkflowInspect(runtime: Runtime, action: "list" | "get", runId?: string | undefined): Promise<number> {
481
+ const dataPath = resolve(runtime.dirs.workdir, runtime.env.KXM_DATA_PATH?.trim() || join(runtime.dirs.state, "kxm.db"));
482
+ if (action === "get" && !runId) {
483
+ runtime.io.stderr(`Usage: ${CLI_NAME} workflow get <runId>\n`);
484
+ return 2;
485
+ }
486
+ try {
487
+ const snapshot = localWorkflowSnapshot(dataPath, runId);
488
+ if (runId && snapshot.runs.length === 0) {
489
+ print(runtime.io, runtime.json, { ok: false, command: "workflow get", error: "workflow_not_found" }, "workflow not found");
490
+ return 1;
491
+ }
492
+ const value = action === "get"
493
+ ? { ok: true, command: "workflow get", run: snapshot.runs[0], journal: snapshot.journal }
494
+ : { ok: true, command: "workflow list", runs: snapshot.runs };
495
+ print(runtime.io, runtime.json, value, action === "get" ? `workflow ${runId}` : `${snapshot.runs.length} workflow(s)`);
496
+ return 0;
497
+ } catch {
498
+ print(runtime.io, runtime.json, { ok: false, command: `workflow ${action}`, error: "state_unavailable" }, "local workflow state is unavailable");
499
+ return 1;
500
+ }
501
+ }
502
+
503
+ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: string, status: string, summary: string, evidenceArgs: string[], deliveryIdFlag?: string | undefined, recoveryAction?: string | undefined): Promise<number> {
504
+ if (!runId || !signalKey || !status || !summary) {
505
+ runtime.io.stderr(`Usage: ${CLI_NAME} gate signal <runId> <signalKey> <passed|warning|failed> <summary> [<required-key>=<evidence> ...]\n`);
506
+ return 2;
507
+ }
508
+ if (status !== "passed" && status !== "warning" && status !== "failed") {
509
+ runtime.io.stderr("status must be passed, warning, or failed\n");
510
+ return 2;
511
+ }
512
+ let evidence: WorkflowEvidenceInput;
513
+ try {
514
+ evidence = parseEvidencePairs(evidenceArgs);
515
+ } catch (error) {
516
+ runtime.io.stderr(`${error instanceof Error ? error.message : "invalid evidence"}\n`);
517
+ return 2;
518
+ }
519
+ const worker = gateOf(runtime, "signal");
520
+ const projectRoot = discoverVnextProjectRoot(runtime.cwd);
521
+ if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
522
+ if (runtime.dryRun) {
523
+ printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signal to vNext run");
524
+ return 0;
525
+ }
526
+ const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
527
+ try {
528
+ const supervisor = await ensureVnextSupervisor({ env: runtime.env });
529
+ const posted = await vnextRuntimeRequest(
530
+ supervisor,
531
+ "POST",
532
+ `/v1/runs/${encodeURIComponent(runId)}/signal?projectRoot=${encodeURIComponent(projectRoot)}`,
533
+ { signalKey, status, summary, evidence, deliveryId, ...(recoveryAction ? { action: recoveryAction } : {}) },
534
+ );
535
+ printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, unblocked: posted.unblocked === true, deliveryId }, "posted signal to vNext run");
536
+ return 0;
537
+ } catch (error) {
538
+ const msg = error instanceof Error ? error.message : "signal_failed";
539
+ printWorker(runtime, worker, { ok: false, command: "signal", error: "signal_failed", detail: msg, deliveryId }, "signal to vNext run failed");
540
+ return 1;
541
+ }
542
+ }
543
+
544
+ const definitionId = runtime.env.KXM_WORKFLOW_ID?.trim();
545
+ if (!definitionId) {
546
+ runtime.io.stderr("signal requires KXM_WORKFLOW_ID\n");
547
+ return 2;
548
+ }
549
+ let signalSecret: string | undefined;
550
+ try {
551
+ signalSecret = workflowCredential(runtime, definitionId, "signal");
552
+ } catch (error) {
553
+ return reportWorkflowConfigError(runtime, error);
554
+ }
555
+ if (!signalSecret) {
556
+ runtime.io.stderr("signal requires KXM_WORKFLOW_SIGNAL_SECRET when no active definition source is configured\n");
557
+ return 2;
558
+ }
559
+ if (runtime.dryRun) {
560
+ printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signed signal");
561
+ return 0;
562
+ }
563
+ const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
564
+ try {
565
+ const posted = await postWorkflowSignal({
566
+ serverUrl: runtime.serverUrl,
567
+ definitionId,
568
+ signalSecret,
569
+ runId,
570
+ signalKey,
571
+ status,
572
+ summary,
573
+ evidence,
574
+ deliveryId,
575
+ fetchImpl: runtime.fetchImpl,
576
+ });
577
+ printWorker(runtime, worker, { ok: true, command: "signal", duplicate: posted.duplicate, deliveryId }, "posted signed signal");
578
+ return 0;
579
+ } catch {
580
+ printWorker(runtime, worker, { ok: false, command: "signal", error: "signal_failed", deliveryId }, "signed signal failed");
581
+ return 1;
582
+ }
583
+ }
584
+
585
+ export async function cmdGithubWatch(runtime: Runtime, options: {
586
+ runId?: string | undefined;
587
+ stageId?: string | undefined;
588
+ signalKey?: string | undefined;
589
+ repo?: string | undefined;
590
+ pr?: string | undefined;
591
+ required?: string | undefined;
592
+ timeoutMs?: string | undefined;
593
+ intervalMs?: string | undefined;
594
+ deliveryId?: string | undefined;
595
+ }): Promise<number> {
596
+ const token = runtime.env.GITHUB_TOKEN?.trim() || runtime.env.GH_TOKEN?.trim();
597
+ const definitionId = runtime.env.KXM_WORKFLOW_ID?.trim();
598
+ const runId = String(options.runId || "");
599
+ const stageId = String(options.stageId || "");
600
+ const signalKey = String(options.signalKey || "");
601
+ const repo = String(options.repo || "");
602
+ const pr = Number(options.pr);
603
+ if (!definitionId || !runId || !stageId || !signalKey || !repo || !Number.isInteger(pr)) {
604
+ runtime.io.stderr("github watch requires KXM_WORKFLOW_ID, --run-id, --stage-id, --signal-key, --repo, --pr\n");
605
+ return 2;
606
+ }
607
+ let signalSecret: string | undefined;
608
+ try {
609
+ signalSecret = workflowCredential(runtime, definitionId, "signal");
610
+ } catch (error) {
611
+ return reportWorkflowConfigError(runtime, error);
612
+ }
613
+ if (!signalSecret) {
614
+ runtime.io.stderr("github watch requires KXM_WORKFLOW_SIGNAL_SECRET when no active definition source is configured\n");
615
+ return 2;
616
+ }
617
+ const result = await watchGithubChecks({
618
+ serverUrl: runtime.serverUrl,
619
+ definitionId,
620
+ signalSecret,
621
+ runId,
622
+ stageId,
623
+ signalKey,
624
+ repo,
625
+ pr,
626
+ required: options.required
627
+ ? [...new Set(String(options.required).split(",").map((name) => name.trim()).filter(Boolean))]
628
+ : [],
629
+ timeoutMs: Number(options.timeoutMs || 1_800_000),
630
+ intervalMs: Number(options.intervalMs || 15_000),
631
+ ...(options.deliveryId ? { deliveryId: options.deliveryId } : {}),
632
+ ...(token ? { token } : {}),
633
+ dryRun: runtime.dryRun,
634
+ fetchImpl: runtime.fetchImpl,
635
+ ...(runtime.io.now ? { now: runtime.io.now } : {}),
636
+ ...(runtime.io.sleep ? { sleep: runtime.io.sleep } : {}),
637
+ });
638
+ const worker = gateOf(runtime, "github-watch");
639
+ const payload = {
640
+ ok: result.exitCode === 0,
641
+ command: "github watch",
642
+ posted: result.posted,
643
+ status: result.status,
644
+ summary: result.summary,
645
+ evidence: result.evidence,
646
+ deliveryId: result.deliveryId,
647
+ skipped: result.skipped,
648
+ };
649
+ const outcome: WorkerOutcome = result.exitCode === 0
650
+ ? (result.status === "warning" ? "warning" : "passed")
651
+ : "failed";
652
+ if (JSON.stringify(payload).includes(token ?? "___never___") || Object.keys(runtime.env).some((key) => maskEnvName(key) && JSON.stringify(payload).includes(String(runtime.env[key])))) {
653
+ printWorker(runtime, worker, { ok: false, command: "github watch", error: "redaction_failure" }, "refusing to print a payload that contains a secret");
654
+ return 1;
655
+ }
656
+ printWorker(runtime, worker, payload, result.summary, outcome);
657
+ return result.exitCode;
658
+ }
659
+
660
+ export async function cmdRetrospectiveExport(runtime: Runtime, runId: string, options: { input?: string | undefined; outDir?: string | undefined }): Promise<number> {
661
+ if (!runId) {
662
+ runtime.io.stderr(`Usage: ${CLI_NAME} workflow export <runId>\n`);
663
+ return 2;
664
+ }
665
+ const snapshotFlag = String(options.input || "");
666
+ const snapshotPath = snapshotFlag ? resolve(runtime.cwd, snapshotFlag) : "";
667
+ let snapshot: { run: WorkflowRun; journal: WorkflowJournalEntry[] };
668
+ try {
669
+ if (snapshotPath) {
670
+ if (!existsSync(snapshotPath)) throw new Error("snapshot_missing");
671
+ snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as typeof snapshot;
672
+ } else {
673
+ const dataPath = resolve(runtime.dirs.workdir, runtime.env.KXM_DATA_PATH?.trim() || join(runtime.dirs.state, "kxm.db"));
674
+ const local = localWorkflowSnapshot(dataPath, runId);
675
+ if (!local.runs[0]) throw new Error("workflow_not_found");
676
+ snapshot = { run: local.runs[0], journal: local.journal };
677
+ }
678
+ if (snapshot.run.id !== runId) throw new Error("run_id_mismatch");
679
+ } catch (error) {
680
+ const reason = error instanceof Error ? error.message : "snapshot_invalid";
681
+ print(runtime.io, runtime.json, { ok: false, command: "retrospective export", error: reason }, "retrospective source is invalid or unavailable");
682
+ return 1;
683
+ }
684
+ const doc = buildRetrospective(snapshot.run, snapshot.journal);
685
+ const outDir = resolve(runtime.cwd, String(options.outDir || join(runtime.dirs.assets, "retrospectives")));
686
+ const assetsRoot = resolve(runtime.dirs.assets);
687
+ const assetsPrefix = `${assetsRoot}${process.platform === "win32" ? "\\" : "/"}`;
688
+ if (outDir !== assetsRoot && !outDir.startsWith(assetsPrefix)) {
689
+ print(runtime.io, runtime.json, { ok: false, command: "retrospective export", error: "output_outside_workspace_assets" }, "retrospectives must stay under the workspace assets directory");
690
+ return 2;
691
+ }
692
+ if (runtime.dryRun) {
693
+ print(runtime.io, runtime.json, { ok: true, command: "retrospective export", dryRun: true, runId: doc.runId }, `would export ${doc.runId}`);
694
+ return 0;
695
+ }
696
+ const written = writeRetrospective(outDir, doc);
697
+ print(runtime.io, runtime.json, { ok: true, command: "retrospective export", ...written, reviewDecision: doc.reviewDecision }, `exported ${written.jsonPath}`);
698
+ return 0;
699
+ }