@kontextmind/kxm 0.7.85 → 0.7.87
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/docs/configuration.md +2 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +898 -498
- package/plugins/kxm/dist/extension.js +70 -61
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +32 -10
- package/plugins/kxm/dist/runtime.js +76 -36
- package/plugins/kxm/dist/server.js +34 -23
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/context-skills.ts +51 -6
- package/plugins/kxm/src/cli/hub.ts +39 -14
- package/plugins/kxm/src/cli/project.ts +84 -23
- package/plugins/kxm/src/cli/roles.ts +48 -4
- package/plugins/kxm/src/cli/system.ts +18 -1
- package/plugins/kxm/src/cli/tasks.ts +34 -5
- package/plugins/kxm/src/cli/types.ts +30 -0
- package/plugins/kxm/src/cli/workflows.ts +20 -4
- package/plugins/kxm/src/cli.ts +57 -0
- package/plugins/kxm/src/config.ts +4 -3
- package/plugins/kxm/src/database.ts +72 -33
- package/plugins/kxm/src/local-snapshot.ts +4 -4
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/memory.ts +10 -7
- package/plugins/kxm/src/role.ts +18 -6
- package/plugins/kxm/src/runtime-supervisor.ts +52 -14
- package/plugins/kxm/src/session-work.ts +4 -1
- package/plugins/kxm/src/skills.ts +29 -15
- package/plugins/kxm/src/sqlite.ts +18 -0
- package/plugins/kxm/src/task-manager.ts +20 -13
- package/plugins/kxm/src/workflow-manager.ts +12 -4
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:f
|
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
|
-
import { createBackup, restoreBackup } from "../database.ts";
|
|
6
|
+
import { createBackup, planBackup, planRestore, restoreBackup } from "../database.ts";
|
|
7
7
|
import { refreshModelInventory } from "../model-inventory.ts";
|
|
8
8
|
import { listInventoryModels, listRoleBindings, loadRoutePolicy, setRouteState, updateRouteState } from "../routes.ts";
|
|
9
9
|
import {
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
} from "../kxm-update.ts";
|
|
44
44
|
import { loadKxmUpdateConfig } from "../kxm-update-config.ts";
|
|
45
45
|
import type { InstallKindReport, InstallProbe } from "../kxm-install-kind.ts";
|
|
46
|
-
import { print, type CliIo, type CliSpawnResult, type Runtime } from "./types.ts";
|
|
46
|
+
import { print, printPlan, refuseDryRun, type CliIo, type CliSpawnResult, type Runtime } from "./types.ts";
|
|
47
47
|
|
|
48
48
|
export const kxmDriveCliSeams: {
|
|
49
49
|
ensureSupervisor?: typeof ensureKxmSupervisor;
|
|
@@ -181,10 +181,24 @@ export async function cmdKxmInit(
|
|
|
181
181
|
|
|
182
182
|
export async function cmdBackup(runtime: Runtime, options: { out?: string | undefined }): Promise<number> {
|
|
183
183
|
try {
|
|
184
|
-
const
|
|
184
|
+
const backupOptions = {
|
|
185
185
|
projectRoot: runtime.cwd,
|
|
186
186
|
...(options.out ? { outDir: resolve(runtime.cwd, options.out) } : {}),
|
|
187
|
-
}
|
|
187
|
+
};
|
|
188
|
+
if (runtime.dryRun) {
|
|
189
|
+
const plan = planBackup(backupOptions);
|
|
190
|
+
printPlan(
|
|
191
|
+
runtime,
|
|
192
|
+
{ command: "backup", outDir: plan.outDir, stores: plan.stores },
|
|
193
|
+
[
|
|
194
|
+
...plan.stores.map((store) => ({ action: "write" as const, target: join(plan.outDir, store.backupFile) })),
|
|
195
|
+
{ action: "write", target: join(plan.outDir, "manifest.json") },
|
|
196
|
+
],
|
|
197
|
+
`back up ${plan.stores.length} store(s) to ${plan.outDir} (sources are not opened, so their WAL is not checkpointed)`,
|
|
198
|
+
);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const { manifest, outDir } = createBackup(backupOptions);
|
|
188
202
|
const payload = {
|
|
189
203
|
ok: true,
|
|
190
204
|
command: "backup",
|
|
@@ -211,6 +225,26 @@ export async function cmdBackup(runtime: Runtime, options: { out?: string | unde
|
|
|
211
225
|
|
|
212
226
|
export async function cmdRestore(runtime: Runtime, manifestArg: string): Promise<number> {
|
|
213
227
|
try {
|
|
228
|
+
if (runtime.dryRun) {
|
|
229
|
+
const plan = planRestore(resolve(runtime.cwd, manifestArg), { projectRoot: runtime.cwd });
|
|
230
|
+
printPlan(
|
|
231
|
+
runtime,
|
|
232
|
+
{
|
|
233
|
+
command: "restore",
|
|
234
|
+
backupId: plan.backupId,
|
|
235
|
+
manifestPath: plan.manifestPath,
|
|
236
|
+
stores: plan.stores.map(({ storeId, targetPath, schemaVersion }) => ({ storeId, targetPath, schemaVersion })),
|
|
237
|
+
},
|
|
238
|
+
plan.stores.flatMap((store) => [
|
|
239
|
+
{ action: "write" as const, target: store.targetPath },
|
|
240
|
+
...[`${store.targetPath}-wal`, `${store.targetPath}-shm`]
|
|
241
|
+
.filter((file) => existsSync(file))
|
|
242
|
+
.map((file) => ({ action: "delete" as const, target: file })),
|
|
243
|
+
]),
|
|
244
|
+
`restore ${plan.stores.length} store(s) from ${plan.manifestPath}; digests verified against the manifest`,
|
|
245
|
+
);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
214
248
|
const result = restoreBackup(resolve(runtime.cwd, manifestArg), {
|
|
215
249
|
projectRoot: runtime.cwd,
|
|
216
250
|
});
|
|
@@ -281,10 +315,23 @@ export async function cmdKxmTrust(runtime: Runtime, check: boolean, options: { b
|
|
|
281
315
|
}
|
|
282
316
|
}
|
|
283
317
|
|
|
318
|
+
/** The supervisor a run read talks to. Normally it is started on demand; under
|
|
319
|
+
* `--dry-run` a read may only attach, because starting the Runtime is a change. */
|
|
320
|
+
async function readSupervisor(runtime: Runtime, command: string): Promise<Awaited<ReturnType<typeof ensureKxmSupervisor>> | number> {
|
|
321
|
+
if (!runtime.dryRun) return await (kxmDriveCliSeams.ensureSupervisor ?? ensureKxmSupervisor)({ env: runtime.env });
|
|
322
|
+
const attached = await attachKxmSupervisor({ env: runtime.env });
|
|
323
|
+
return attached ?? refuseDryRun(runtime.io, runtime.json, command, "the Runtime supervisor is not running and --dry-run will not start it");
|
|
324
|
+
}
|
|
325
|
+
|
|
284
326
|
export const RUN_ENGINE_PHASE = "pre-3a";
|
|
285
327
|
export const RUN_ENGINE_NOTICE = "runs remain created until the run engine lands; no steps execute yet";
|
|
286
328
|
|
|
287
|
-
|
|
329
|
+
/** Where `kxm run <workflow>` would create its run, or the exit code of the
|
|
330
|
+
* refusal it already printed. Reads only; shared with `task run --dry-run`. */
|
|
331
|
+
export function resolveKxmRunTarget(
|
|
332
|
+
runtime: Runtime,
|
|
333
|
+
workflow: string | undefined,
|
|
334
|
+
): { projectRoot: string; workflowId: string; configRevision: string } | number {
|
|
288
335
|
if (runtime.workspaceFlag !== undefined) {
|
|
289
336
|
print(runtime.io, runtime.json, {
|
|
290
337
|
ok: false,
|
|
@@ -297,33 +344,38 @@ export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined,
|
|
|
297
344
|
print(runtime.io, runtime.json, { ok: false, command: "run", error: "workflow_required" }, "usage: kxm run <workflow> [prompt]");
|
|
298
345
|
return 2;
|
|
299
346
|
}
|
|
347
|
+
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
348
|
+
if (!projectRoot) {
|
|
349
|
+
print(runtime.io, runtime.json, { ok: false, command: "run", error: "project_required" }, "kxm run requires a KXM project (run kxm init first)");
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
const bundle = loadKxmProject(projectRoot, {});
|
|
353
|
+
if (!bundle.workflows.has(workflow)) {
|
|
354
|
+
print(runtime.io, runtime.json, { ok: false, command: "run", error: "run_workflow_unknown", workflow }, `workflow ${workflow} does not exist in this project`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
return { projectRoot, workflowId: workflow, configRevision: bundle.configRevision };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function cmdKxmRun(runtime: Runtime, workflow: string | undefined, promptParts: string[]): Promise<number> {
|
|
300
361
|
try {
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
return 1;
|
|
305
|
-
}
|
|
306
|
-
const bundle = loadKxmProject(projectRoot, {});
|
|
307
|
-
if (!bundle.workflows.has(workflow)) {
|
|
308
|
-
print(runtime.io, runtime.json, { ok: false, command: "run", error: "run_workflow_unknown", workflow }, `workflow ${workflow} does not exist in this project`);
|
|
309
|
-
return 1;
|
|
310
|
-
}
|
|
362
|
+
const target = resolveKxmRunTarget(runtime, workflow);
|
|
363
|
+
if (typeof target === "number") return target;
|
|
364
|
+
const { projectRoot } = target;
|
|
311
365
|
if (runtime.dryRun) {
|
|
312
366
|
print(runtime.io, runtime.json, {
|
|
313
367
|
ok: true,
|
|
314
368
|
command: "run",
|
|
315
369
|
dryRun: true,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
configRevision: bundle.configRevision,
|
|
319
|
-
}, `run plan: workflow ${workflow} at ${bundle.configRevision.slice(0, 19)}… (no run created)`);
|
|
370
|
+
...target,
|
|
371
|
+
}, `run plan: workflow ${target.workflowId} at ${target.configRevision.slice(0, 19)}… (no run created)`);
|
|
320
372
|
return 0;
|
|
321
373
|
}
|
|
322
374
|
const supervisor = await ensureKxmSupervisor({ env: runtime.env });
|
|
323
375
|
const prompt = promptParts.join(" ").trim();
|
|
324
376
|
const acceptance = await kxmRuntimeRequest(supervisor, "POST", "/v1/runs", {
|
|
325
377
|
projectRoot,
|
|
326
|
-
workflowId:
|
|
378
|
+
workflowId: target.workflowId,
|
|
327
379
|
prompt,
|
|
328
380
|
});
|
|
329
381
|
const run = acceptance.run as { runId: string; homeRuntimeId: string; status: string; configRevision: string };
|
|
@@ -408,7 +460,8 @@ export async function cmdKxmRunStatus(runtime: Runtime, runId: string): Promise<
|
|
|
408
460
|
print(runtime.io, runtime.json, { ok: false, command: "runs status", error: "project_required" }, "kxm runs status requires a KXM project (run kxm init first)");
|
|
409
461
|
return 1;
|
|
410
462
|
}
|
|
411
|
-
const supervisor = await (
|
|
463
|
+
const supervisor = await readSupervisor(runtime, "runs status");
|
|
464
|
+
if (typeof supervisor === "number") return supervisor;
|
|
412
465
|
const result = await (kxmDriveCliSeams.runtimeRequest ?? kxmRuntimeRequest)(supervisor, "GET", `/v1/runs/${encodeURIComponent(runId)}?projectRoot=${encodeURIComponent(projectRoot)}`);
|
|
413
466
|
const run = result.run as { runId: string; status: string; workflowId: string; configRevision: string; updatedAt: string };
|
|
414
467
|
const drive = result.drive as KxmStatusDrive | undefined;
|
|
@@ -570,7 +623,8 @@ export async function cmdKxmRunList(runtime: Runtime): Promise<number> {
|
|
|
570
623
|
}
|
|
571
624
|
const bundle = loadKxmProject(projectRoot, {});
|
|
572
625
|
const projectId = String(bundle.project.value.id);
|
|
573
|
-
const supervisor = await
|
|
626
|
+
const supervisor = await readSupervisor(runtime, "runs list");
|
|
627
|
+
if (typeof supervisor === "number") return supervisor;
|
|
574
628
|
const result = await kxmRuntimeRequest(supervisor, "GET", `/v1/projects/${encodeURIComponent(projectId)}/runs?projectRoot=${encodeURIComponent(projectRoot)}`);
|
|
575
629
|
const runs = (result.runs ?? []) as Array<{ runId: string; status: string; workflowId: string; createdAt: string; projectionError?: string }>;
|
|
576
630
|
print(
|
|
@@ -828,6 +882,12 @@ function formatKxmSyncStatus(sync: KxmProjectSyncStatus[] | undefined, running:
|
|
|
828
882
|
if (sync.length === 0) return ["sync: no project registered with this Runtime yet"];
|
|
829
883
|
return sync.map((project) => {
|
|
830
884
|
const codes = project.outbox.refusals.map((refusal) => `${refusal.code} x${refusal.count}`).join(", ");
|
|
885
|
+
// A store this build refuses to open reports zeros it cannot read, not an
|
|
886
|
+
// empty outbox. Saying "pending 0" there would send the operator looking
|
|
887
|
+
// somewhere else while every row in the project sits unreachable.
|
|
888
|
+
if (project.storeReadable === false) {
|
|
889
|
+
return `sync ${project.projectId}: blocked (its store is not readable by this build) — ${project.lastError ?? "unknown reason"}`;
|
|
890
|
+
}
|
|
831
891
|
const counts = `pending ${project.outbox.pending}, acked ${project.outbox.acked}, refused ${project.outbox.refused}`;
|
|
832
892
|
const tail = project.state === "refusing"
|
|
833
893
|
? ` (${codes || "see log"}) — fix the hub, then: kxm runtime sync-retry`
|
|
@@ -845,7 +905,8 @@ export async function cmdKxmRunReceipt(runtime: Runtime, runId: string, options:
|
|
|
845
905
|
print(runtime.io, runtime.json, { ok: false, command: "runs receipt", error: "project_required" }, "kxm runs receipt requires a KXM project (run kxm init first)");
|
|
846
906
|
return 1;
|
|
847
907
|
}
|
|
848
|
-
const supervisor = await (
|
|
908
|
+
const supervisor = await readSupervisor(runtime, "runs receipt");
|
|
909
|
+
if (typeof supervisor === "number") return supervisor;
|
|
849
910
|
const result = await (kxmDriveCliSeams.runtimeRequest ?? kxmRuntimeRequest)(
|
|
850
911
|
supervisor,
|
|
851
912
|
"GET",
|
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
6
|
-
import { DatabaseSync } from "../sqlite.ts";
|
|
6
|
+
import { DatabaseSync, openReadOnlyDatabase } from "../sqlite.ts";
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_ROLES,
|
|
9
9
|
DEFAULT_ROLE_SEATS,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { discoverKxmProjectRoot } from "../project-config.ts";
|
|
21
21
|
import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
|
|
22
22
|
import { resumeWorkflowFromRuling, type WorkflowRun } from "../workflow.ts";
|
|
23
|
-
import { print, type CliIo, type Runtime } from "./types.ts";
|
|
23
|
+
import { print, printPlan, type CliIo, type Runtime } from "./types.ts";
|
|
24
24
|
|
|
25
25
|
export interface PickCandidate {
|
|
26
26
|
id: string;
|
|
@@ -192,7 +192,12 @@ export async function cmdRoleAdd(
|
|
|
192
192
|
repoRoot: runtime.cwd,
|
|
193
193
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
194
194
|
overwrite: options.overwrite,
|
|
195
|
+
dryRun: runtime.dryRun,
|
|
195
196
|
});
|
|
197
|
+
if (runtime.dryRun) {
|
|
198
|
+
printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
196
201
|
print(
|
|
197
202
|
runtime.io,
|
|
198
203
|
runtime.json,
|
|
@@ -232,7 +237,12 @@ export async function cmdRoleAdd(
|
|
|
232
237
|
repoRoot: runtime.cwd,
|
|
233
238
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
234
239
|
overwrite: options.overwrite,
|
|
240
|
+
dryRun: runtime.dryRun,
|
|
235
241
|
});
|
|
242
|
+
if (runtime.dryRun) {
|
|
243
|
+
printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
236
246
|
print(
|
|
237
247
|
runtime.io,
|
|
238
248
|
runtime.json,
|
|
@@ -283,7 +293,12 @@ export async function cmdRoleRemove(
|
|
|
283
293
|
scope,
|
|
284
294
|
repoRoot: runtime.cwd,
|
|
285
295
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
296
|
+
dryRun: runtime.dryRun,
|
|
286
297
|
});
|
|
298
|
+
if (runtime.dryRun) {
|
|
299
|
+
printPlan(runtime, { command: "role remove", roleId, ...res }, [{ action: "delete", target: res.filePath }], `remove role '${roleId}' from ${res.scope}`);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
287
302
|
print(
|
|
288
303
|
runtime.io,
|
|
289
304
|
runtime.json,
|
|
@@ -377,7 +392,12 @@ export async function cmdRoleModify(
|
|
|
377
392
|
scope: options.scope,
|
|
378
393
|
repoRoot: runtime.cwd,
|
|
379
394
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
395
|
+
dryRun: runtime.dryRun,
|
|
380
396
|
});
|
|
397
|
+
if (runtime.dryRun) {
|
|
398
|
+
printPlan(runtime, { command: "role modify", roleId, ...res }, [{ action: "write", target: res.filePath }], `modify role '${roleId}' in ${res.scope}`);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
381
401
|
print(
|
|
382
402
|
runtime.io,
|
|
383
403
|
runtime.json,
|
|
@@ -482,7 +502,17 @@ export async function cmdRoleSetHost(
|
|
|
482
502
|
scope: options.scope ?? "local",
|
|
483
503
|
repoRoot: runtime.cwd,
|
|
484
504
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
505
|
+
dryRun: runtime.dryRun,
|
|
485
506
|
});
|
|
507
|
+
if (runtime.dryRun) {
|
|
508
|
+
printPlan(
|
|
509
|
+
runtime,
|
|
510
|
+
{ command: "role set-host", seatId, host, binding: result.binding, filePath: result.filePath, scope: result.scope },
|
|
511
|
+
[{ action: "write", target: result.filePath }],
|
|
512
|
+
`bind seat '${seatId}' to host '${host}'`,
|
|
513
|
+
);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
486
516
|
|
|
487
517
|
print(
|
|
488
518
|
runtime.io,
|
|
@@ -521,7 +551,12 @@ export async function cmdRoleResume(
|
|
|
521
551
|
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
522
552
|
if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
|
|
523
553
|
if (runtime.dryRun) {
|
|
524
|
-
|
|
554
|
+
printPlan(
|
|
555
|
+
runtime,
|
|
556
|
+
{ command: "role resume", runId, ruling: effectiveRuling },
|
|
557
|
+
[{ action: "request", target: `POST kxm-runtime /v1/runs/${runId}/signal (audit_escalation unblock)` }],
|
|
558
|
+
`resume KXM run ${runId}`,
|
|
559
|
+
);
|
|
525
560
|
return 0;
|
|
526
561
|
}
|
|
527
562
|
try {
|
|
@@ -555,7 +590,7 @@ export async function cmdRoleResume(
|
|
|
555
590
|
const dbPath = join(runtime.cwd, ".kxm", "state", "kxm.db");
|
|
556
591
|
if (existsSync(dbPath)) {
|
|
557
592
|
try {
|
|
558
|
-
const database = new DatabaseSync(dbPath);
|
|
593
|
+
const database = runtime.dryRun ? openReadOnlyDatabase(dbPath) : new DatabaseSync(dbPath);
|
|
559
594
|
try {
|
|
560
595
|
const row = database.prepare("SELECT record FROM workflow_runs WHERE id = ?").get(runId) as { record: string } | undefined;
|
|
561
596
|
if (!row) {
|
|
@@ -565,6 +600,15 @@ export async function cmdRoleResume(
|
|
|
565
600
|
const run = JSON.parse(row.record) as WorkflowRun;
|
|
566
601
|
const now = new Date().toISOString();
|
|
567
602
|
const resumeResult = resumeWorkflowFromRuling(run, effectiveRuling, now);
|
|
603
|
+
if (runtime.dryRun) {
|
|
604
|
+
printPlan(
|
|
605
|
+
runtime,
|
|
606
|
+
{ command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status },
|
|
607
|
+
[{ action: "write", target: `${dbPath} (workflow_runs ${runId}, one workflow_journal decision)` }],
|
|
608
|
+
`resume workflow run ${runId} (stage: ${resumeResult.stageId})`,
|
|
609
|
+
);
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
568
612
|
|
|
569
613
|
database.prepare("UPDATE workflow_runs SET record = ? WHERE id = ?").run(
|
|
570
614
|
JSON.stringify(resumeResult.run),
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
import { kxmUserStateRoot } from "../bindings.ts";
|
|
89
89
|
import {
|
|
90
90
|
print,
|
|
91
|
+
printPlan,
|
|
91
92
|
printWorker,
|
|
92
93
|
gateOf,
|
|
93
94
|
type CliIo,
|
|
@@ -224,6 +225,10 @@ export async function cmdSshRun(
|
|
|
224
225
|
options: { sudo?: boolean | undefined },
|
|
225
226
|
): Promise<number> {
|
|
226
227
|
const command = commandParts.join(" ");
|
|
228
|
+
if (runtime.dryRun) {
|
|
229
|
+
printPlan(runtime, { command: "ssh run", host, remoteCommand: command, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${options.sudo ? "sudo " : ""}${command}` }], `run a command on ${host}`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
227
232
|
const receipt = executeSshRun({
|
|
228
233
|
action: "command",
|
|
229
234
|
host,
|
|
@@ -247,6 +252,10 @@ export async function cmdSshFile(
|
|
|
247
252
|
options: { content?: string | undefined; read?: boolean | undefined; append?: boolean | undefined; sudo?: boolean | undefined },
|
|
248
253
|
): Promise<number> {
|
|
249
254
|
const op = options.read ? "read" : (options.append ? "append" : "write");
|
|
255
|
+
if (runtime.dryRun) {
|
|
256
|
+
printPlan(runtime, { command: "ssh file", host, path: filePath, op, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${op} ${filePath}` }], `${op} ${filePath} on ${host}`);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
250
259
|
const receipt = executeSshRun({
|
|
251
260
|
action: "file",
|
|
252
261
|
host,
|
|
@@ -269,6 +278,10 @@ export async function cmdSshFile(
|
|
|
269
278
|
}
|
|
270
279
|
|
|
271
280
|
export async function cmdSshClose(runtime: Runtime, host: string): Promise<number> {
|
|
281
|
+
if (runtime.dryRun) {
|
|
282
|
+
printPlan(runtime, { command: "ssh close", host }, [{ action: "ssh", target: `${host}: close the ControlMaster socket` }], `close the ControlMaster socket for ${host}`);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
272
285
|
const closed = closeControlSocket(host);
|
|
273
286
|
if (runtime.json) {
|
|
274
287
|
print(runtime.io, runtime.json, { ok: true, closed, command: "ssh close", host }, "");
|
|
@@ -568,7 +581,11 @@ export async function cmdConfigSet(
|
|
|
568
581
|
} catch {
|
|
569
582
|
// keep string
|
|
570
583
|
}
|
|
571
|
-
setKxmConfigValue(runtime.cwd, key, parsedVal, { scope });
|
|
584
|
+
const { file } = setKxmConfigValue(runtime.cwd, key, parsedVal, { scope, dryRun: runtime.dryRun });
|
|
585
|
+
if (runtime.dryRun) {
|
|
586
|
+
printPlan(runtime, { command: "config set", key, value: parsedVal, scope }, [{ action: "write", target: file }], `set ${key} = ${value} in ${scope} config`);
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
572
589
|
print(
|
|
573
590
|
runtime.io,
|
|
574
591
|
runtime.json,
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
getTask,
|
|
12
12
|
updateTaskStatus,
|
|
13
13
|
syncTaskWithTracker,
|
|
14
|
+
goalFilePath,
|
|
15
|
+
taskFilePath,
|
|
14
16
|
type TaskStatus,
|
|
15
17
|
type TrackerType,
|
|
16
18
|
} from "../task-manager.ts";
|
|
@@ -22,8 +24,8 @@ import {
|
|
|
22
24
|
generateStudioLayout,
|
|
23
25
|
} from "../studio-layout.ts";
|
|
24
26
|
import { readSessionTokenFromDisk } from "../commands.ts";
|
|
25
|
-
import { print, type Runtime } from "./types.ts";
|
|
26
|
-
import { cmdKxmRun } from "./project.ts";
|
|
27
|
+
import { print, printPlan, type Runtime } from "./types.ts";
|
|
28
|
+
import { cmdKxmRun, resolveKxmRunTarget } from "./project.ts";
|
|
27
29
|
|
|
28
30
|
export async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
|
|
29
31
|
try {
|
|
@@ -74,7 +76,11 @@ export async function cmdGoalCreate(
|
|
|
74
76
|
area: options.area,
|
|
75
77
|
successMetrics: options.metric,
|
|
76
78
|
targetDate: options.targetDate,
|
|
77
|
-
});
|
|
79
|
+
}, { dryRun: runtime.dryRun });
|
|
80
|
+
if (runtime.dryRun) {
|
|
81
|
+
printPlan(runtime, { command: "goal create", goal }, [{ action: "write", target: goalFilePath(runtime.cwd, goal.id) }], `create goal ${goal.title} (the id is assigned when it is created)`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
78
84
|
print(
|
|
79
85
|
runtime.io,
|
|
80
86
|
runtime.json,
|
|
@@ -118,7 +124,11 @@ export async function cmdTaskCreate(
|
|
|
118
124
|
trackerSync: options.tracker && options.issue
|
|
119
125
|
? { tracker: options.tracker as TrackerType, issueKey: options.issue }
|
|
120
126
|
: undefined,
|
|
121
|
-
});
|
|
127
|
+
}, { dryRun: runtime.dryRun });
|
|
128
|
+
if (runtime.dryRun) {
|
|
129
|
+
printPlan(runtime, { command: "task create", task }, [{ action: "write", target: taskFilePath(runtime.cwd, task.id) }], `create task ${task.title} [${task.status}] (the id is assigned when it is created)`);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
122
132
|
print(
|
|
123
133
|
runtime.io,
|
|
124
134
|
runtime.json,
|
|
@@ -187,6 +197,21 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
|
|
|
187
197
|
return 1;
|
|
188
198
|
}
|
|
189
199
|
const workflow = task.assignedWorkflow ?? "default";
|
|
200
|
+
if (runtime.dryRun) {
|
|
201
|
+
const target = resolveKxmRunTarget(runtime, workflow);
|
|
202
|
+
if (typeof target === "number") return target;
|
|
203
|
+
const started = updateTaskStatus(runtime.cwd, taskId, "in_progress", { dryRun: true });
|
|
204
|
+
printPlan(
|
|
205
|
+
runtime,
|
|
206
|
+
{ command: "task run", taskId, ...target, status: started.status },
|
|
207
|
+
[
|
|
208
|
+
{ action: "request", target: "POST kxm-runtime /v1/runs (starts the Runtime supervisor if it is not running)" },
|
|
209
|
+
{ action: "write", target: taskFilePath(runtime.cwd, taskId) },
|
|
210
|
+
],
|
|
211
|
+
`run workflow ${workflow} for task ${taskId}, then mark it ${started.status}`,
|
|
212
|
+
);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
190
215
|
const exitCode = await cmdKxmRun(runtime, workflow, [task.objective]);
|
|
191
216
|
if (exitCode === 0) {
|
|
192
217
|
updateTaskStatus(runtime.cwd, taskId, "in_progress");
|
|
@@ -201,7 +226,11 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
|
|
|
201
226
|
|
|
202
227
|
export async function cmdTaskSync(runtime: Runtime, taskId: string): Promise<number> {
|
|
203
228
|
try {
|
|
204
|
-
const synced = syncTaskWithTracker(runtime.cwd, taskId);
|
|
229
|
+
const synced = syncTaskWithTracker(runtime.cwd, taskId, { dryRun: runtime.dryRun });
|
|
230
|
+
if (runtime.dryRun) {
|
|
231
|
+
printPlan(runtime, { command: "task sync", task: synced }, [{ action: "write", target: taskFilePath(runtime.cwd, taskId) }], `mark task ${taskId} synced with ${synced.trackerSync?.tracker} #${synced.trackerSync?.issueKey}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
205
234
|
print(
|
|
206
235
|
runtime.io,
|
|
207
236
|
runtime.json,
|
|
@@ -101,6 +101,36 @@ export function print(io: CliIo, jsonMode: boolean, payload: object, text: strin
|
|
|
101
101
|
else io.stdout(line);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** One change a command would have made had `--dry-run` not been given. */
|
|
105
|
+
export interface PlannedChange {
|
|
106
|
+
action: "write" | "delete" | "move" | "request" | "ssh";
|
|
107
|
+
target: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The `--dry-run` answer of a mutating command: the envelope the real run
|
|
111
|
+
* prints, marked `dryRun: true`, plus every write, request, or remote step it
|
|
112
|
+
* would have taken. Nothing is written by the caller before or after this. */
|
|
113
|
+
export function printPlan(
|
|
114
|
+
runtime: Runtime,
|
|
115
|
+
payload: Record<string, unknown> & { command: string },
|
|
116
|
+
planned: readonly PlannedChange[],
|
|
117
|
+
text: string,
|
|
118
|
+
): void {
|
|
119
|
+
print(runtime.io, runtime.json, { ok: true, ...payload, dryRun: true, planned }, [
|
|
120
|
+
`dry run: ${text}`,
|
|
121
|
+
...(planned.length === 0 ? [" no changes"] : planned.map((change) => ` would ${change.action} ${change.target}`)),
|
|
122
|
+
].join("\n"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const DRY_RUN_UNSUPPORTED = "dry_run_unsupported";
|
|
126
|
+
|
|
127
|
+
/** For a command that cannot say what it would do without doing some of it:
|
|
128
|
+
* refuse under `--dry-run` instead of executing. */
|
|
129
|
+
export function refuseDryRun(io: CliIo, jsonMode: boolean, command: string, reason: string): number {
|
|
130
|
+
print(io, jsonMode, { ok: false, command, dryRun: true, error: DRY_RUN_UNSUPPORTED, detail: reason }, `kxm ${command} --dry-run refused: ${reason}`);
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
export function printWorker(
|
|
105
135
|
runtime: Runtime,
|
|
106
136
|
worker: Worker,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHmac, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { openReadOnlyDatabase } from "../sqlite.ts";
|
|
5
5
|
import { buildRetrospective, writeRetrospective } from "../retrospective.ts";
|
|
6
6
|
import { redactSecrets } from "../redact.ts";
|
|
7
7
|
import { postWorkflowSignal, watchGithubChecks } from "../github-watch.ts";
|
|
@@ -24,6 +24,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts
|
|
|
24
24
|
import type { WorkerOutcome } from "../envelope.ts";
|
|
25
25
|
import {
|
|
26
26
|
print,
|
|
27
|
+
printPlan,
|
|
27
28
|
printWorker,
|
|
28
29
|
gateOf,
|
|
29
30
|
parseEvidencePairs,
|
|
@@ -37,7 +38,7 @@ const CLI_NAME = "kxm";
|
|
|
37
38
|
|
|
38
39
|
export function localWorkflowSnapshot(dataPath: string, runId?: string): { runs: WorkflowRun[]; journal: WorkflowJournalEntry[] } {
|
|
39
40
|
if (!existsSync(dataPath)) throw new Error("state_database_not_found");
|
|
40
|
-
const database =
|
|
41
|
+
const database = openReadOnlyDatabase(dataPath);
|
|
41
42
|
try {
|
|
42
43
|
const rows = runId
|
|
43
44
|
? database.prepare("SELECT record FROM workflow_runs WHERE id = ?").all(runId)
|
|
@@ -265,7 +266,12 @@ export async function cmdWorkflowAdd(
|
|
|
265
266
|
repoRoot: runtime.cwd,
|
|
266
267
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
267
268
|
overwrite: options.overwrite,
|
|
269
|
+
dryRun: runtime.dryRun,
|
|
268
270
|
});
|
|
271
|
+
if (runtime.dryRun) {
|
|
272
|
+
printPlan(runtime, { command: "workflow add", workflowId, ...res }, [{ action: "write", target: res.filePath }], `add workflow '${workflowId}' to ${res.scope}`);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
269
275
|
print(
|
|
270
276
|
runtime.io,
|
|
271
277
|
runtime.json,
|
|
@@ -316,7 +322,12 @@ export async function cmdWorkflowRemove(
|
|
|
316
322
|
scope,
|
|
317
323
|
repoRoot: runtime.cwd,
|
|
318
324
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
325
|
+
dryRun: runtime.dryRun,
|
|
319
326
|
});
|
|
327
|
+
if (runtime.dryRun) {
|
|
328
|
+
printPlan(runtime, { command: "workflow remove", workflowId, ...res }, [{ action: "delete", target: res.filePath }], `remove workflow '${workflowId}' from ${res.scope}`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
320
331
|
print(
|
|
321
332
|
runtime.io,
|
|
322
333
|
runtime.json,
|
|
@@ -373,8 +384,13 @@ export async function cmdWorkflowModify(
|
|
|
373
384
|
scope: options.scope,
|
|
374
385
|
repoRoot: runtime.cwd,
|
|
375
386
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
387
|
+
dryRun: runtime.dryRun,
|
|
376
388
|
},
|
|
377
389
|
);
|
|
390
|
+
if (runtime.dryRun) {
|
|
391
|
+
printPlan(runtime, { command: "workflow modify", workflowId, ...res }, [{ action: "write", target: res.filePath }], `modify workflow '${workflowId}' in ${res.scope}`);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
378
394
|
print(
|
|
379
395
|
runtime.io,
|
|
380
396
|
runtime.json,
|
|
@@ -520,7 +536,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
|
|
|
520
536
|
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
521
537
|
if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
|
|
522
538
|
if (runtime.dryRun) {
|
|
523
|
-
printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
|
|
539
|
+
printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
|
|
524
540
|
return 0;
|
|
525
541
|
}
|
|
526
542
|
const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
|
|
@@ -557,7 +573,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
|
|
|
557
573
|
return 2;
|
|
558
574
|
}
|
|
559
575
|
if (runtime.dryRun) {
|
|
560
|
-
printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signed signal");
|
|
576
|
+
printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signed signal");
|
|
561
577
|
return 0;
|
|
562
578
|
}
|
|
563
579
|
const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts"
|
|
|
30
30
|
// Submodule imports
|
|
31
31
|
import {
|
|
32
32
|
print,
|
|
33
|
+
refuseDryRun,
|
|
33
34
|
runtimeFrom,
|
|
34
35
|
redactConfiguredValues,
|
|
35
36
|
type CliContext,
|
|
@@ -172,6 +173,54 @@ const USAGE_ERROR_CODES = new Set([
|
|
|
172
173
|
"commander.optionMissingArgument",
|
|
173
174
|
]);
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Every command that answers `--dry-run` without changing anything: it only
|
|
178
|
+
* reads, or it prints the plan (`dryRun: true` plus `planned`) and stops.
|
|
179
|
+
* A command missing from this set is refused under `--dry-run` with
|
|
180
|
+
* `dry_run_unsupported` before its action runs, so a new command that forgets
|
|
181
|
+
* to check the flag fails closed instead of mutating. Keyed by command path.
|
|
182
|
+
*/
|
|
183
|
+
const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
|
|
184
|
+
"init", "backup", "restore", "run", "explain", "suggest", "update", "dash", "completion", "completion install",
|
|
185
|
+
"runs status", "runs drive", "runs receipt", "runs cancel", "runs list",
|
|
186
|
+
"tenant status", "models inventory-refresh", "harness list", "auth token",
|
|
187
|
+
"routes list", "routes count", "routes admit", "routes disable",
|
|
188
|
+
"runtime start", "runtime status", "runtime sync-retry", "runtime stop",
|
|
189
|
+
"trust diff", "trust check", "agent worker",
|
|
190
|
+
"session status", "session brief", "session token", "session start", "session stop",
|
|
191
|
+
"peer list", "peer send", "peer get", "peer await", "peer cancel", "peer fanout", "peer inbox", "peer reply",
|
|
192
|
+
"workflow list", "workflow get", "workflow checkpoint", "workflow record", "workflow wait", "workflow signal",
|
|
193
|
+
"workflow start", "workflow export", "workflow definitions", "workflow add", "workflow remove", "workflow modify",
|
|
194
|
+
"role list", "role get", "role add", "role remove", "role modify", "role hosts", "role set-host", "role resume",
|
|
195
|
+
"gate validate", "gate artifacts-exist", "gate degrade", "gate signal", "gate github watch",
|
|
196
|
+
"improve report",
|
|
197
|
+
"context get", "context recall", "context state", "context episode", "context promote", "context explain",
|
|
198
|
+
"context wiki-compile", "context wiki-lint",
|
|
199
|
+
"skills create", "skills evaluate", "skills promote", "skills reject", "skills list", "skills verify",
|
|
200
|
+
"memory brief", "memory note", "memory sync",
|
|
201
|
+
"routing report", "routing benchmark",
|
|
202
|
+
"ssh info", "ssh run", "ssh file", "ssh close",
|
|
203
|
+
"hub view", "hub start", "hub stop", "hub bind", "hub unbind",
|
|
204
|
+
"config get", "config set", "config list",
|
|
205
|
+
"goal create", "goal list",
|
|
206
|
+
"task create", "task list", "task get", "task run", "task sync",
|
|
207
|
+
"studio layout", "studio serve",
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
class DryRunRefused extends Error {
|
|
211
|
+
readonly code: number;
|
|
212
|
+
constructor(code: number) {
|
|
213
|
+
super("dry_run_unsupported");
|
|
214
|
+
this.code = code;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function commandPath(command: Command): string {
|
|
219
|
+
const names: string[] = [];
|
|
220
|
+
for (let current: Command | null = command; current?.parent; current = current.parent) names.unshift(current.name());
|
|
221
|
+
return names.join(" ");
|
|
222
|
+
}
|
|
223
|
+
|
|
175
224
|
function addGlobalOptions(command: Command): Command {
|
|
176
225
|
return command
|
|
177
226
|
.option("--json", "Print machine-readable JSON")
|
|
@@ -331,6 +380,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
331
380
|
})
|
|
332
381
|
.helpCommand("help", "Show help");
|
|
333
382
|
addGlobalOptions(program);
|
|
383
|
+
program.hook("preAction", (_program, actionCommand) => {
|
|
384
|
+
const opts = actionCommand.optsWithGlobals() as { dryRun?: boolean; json?: boolean };
|
|
385
|
+
if (!opts.dryRun) return;
|
|
386
|
+
const path = commandPath(actionCommand);
|
|
387
|
+
if (DRY_RUN_COMMANDS.has(path)) return;
|
|
388
|
+
throw new DryRunRefused(refuseDryRun(ctx.io, Boolean(opts.json), path, "this command cannot plan without making changes; rerun without --dry-run"));
|
|
389
|
+
});
|
|
334
390
|
|
|
335
391
|
program.command("init").description("Create, validate, repair, or join a KXM project")
|
|
336
392
|
.option("--json", "Print machine-readable JSON")
|
|
@@ -1222,6 +1278,7 @@ export async function runCli(
|
|
|
1222
1278
|
return result.code;
|
|
1223
1279
|
} catch (error) {
|
|
1224
1280
|
if (error instanceof CommanderError) return mapCommanderError(error);
|
|
1281
|
+
if (error instanceof DryRunRefused) return error.code;
|
|
1225
1282
|
throw error;
|
|
1226
1283
|
}
|
|
1227
1284
|
}
|