@netnodeag/kraftwerk 0.32.1 → 0.33.0
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/dist/cli/doctor.js +2 -3
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/cli/projects.d.ts +2 -0
- package/dist/cli/projects.js +126 -0
- package/dist/cli/ui.js +28 -11
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -1
- package/dist/inspector/instances.d.ts +64 -1
- package/dist/inspector/instances.js +257 -15
- package/dist/inspector/self-command.d.ts +15 -0
- package/dist/inspector/self-command.js +18 -0
- package/dist/inspector/server.js +43 -7
- package/inspector/dist/assets/{editor-DhNlc5vd.js → editor-BlAeAqt0.js} +3 -3
- package/inspector/dist/assets/{index-CT73nPrH.js → index--9LQl_DL.js} +1 -1
- package/inspector/dist/assets/{index-xM_-dBjg.js → index--wwp-KD0.js} +1 -1
- package/inspector/dist/assets/{index-CWIXOY1D.js → index-9rElziKS.js} +1 -1
- package/inspector/dist/assets/{index-C22iDz8w.js → index-AR7uGZPa.js} +1 -1
- package/inspector/dist/assets/{index--vNjDAEm.js → index-B0qGMqfM.js} +1 -1
- package/inspector/dist/assets/index-B7W4QbA2.css +1 -0
- package/inspector/dist/assets/{index-DYnto4lP.js → index-BKnMNAOc.js} +1 -1
- package/inspector/dist/assets/{index-DmZE5psD.js → index-BvCAG0mQ.js} +1 -1
- package/inspector/dist/assets/{index-Hlokid_B.js → index-BvGhLr64.js} +1 -1
- package/inspector/dist/assets/{index-C9y82Xkr.js → index-BxPjb1aG.js} +1 -1
- package/inspector/dist/assets/{index-BWQugB-m.js → index-C3ugp_Wf.js} +1 -1
- package/inspector/dist/assets/{index-IK4Vsg-q.js → index-C5IBvIVa.js} +44 -44
- package/inspector/dist/assets/{index-Dx4HOh5k.js → index-CV4Aiss-.js} +1 -1
- package/inspector/dist/assets/{index-BanLa7PQ.js → index-CdkhUV5I.js} +1 -1
- package/inspector/dist/assets/{index-BvrVgIw_.js → index-D3O4auYk.js} +1 -1
- package/inspector/dist/assets/{index-BmdO9i46.js → index-DcXZX6dj.js} +1 -1
- package/inspector/dist/assets/{index-Dgi1UC16.js → index-Dnqjpy0h.js} +1 -1
- package/inspector/dist/assets/{index-CK6xHVuA.js → index-LBoNKP4N.js} +1 -1
- package/inspector/dist/assets/{index-2RQVAOu3.js → index-e-11MUIo.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/index-iGaBYIin.css +0 -1
package/dist/cli/doctor.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { readFile
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import { resolveProject } from "../config.js";
|
|
5
|
+
import { isDir, resolveProject } from "../config.js";
|
|
6
6
|
import { discoverWorkflows } from "../discover.js";
|
|
7
7
|
import { missingEnv } from "../yaml.js";
|
|
8
8
|
const ICONS = {
|
|
@@ -90,7 +90,6 @@ export async function runDoctor(cwd) {
|
|
|
90
90
|
report("ok", `${path.basename(project.configPath)} well-formed`, keys.length ? keys.map((k) => `${k}: ${cfg[k]}`).join(", ") : "empty — defaults apply");
|
|
91
91
|
if (!cfg.name)
|
|
92
92
|
report("info", "name not set in kraftwerk.yml", "inspector header falls back to the folder name");
|
|
93
|
-
const isDir = async (p) => (await stat(p).catch(() => null))?.isDirectory() ?? false;
|
|
94
93
|
for (const key of ["workflows", "knowledge", "agents", "output"]) {
|
|
95
94
|
const value = cfg[key];
|
|
96
95
|
if (!value)
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -12,6 +12,7 @@ import { renderCreateBrief } from "./create-brief.js";
|
|
|
12
12
|
import { runDoctor } from "./doctor.js";
|
|
13
13
|
import { runInit } from "./init.js";
|
|
14
14
|
import { registerKnowledgeCommands } from "./knowledge.js";
|
|
15
|
+
import { registerProjectCommands } from "./projects.js";
|
|
15
16
|
import { registerRoutineCommands } from "./routines.js";
|
|
16
17
|
import { listRuns, showRun } from "./runs.js";
|
|
17
18
|
import { runUi } from "./ui.js";
|
|
@@ -25,6 +26,7 @@ import { runUi } from "./ui.js";
|
|
|
25
26
|
* kraftwerk knowledge ... Context & Knowledge: OKF bundles (list/get/put/verify/...)
|
|
26
27
|
* kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run)
|
|
27
28
|
* kraftwerk ui start the inspector web UI (localhost:1981)
|
|
29
|
+
* kraftwerk projects ... known projects on this machine (list/start/forget)
|
|
28
30
|
* kraftwerk doctor preflight: harness CLIs, docker, workflows, env
|
|
29
31
|
* kraftwerk validate [paths...] validate without executing
|
|
30
32
|
*
|
|
@@ -256,6 +258,7 @@ runs
|
|
|
256
258
|
});
|
|
257
259
|
registerKnowledgeCommands(program);
|
|
258
260
|
registerRoutineCommands(program);
|
|
261
|
+
registerProjectCommands(program);
|
|
259
262
|
program
|
|
260
263
|
.command("ui")
|
|
261
264
|
.description("Start the inspector web UI for this project's runs and workflows")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import Table from "cli-table3";
|
|
4
|
+
import { discoverWorkspaces, forgetProject, startProject, tildify, } from "../inspector/instances.js";
|
|
5
|
+
/**
|
|
6
|
+
* `kraftwerk projects` — every project that ever ran the inspector on this
|
|
7
|
+
* machine (~/.kraftwerk/projects), joined with what is running right now.
|
|
8
|
+
* The answer to "where did I start that UI, and how do I get it back":
|
|
9
|
+
*
|
|
10
|
+
* kraftwerk projects list: name, root, running/stopped
|
|
11
|
+
* kraftwerk projects start <ref> relaunch `kraftwerk ui` in that root, detached
|
|
12
|
+
* kraftwerk projects forget <ref> drop the record (the folder stays)
|
|
13
|
+
*
|
|
14
|
+
* <ref> is a project name, the root's folder name, or the root path.
|
|
15
|
+
*/
|
|
16
|
+
const fmtAgo = (iso) => {
|
|
17
|
+
if (!iso)
|
|
18
|
+
return "—";
|
|
19
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
20
|
+
if (s < 90)
|
|
21
|
+
return "just now";
|
|
22
|
+
if (s < 5400)
|
|
23
|
+
return `${Math.round(s / 60)} min ago`;
|
|
24
|
+
if (s < 129600)
|
|
25
|
+
return `${Math.round(s / 3600)} h ago`;
|
|
26
|
+
return `${Math.round(s / 86400)} d ago`;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Running → its url. Not running: "stopped" after a clean shutdown, "died"
|
|
30
|
+
* when the last start has no matching stop (killed, crashed, terminal
|
|
31
|
+
* closed) — the case this command exists for.
|
|
32
|
+
*/
|
|
33
|
+
const status = (e) => {
|
|
34
|
+
if (e.live)
|
|
35
|
+
return chalk.green(`● ${e.url.replace(/^https?:\/\//, "")}`);
|
|
36
|
+
if (e.exists === false)
|
|
37
|
+
return chalk.red("path missing");
|
|
38
|
+
const clean = !!e.lastStopped && !!e.lastStarted && e.lastStopped >= e.lastStarted;
|
|
39
|
+
return clean ? chalk.dim("stopped") : chalk.yellow("died");
|
|
40
|
+
};
|
|
41
|
+
async function resolveRef(ref) {
|
|
42
|
+
const all = (await discoverWorkspaces()).filter((e) => e.root);
|
|
43
|
+
const abs = path.resolve(ref);
|
|
44
|
+
const hits = all.filter((e) => e.root === abs ||
|
|
45
|
+
e.root === ref ||
|
|
46
|
+
e.name === ref ||
|
|
47
|
+
path.basename(e.root) === ref);
|
|
48
|
+
if (hits.length === 1)
|
|
49
|
+
return hits[0];
|
|
50
|
+
if (hits.length === 0) {
|
|
51
|
+
console.error(chalk.red(`No known project "${ref}". See \`kraftwerk projects\`.`));
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
console.error(chalk.red(`"${ref}" is ambiguous — use the root path:`));
|
|
55
|
+
for (const h of hits)
|
|
56
|
+
console.error(` ${h.root}`);
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
export function registerProjectCommands(program) {
|
|
60
|
+
const projects = program
|
|
61
|
+
.command("projects")
|
|
62
|
+
.description("Known kraftwerk projects on this machine: list, start a stopped UI, forget");
|
|
63
|
+
projects
|
|
64
|
+
.command("list", { isDefault: true })
|
|
65
|
+
.description("List known projects with their root and whether the UI is running")
|
|
66
|
+
.option("--json", "Machine-readable output")
|
|
67
|
+
.action(async (opts) => {
|
|
68
|
+
const entries = await discoverWorkspaces();
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (entries.length === 0) {
|
|
74
|
+
console.log(chalk.dim("No projects known yet — `kraftwerk ui` registers the current one."));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const table = new Table({
|
|
78
|
+
head: ["project", "root", "status", "last started"].map((h) => chalk.bold(h)),
|
|
79
|
+
wordWrap: true,
|
|
80
|
+
colWidths: [22, 48, 24, 14],
|
|
81
|
+
});
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
table.push([
|
|
84
|
+
`${e.icon ? e.icon + " " : ""}${chalk.cyan(e.name)}`,
|
|
85
|
+
e.rootLabel ?? chalk.dim("(no root recorded)"),
|
|
86
|
+
status(e),
|
|
87
|
+
fmtAgo(e.lastStarted),
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
console.log(table.toString());
|
|
91
|
+
});
|
|
92
|
+
projects
|
|
93
|
+
.command("start")
|
|
94
|
+
.description("Relaunch the UI of a stopped project (detached; logs in ~/.kraftwerk/logs)")
|
|
95
|
+
.argument("<ref>", "Project name, folder name, or root path")
|
|
96
|
+
.action(async (ref) => {
|
|
97
|
+
const entry = await resolveRef(ref);
|
|
98
|
+
if (entry.live) {
|
|
99
|
+
console.log(`${chalk.green("✔")} ${entry.name} already running: ${chalk.cyan(entry.url)}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const r = await startProject(entry.root);
|
|
103
|
+
if (!r.ok) {
|
|
104
|
+
console.error(chalk.red(`Could not start ${entry.name}: ${r.error}`));
|
|
105
|
+
if (r.log)
|
|
106
|
+
console.error(chalk.dim(`log: ${tildify(r.log)}`));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
console.log(`${chalk.green("✔")} ${entry.name} ${r.live ? "running" : "starting"}: ${chalk.cyan(r.url)}` +
|
|
110
|
+
chalk.dim(` (pid ${r.pid}${r.log ? `, log ${tildify(r.log)}` : ""})`));
|
|
111
|
+
});
|
|
112
|
+
projects
|
|
113
|
+
.command("forget")
|
|
114
|
+
.description("Remove a project from the registry (its folder is untouched)")
|
|
115
|
+
.argument("<ref>", "Project name, folder name, or root path")
|
|
116
|
+
.action(async (ref) => {
|
|
117
|
+
const entry = await resolveRef(ref);
|
|
118
|
+
if (await forgetProject(entry.root)) {
|
|
119
|
+
console.log(`${chalk.green("✔")} forgot ${entry.name} (${entry.rootLabel})`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.error(chalk.red(`No record for ${entry.root}`));
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/cli/ui.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import { resolveProject } from "../config.js";
|
|
8
|
+
import { selfCommand } from "../inspector/self-command.js";
|
|
7
9
|
import { RESTART_EXIT_CODE, startInspector } from "../inspector/server.js";
|
|
8
10
|
/**
|
|
9
11
|
* `kraftwerk ui` — start the inspector web UI pointed at the current
|
|
@@ -59,27 +61,42 @@ export async function runUi(cwd, opts) {
|
|
|
59
61
|
}
|
|
60
62
|
/**
|
|
61
63
|
* Respawn loop around the real server. Spawns this same bin script via the
|
|
62
|
-
* current node — works identically for global installs,
|
|
63
|
-
* npx, and a file: dev checkout. Any exit other than
|
|
64
|
-
*
|
|
64
|
+
* current node (selfCommand) — works identically for global installs,
|
|
65
|
+
* local node_modules, npx, and a file: dev checkout. Any exit other than
|
|
66
|
+
* RESTART_EXIT_CODE ends the loop. A SIGTERM/SIGINT to the supervisor is
|
|
67
|
+
* forwarded to the server, so `kill <supervisor pid>` (what `kraftwerk
|
|
68
|
+
* projects start` reports) takes the whole UI down; Ctrl-C signals the
|
|
69
|
+
* foreground group and reaches both anyway.
|
|
65
70
|
*/
|
|
66
71
|
async function superviseUi(opts) {
|
|
67
|
-
const args = [
|
|
68
|
-
process.argv[1],
|
|
72
|
+
const { cmd, args } = selfCommand([
|
|
69
73
|
"ui",
|
|
70
74
|
...(opts.port ? ["--port", opts.port] : []),
|
|
71
75
|
...(opts.output ? ["--output", opts.output] : []),
|
|
72
|
-
];
|
|
76
|
+
]);
|
|
73
77
|
for (;;) {
|
|
74
|
-
const child = spawn(
|
|
78
|
+
const child = spawn(cmd, args, {
|
|
75
79
|
stdio: "inherit",
|
|
76
80
|
env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1" },
|
|
77
81
|
});
|
|
78
|
-
const
|
|
79
|
-
child.
|
|
82
|
+
const forward = (sig) => () => {
|
|
83
|
+
child.kill(sig);
|
|
84
|
+
};
|
|
85
|
+
const handlers = { SIGTERM: forward("SIGTERM"), SIGINT: forward("SIGINT") };
|
|
86
|
+
process.on("SIGTERM", handlers.SIGTERM);
|
|
87
|
+
process.on("SIGINT", handlers.SIGINT);
|
|
88
|
+
const result = await new Promise((resolve) => {
|
|
89
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
80
90
|
});
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
process.off("SIGTERM", handlers.SIGTERM);
|
|
92
|
+
process.off("SIGINT", handlers.SIGINT);
|
|
93
|
+
if (result.code !== RESTART_EXIT_CODE) {
|
|
94
|
+
// A signal-killed server is not a clean exit — say so in the exit code
|
|
95
|
+
// (128 + signal, the shell convention) so callers can tell.
|
|
96
|
+
if (result.signal)
|
|
97
|
+
process.exit(128 + (os.constants.signals[result.signal] ?? 1));
|
|
98
|
+
process.exit(result.code ?? 1);
|
|
99
|
+
}
|
|
83
100
|
console.log(chalk.dim("↻ relaunching the UI with the current install ..."));
|
|
84
101
|
}
|
|
85
102
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface Project {
|
|
|
68
68
|
/** Absolute run-artifact directory (may not exist yet). */
|
|
69
69
|
outputDir: string;
|
|
70
70
|
}
|
|
71
|
+
/** True when the path exists and is a directory. */
|
|
72
|
+
export declare const isDir: (p: string) => Promise<boolean>;
|
|
71
73
|
/**
|
|
72
74
|
* Resolve the project for a cwd: walk up until a kraftwerk.yml or a
|
|
73
75
|
* workflows root appears; a .git directory is the fallback root, the cwd
|
package/dist/config.js
CHANGED
|
@@ -32,7 +32,8 @@ export const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kr
|
|
|
32
32
|
export const CONFIG_FILENAMES = ["kraftwerk.yml", "kraftwerk.yaml"];
|
|
33
33
|
const WORKFLOW_ROOT_CANDIDATES = ["src/workflows", "workflows"];
|
|
34
34
|
const exists = async (p) => !!(await stat(p).catch(() => null));
|
|
35
|
-
|
|
35
|
+
/** True when the path exists and is a directory. */
|
|
36
|
+
export const isDir = async (p) => (await stat(p).catch(() => null))?.isDirectory() ?? false;
|
|
36
37
|
async function findConfigFile(dir) {
|
|
37
38
|
for (const name of CONFIG_FILENAMES) {
|
|
38
39
|
const candidate = path.join(dir, name);
|
|
@@ -1,13 +1,76 @@
|
|
|
1
|
+
/** Durable per-project record. The root is the key; everything else derives from it. */
|
|
2
|
+
export interface ProjectRecord {
|
|
3
|
+
root: string;
|
|
4
|
+
firstSeen: string;
|
|
5
|
+
lastStarted: string;
|
|
6
|
+
/** Set on clean shutdown; older than lastStarted means the last run died. */
|
|
7
|
+
lastStopped?: string;
|
|
8
|
+
startCount: number;
|
|
9
|
+
}
|
|
1
10
|
/** A verified running instance, shaped like a switcher entry. */
|
|
2
11
|
export interface DiscoveredInstance {
|
|
3
12
|
name: string;
|
|
4
13
|
url: string;
|
|
5
14
|
icon?: string;
|
|
6
15
|
live: true;
|
|
16
|
+
root?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One workspace as the switcher shows it: a known project (running or
|
|
20
|
+
* not) or a running instance the projects registry doesn't know yet.
|
|
21
|
+
*/
|
|
22
|
+
export interface WorkspaceEntry {
|
|
23
|
+
name: string;
|
|
24
|
+
/** Where the inspector answers (live) or would answer once started. */
|
|
25
|
+
url: string;
|
|
26
|
+
icon?: string;
|
|
27
|
+
live: boolean;
|
|
28
|
+
root?: string;
|
|
29
|
+
/** Root with ~ for the home dir — what the UI prints. */
|
|
30
|
+
rootLabel?: string;
|
|
31
|
+
/** False when the root directory is gone (or no longer a project of its own); start is impossible. */
|
|
32
|
+
exists?: boolean;
|
|
33
|
+
lastStarted?: string;
|
|
34
|
+
lastStopped?: string;
|
|
7
35
|
}
|
|
36
|
+
/** ~/… for display. */
|
|
37
|
+
export declare const tildify: (p: string) => string;
|
|
8
38
|
/** Write this instance's registry file (call once the server listens). */
|
|
9
|
-
export declare function registerInstance(port: number): Promise<void>;
|
|
39
|
+
export declare function registerInstance(port: number, root: string): Promise<void>;
|
|
10
40
|
/** Remove this instance's registry file. Sync so exit handlers can call it. */
|
|
11
41
|
export declare function unregisterInstance(): void;
|
|
12
42
|
/** All other running instances, verified live. Cached briefly — /api/meta is polled. */
|
|
13
43
|
export declare function discoverInstances(): Promise<DiscoveredInstance[]>;
|
|
44
|
+
/** Upsert the project record for a root (call on every inspector start). */
|
|
45
|
+
export declare function registerProject(root: string): Promise<void>;
|
|
46
|
+
/** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
|
|
47
|
+
export declare function markProjectStopped(): void;
|
|
48
|
+
/** All known projects, most recently started first. */
|
|
49
|
+
export declare function listProjects(): Promise<ProjectRecord[]>;
|
|
50
|
+
/** Drop a project record (the root itself is untouched). */
|
|
51
|
+
export declare function forgetProject(root: string): Promise<boolean>;
|
|
52
|
+
/**
|
|
53
|
+
* The switcher's view: every known project joined with the live instances,
|
|
54
|
+
* plus running instances with no project record yet. The calling instance
|
|
55
|
+
* itself is left out. Live entries first, then by last start. Cached like
|
|
56
|
+
* discoverInstances — /api/meta is polled and this stats every root.
|
|
57
|
+
*/
|
|
58
|
+
export declare function discoverWorkspaces(): Promise<WorkspaceEntry[]>;
|
|
59
|
+
export interface StartResult {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
url?: string;
|
|
62
|
+
/** True once the new inspector answered its probe within the wait. */
|
|
63
|
+
live?: boolean;
|
|
64
|
+
pid?: number;
|
|
65
|
+
log?: string;
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Launch `kraftwerk ui` for a root as a detached process (selfCommand:
|
|
70
|
+
* the bin this process runs from), logging to ~/.kraftwerk/logs/<key>.log.
|
|
71
|
+
* The reported pid is the `kraftwerk ui` supervisor, which forwards
|
|
72
|
+
* SIGTERM/SIGINT to its server child, so `kill <pid>` stops the whole UI.
|
|
73
|
+
* Waits up to ~5s for the new inspector to answer so callers can link
|
|
74
|
+
* straight to it.
|
|
75
|
+
*/
|
|
76
|
+
export declare function startProject(root: string): Promise<StartResult>;
|
|
@@ -1,23 +1,48 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
1
3
|
import fsSync, { promises as fs } from "node:fs";
|
|
2
4
|
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
6
|
+
import { isDir, resolveProject } from "../config.js";
|
|
7
|
+
import { selfCommand } from "./self-command.js";
|
|
4
8
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Two registries under ~/.kraftwerk, two lifecycles:
|
|
10
|
+
*
|
|
11
|
+
* instances/<pid>.json — ephemeral. Every running inspector writes one so
|
|
12
|
+
* other local instances can discover it (the workspace switcher lists
|
|
13
|
+
* running workspaces automatically, no kraftwerk.yml `switcher:` needed).
|
|
14
|
+
* Registered ports are verified by probing /api/meta on read; entries
|
|
15
|
+
* that stop answering are pruned, so crashes leave no ghosts.
|
|
16
|
+
*
|
|
17
|
+
* projects/<hash(root)>.json — durable. One record per project root that
|
|
18
|
+
* ever ran the inspector on this machine, keyed by the root and never
|
|
19
|
+
* pruned automatically. Everything else (name, icon, port) is derived from
|
|
20
|
+
* the root's kraftwerk.yml at read time, so it is always current. A
|
|
21
|
+
* project that is not running can be started again from the switcher or
|
|
22
|
+
* `kraftwerk projects start` — that is what makes a killed UI findable.
|
|
11
23
|
*/
|
|
12
|
-
const
|
|
13
|
-
const
|
|
24
|
+
const HOME = path.join(os.homedir(), ".kraftwerk");
|
|
25
|
+
const INSTANCES_DIR = path.join(HOME, "instances");
|
|
26
|
+
const PROJECTS_DIR = path.join(HOME, "projects");
|
|
27
|
+
const LOGS_DIR = path.join(HOME, "logs");
|
|
28
|
+
const selfFile = () => path.join(INSTANCES_DIR, `${process.pid}.json`);
|
|
14
29
|
let selfPort = null;
|
|
30
|
+
let selfRoot = null;
|
|
31
|
+
const projectKey = (root) => createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 16);
|
|
32
|
+
const projectFile = (root) => path.join(PROJECTS_DIR, `${projectKey(root)}.json`);
|
|
33
|
+
/** ~/… for display. */
|
|
34
|
+
export const tildify = (p) => {
|
|
35
|
+
const home = os.homedir();
|
|
36
|
+
return p === home || p.startsWith(home + path.sep) ? "~" + p.slice(home.length) : p;
|
|
37
|
+
};
|
|
38
|
+
/* ---------- instances (ephemeral) ---------- */
|
|
15
39
|
/** Write this instance's registry file (call once the server listens). */
|
|
16
|
-
export async function registerInstance(port) {
|
|
40
|
+
export async function registerInstance(port, root) {
|
|
17
41
|
selfPort = port;
|
|
42
|
+
selfRoot = path.resolve(root);
|
|
18
43
|
try {
|
|
19
|
-
await fs.mkdir(
|
|
20
|
-
const rec = { pid: process.pid, port, startedAt: new Date().toISOString() };
|
|
44
|
+
await fs.mkdir(INSTANCES_DIR, { recursive: true });
|
|
45
|
+
const rec = { pid: process.pid, port, startedAt: new Date().toISOString(), root: selfRoot };
|
|
21
46
|
await fs.writeFile(selfFile(), JSON.stringify(rec));
|
|
22
47
|
}
|
|
23
48
|
catch { } // best-effort — without it discovery just won't see us
|
|
@@ -62,7 +87,7 @@ export async function discoverInstances() {
|
|
|
62
87
|
return cache.entries;
|
|
63
88
|
let files;
|
|
64
89
|
try {
|
|
65
|
-
files = await fs.readdir(
|
|
90
|
+
files = await fs.readdir(INSTANCES_DIR);
|
|
66
91
|
}
|
|
67
92
|
catch {
|
|
68
93
|
return [];
|
|
@@ -72,7 +97,7 @@ export async function discoverInstances() {
|
|
|
72
97
|
.map(async (f) => {
|
|
73
98
|
let rec;
|
|
74
99
|
try {
|
|
75
|
-
rec = JSON.parse(await fs.readFile(path.join(
|
|
100
|
+
rec = JSON.parse(await fs.readFile(path.join(INSTANCES_DIR, f), "utf8"));
|
|
76
101
|
}
|
|
77
102
|
catch {
|
|
78
103
|
return null;
|
|
@@ -81,10 +106,10 @@ export async function discoverInstances() {
|
|
|
81
106
|
return null;
|
|
82
107
|
const found = await probe(rec.port);
|
|
83
108
|
if (!found) {
|
|
84
|
-
await fs.unlink(path.join(
|
|
109
|
+
await fs.unlink(path.join(INSTANCES_DIR, f)).catch(() => { }); // stale — prune
|
|
85
110
|
return null;
|
|
86
111
|
}
|
|
87
|
-
return found;
|
|
112
|
+
return { ...found, root: rec.root };
|
|
88
113
|
}));
|
|
89
114
|
const entries = probed
|
|
90
115
|
.filter((e) => e != null)
|
|
@@ -92,3 +117,220 @@ export async function discoverInstances() {
|
|
|
92
117
|
cache = { at: Date.now(), entries };
|
|
93
118
|
return entries;
|
|
94
119
|
}
|
|
120
|
+
/* ---------- projects (durable) ---------- */
|
|
121
|
+
async function readProject(file) {
|
|
122
|
+
try {
|
|
123
|
+
const rec = JSON.parse(await fs.readFile(file, "utf8"));
|
|
124
|
+
if (typeof rec.root !== "string")
|
|
125
|
+
return null;
|
|
126
|
+
return {
|
|
127
|
+
root: rec.root,
|
|
128
|
+
firstSeen: rec.firstSeen ?? rec.lastStarted ?? "",
|
|
129
|
+
lastStarted: rec.lastStarted ?? rec.firstSeen ?? "",
|
|
130
|
+
lastStopped: rec.lastStopped,
|
|
131
|
+
startCount: rec.startCount ?? 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Upsert the project record for a root (call on every inspector start). */
|
|
139
|
+
export async function registerProject(root) {
|
|
140
|
+
const abs = path.resolve(root);
|
|
141
|
+
const now = new Date().toISOString();
|
|
142
|
+
try {
|
|
143
|
+
await fs.mkdir(PROJECTS_DIR, { recursive: true });
|
|
144
|
+
const prev = await readProject(projectFile(abs));
|
|
145
|
+
const rec = {
|
|
146
|
+
root: abs,
|
|
147
|
+
firstSeen: prev?.firstSeen || now,
|
|
148
|
+
lastStarted: now,
|
|
149
|
+
startCount: (prev?.startCount ?? 0) + 1,
|
|
150
|
+
};
|
|
151
|
+
await fs.writeFile(projectFile(abs), JSON.stringify(rec, null, 2));
|
|
152
|
+
}
|
|
153
|
+
catch { } // best-effort, like the instance file
|
|
154
|
+
}
|
|
155
|
+
/** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
|
|
156
|
+
export function markProjectStopped() {
|
|
157
|
+
if (!selfRoot)
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
const file = projectFile(selfRoot);
|
|
161
|
+
const rec = JSON.parse(fsSync.readFileSync(file, "utf8"));
|
|
162
|
+
rec.lastStopped = new Date().toISOString();
|
|
163
|
+
fsSync.writeFileSync(file, JSON.stringify(rec, null, 2));
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
}
|
|
167
|
+
/** All known projects, most recently started first. */
|
|
168
|
+
export async function listProjects() {
|
|
169
|
+
let files;
|
|
170
|
+
try {
|
|
171
|
+
files = await fs.readdir(PROJECTS_DIR);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const recs = await Promise.all(files.filter((f) => f.endsWith(".json")).map((f) => readProject(path.join(PROJECTS_DIR, f))));
|
|
177
|
+
return recs
|
|
178
|
+
.filter((r) => r != null)
|
|
179
|
+
.sort((a, b) => b.lastStarted.localeCompare(a.lastStarted));
|
|
180
|
+
}
|
|
181
|
+
/** Drop a project record (the root itself is untouched). */
|
|
182
|
+
export async function forgetProject(root) {
|
|
183
|
+
try {
|
|
184
|
+
await fs.unlink(projectFile(root));
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Name, icon and port a root would run with right now — read from its
|
|
193
|
+
* kraftwerk.yml. `exists` is false when the directory is gone or when the
|
|
194
|
+
* project resolves to an ancestor (its kraftwerk.yml was removed, or it
|
|
195
|
+
* sits inside another project): resolveProject walks up and never fails,
|
|
196
|
+
* so without this check a stale record would wear its parent's identity.
|
|
197
|
+
*/
|
|
198
|
+
async function describeRoot(root) {
|
|
199
|
+
const fallback = { name: path.basename(root), port: 1981, exists: false };
|
|
200
|
+
if (!(await isDir(root)))
|
|
201
|
+
return fallback;
|
|
202
|
+
try {
|
|
203
|
+
const project = await resolveProject(root);
|
|
204
|
+
if (path.resolve(project.root) !== path.resolve(root))
|
|
205
|
+
return fallback;
|
|
206
|
+
return {
|
|
207
|
+
name: project.config.name ?? path.basename(project.root),
|
|
208
|
+
icon: project.config.icon || undefined,
|
|
209
|
+
port: project.config.port ?? 1981,
|
|
210
|
+
exists: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let wsCache = null;
|
|
218
|
+
/**
|
|
219
|
+
* The switcher's view: every known project joined with the live instances,
|
|
220
|
+
* plus running instances with no project record yet. The calling instance
|
|
221
|
+
* itself is left out. Live entries first, then by last start. Cached like
|
|
222
|
+
* discoverInstances — /api/meta is polled and this stats every root.
|
|
223
|
+
*/
|
|
224
|
+
export async function discoverWorkspaces() {
|
|
225
|
+
if (wsCache && Date.now() - wsCache.at < 5_000)
|
|
226
|
+
return wsCache.entries;
|
|
227
|
+
const [projects, live] = await Promise.all([listProjects(), discoverInstances()]);
|
|
228
|
+
const liveByRoot = new Map(live.filter((i) => i.root).map((i) => [i.root, i]));
|
|
229
|
+
const consumed = new Set();
|
|
230
|
+
const entries = (await Promise.all(projects
|
|
231
|
+
.filter((p) => p.root !== selfRoot)
|
|
232
|
+
.map(async (p) => {
|
|
233
|
+
const base = { root: p.root, rootLabel: tildify(p.root), lastStarted: p.lastStarted, lastStopped: p.lastStopped };
|
|
234
|
+
let running = liveByRoot.get(p.root);
|
|
235
|
+
let d;
|
|
236
|
+
if (!running) {
|
|
237
|
+
d = await describeRoot(p.root);
|
|
238
|
+
// A pre-0.33 inspector still serving this root registered no
|
|
239
|
+
// root — match it by the port the root would use, else the
|
|
240
|
+
// switcher shows the same project twice (live + stopped).
|
|
241
|
+
running = live.find((i) => !i.root && i.url === `http://localhost:${d.port}`);
|
|
242
|
+
}
|
|
243
|
+
if (running) {
|
|
244
|
+
consumed.add(running);
|
|
245
|
+
return { ...base, name: running.name, url: running.url, icon: running.icon, live: true, exists: true };
|
|
246
|
+
}
|
|
247
|
+
return { ...base, name: d.name, url: `http://localhost:${d.port}`, icon: d.icon, live: false, exists: d.exists };
|
|
248
|
+
}))).concat(await Promise.all(live
|
|
249
|
+
.filter((i) => !consumed.has(i))
|
|
250
|
+
.map(async (i) => ({
|
|
251
|
+
name: i.name,
|
|
252
|
+
url: i.url,
|
|
253
|
+
icon: i.icon,
|
|
254
|
+
live: true,
|
|
255
|
+
root: i.root,
|
|
256
|
+
rootLabel: i.root ? tildify(i.root) : undefined,
|
|
257
|
+
exists: i.root ? await isDir(i.root) : undefined,
|
|
258
|
+
}))));
|
|
259
|
+
entries.sort((a, b) => {
|
|
260
|
+
if (a.live !== b.live)
|
|
261
|
+
return a.live ? -1 : 1;
|
|
262
|
+
return (b.lastStarted ?? "").localeCompare(a.lastStarted ?? "") || a.name.localeCompare(b.name);
|
|
263
|
+
});
|
|
264
|
+
wsCache = { at: Date.now(), entries };
|
|
265
|
+
return entries;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Launch `kraftwerk ui` for a root as a detached process (selfCommand:
|
|
269
|
+
* the bin this process runs from), logging to ~/.kraftwerk/logs/<key>.log.
|
|
270
|
+
* The reported pid is the `kraftwerk ui` supervisor, which forwards
|
|
271
|
+
* SIGTERM/SIGINT to its server child, so `kill <pid>` stops the whole UI.
|
|
272
|
+
* Waits up to ~5s for the new inspector to answer so callers can link
|
|
273
|
+
* straight to it.
|
|
274
|
+
*/
|
|
275
|
+
export async function startProject(root) {
|
|
276
|
+
const abs = path.resolve(root);
|
|
277
|
+
const { name, port, exists } = await describeRoot(abs);
|
|
278
|
+
if (!exists)
|
|
279
|
+
return { ok: false, error: `not a project directory (missing or moved): ${abs}` };
|
|
280
|
+
const url = `http://localhost:${port}`;
|
|
281
|
+
if (abs === selfRoot)
|
|
282
|
+
return { ok: false, url, live: true, error: "that is this workspace" };
|
|
283
|
+
// Already running, or the port is taken by another kraftwerk?
|
|
284
|
+
cache = wsCache = null;
|
|
285
|
+
const live = await discoverInstances();
|
|
286
|
+
const byRoot = live.find((i) => i.root === abs);
|
|
287
|
+
if (byRoot)
|
|
288
|
+
return { ok: true, url: byRoot.url, live: true };
|
|
289
|
+
if (port === selfPort)
|
|
290
|
+
return { ok: false, url, error: `port ${port} is used by this workspace` };
|
|
291
|
+
const byPort = live.find((i) => i.url === url);
|
|
292
|
+
if (byPort)
|
|
293
|
+
return { ok: false, url, error: `port ${port} is used by "${byPort.name}"` };
|
|
294
|
+
let log;
|
|
295
|
+
let stdio = ["ignore", "ignore", "ignore"];
|
|
296
|
+
try {
|
|
297
|
+
await fs.mkdir(LOGS_DIR, { recursive: true });
|
|
298
|
+
log = path.join(LOGS_DIR, `${projectKey(abs)}.log`);
|
|
299
|
+
const fd = fsSync.openSync(log, "a");
|
|
300
|
+
fsSync.writeSync(fd, `\n--- ${new Date().toISOString()} start ${name} (${abs}) ---\n`);
|
|
301
|
+
stdio = ["ignore", fd, fd];
|
|
302
|
+
}
|
|
303
|
+
catch { }
|
|
304
|
+
const { cmd, args } = selfCommand(["ui"]);
|
|
305
|
+
let child;
|
|
306
|
+
try {
|
|
307
|
+
const env = { ...process.env };
|
|
308
|
+
delete env.KRAFTWERK_UI_SUPERVISED; // the new one runs its own supervisor
|
|
309
|
+
child = spawn(cmd, args, { cwd: abs, detached: true, stdio, env });
|
|
310
|
+
child.unref();
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
return { ok: false, url, log, error: err.message };
|
|
314
|
+
}
|
|
315
|
+
if (typeof stdio[1] === "number") {
|
|
316
|
+
try {
|
|
317
|
+
fsSync.closeSync(stdio[1]);
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
}
|
|
321
|
+
let exited = null;
|
|
322
|
+
child.once("exit", (code, signal) => {
|
|
323
|
+
exited = signal ? `killed by ${signal}` : `exited with code ${code}`;
|
|
324
|
+
});
|
|
325
|
+
const deadline = Date.now() + 5_000;
|
|
326
|
+
while (Date.now() < deadline) {
|
|
327
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
328
|
+
if (exited)
|
|
329
|
+
return { ok: false, url, pid: child.pid, log, error: `kraftwerk ui ${exited}` };
|
|
330
|
+
if (await probe(port)) {
|
|
331
|
+
cache = wsCache = null;
|
|
332
|
+
return { ok: true, url, live: true, pid: child.pid, log };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, url, live: false, pid: child.pid, log };
|
|
336
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to run "this same kraftwerk" as a new process: the node we run on
|
|
3
|
+
* plus the bin script we were started from. Identical for global installs,
|
|
4
|
+
* local node_modules, npx caches and a dev checkout (bin/kraftwerk.js
|
|
5
|
+
* picks src/ vs dist/ itself). Shared by the `kraftwerk ui` supervisor and
|
|
6
|
+
* the project-start feature so bin resolution lives in one place.
|
|
7
|
+
*
|
|
8
|
+
* If the bin file is gone (npx cache pruned, package removed) the
|
|
9
|
+
* fallback is `npx @netnodeag/kraftwerk`, i.e. the registry's latest —
|
|
10
|
+
* not the running version, but better than nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function selfCommand(args: string[]): {
|
|
13
|
+
cmd: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
};
|