@netnodeag/kraftwerk 0.61.0 → 0.63.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/routines.js +2 -3
- package/dist/client/index.d.ts +59 -0
- package/dist/client/index.js +63 -0
- package/dist/client/live.d.ts +34 -0
- package/dist/client/live.js +169 -0
- package/dist/client/routes.d.ts +125 -0
- package/dist/client/routes.js +126 -0
- package/dist/inspector/api/agents.d.ts +31 -0
- package/dist/inspector/api/agents.js +50 -0
- package/dist/inspector/api/attention.d.ts +10 -0
- package/dist/inspector/api/attention.js +23 -0
- package/dist/inspector/api/channels.d.ts +67 -0
- package/dist/inspector/api/channels.js +58 -0
- package/dist/inspector/api/chats.d.ts +55 -0
- package/dist/inspector/api/chats.js +154 -0
- package/dist/inspector/api/files.d.ts +15 -0
- package/dist/inspector/api/files.js +43 -0
- package/dist/inspector/api/git.d.ts +25 -0
- package/dist/inspector/api/git.js +56 -0
- package/dist/inspector/api/index.d.ts +334 -0
- package/dist/inspector/api/index.js +43 -0
- package/dist/inspector/api/knowledge.d.ts +13 -0
- package/dist/inspector/api/knowledge.js +56 -0
- package/dist/inspector/api/mime.d.ts +2 -0
- package/dist/inspector/api/mime.js +18 -0
- package/dist/inspector/api/projects.d.ts +11 -0
- package/dist/inspector/api/projects.js +32 -0
- package/dist/inspector/api/router.d.ts +75 -0
- package/dist/inspector/api/router.js +186 -0
- package/dist/inspector/api/runs.d.ts +33 -0
- package/dist/inspector/api/runs.js +105 -0
- package/dist/inspector/api/socket.d.ts +3 -0
- package/dist/inspector/api/socket.js +115 -0
- package/dist/inspector/api/vibeables.d.ts +4 -0
- package/dist/inspector/api/vibeables.js +31 -0
- package/dist/inspector/api/workspace.d.ts +55 -0
- package/dist/inspector/api/workspace.js +117 -0
- package/dist/inspector/chat/sessions.js +31 -19
- package/dist/inspector/context.d.ts +5 -7
- package/dist/inspector/context.js +6 -20
- package/dist/inspector/git.js +52 -43
- package/dist/inspector/live.d.ts +42 -0
- package/dist/inspector/live.js +91 -0
- package/dist/inspector/notifications.js +19 -13
- package/dist/inspector/routines.d.ts +22 -1
- package/dist/inspector/routines.js +32 -5
- package/dist/inspector/runner.js +5 -4
- package/dist/inspector/server.d.ts +2 -2
- package/dist/inspector/server.js +59 -1389
- package/dist/inspector/version.d.ts +9 -0
- package/dist/inspector/version.js +27 -0
- package/dist/inspector/vibeables.js +25 -23
- package/dist/inspector/workspace.d.ts +42 -0
- package/dist/inspector/workspace.js +63 -0
- package/dist/inspector/ws.d.ts +11 -0
- package/dist/inspector/ws.js +143 -0
- package/inspector/dist/assets/{dist-B3L4wDm3.js → dist-9RESajfo.js} +1 -1
- package/inspector/dist/assets/{dist-Cwukoo61.js → dist-Bm5S1BoZ.js} +1 -1
- package/inspector/dist/assets/dist-C8BofvHx.js +1 -0
- package/inspector/dist/assets/{dist-wYE0FpHk.js → dist-CIs4wnTu.js} +1 -1
- package/inspector/dist/assets/{dist-De7-pK5i.js → dist-CjuETd-_.js} +1 -1
- package/inspector/dist/assets/{dist-B58fOC3R.js → dist-CmVK6rgN.js} +1 -1
- package/inspector/dist/assets/{dist-zk56dqRQ.js → dist-CryXvMV_.js} +1 -1
- package/inspector/dist/assets/{dist-BiWJ3XY1.js → dist-D1hk0iC5.js} +1 -1
- package/inspector/dist/assets/{dist-CH9pz3rR.js → dist-DvW9QE1d.js} +1 -1
- package/inspector/dist/assets/{dist-Dno2CmWo.js → dist-VD9jMxBv.js} +1 -1
- package/inspector/dist/assets/{dist-BKBuUSVQ.js → dist-VlAqWD8T.js} +1 -1
- package/inspector/dist/assets/{editor-CMQMyCOv.js → editor-DkWqwl89.js} +3 -3
- package/inspector/dist/assets/index-Bra74Nxe.css +2 -0
- package/inspector/dist/assets/index-DRJS2pGj.js +229 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +7 -2
- package/inspector/dist/assets/dist-Bj93ipvc.js +0 -1
- package/inspector/dist/assets/index-1wfYRgHE.css +0 -2
- package/inspector/dist/assets/index-CtygNkZJ.js +0 -229
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { publicUrlFor, resolveProject } from "../../config.js";
|
|
3
|
+
import { disposeAllBackends } from "../chat/sessions.js";
|
|
4
|
+
import { cloudStatus } from "../cloud.js";
|
|
5
|
+
import { getProjectRoot } from "../context.js";
|
|
6
|
+
import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, startWorkspace, stopWorkspace, tildify } from "../instances.js";
|
|
7
|
+
import { searchAgents } from "../search.js";
|
|
8
|
+
import { getSettings, saveSettings } from "../settings.js";
|
|
9
|
+
import { canSelfUpdate, startUpdate, updateStatus } from "../update.js";
|
|
10
|
+
import { getDiskVersion, getPkgName, getPkgVersion, RESTART_EXIT_CODE, supervised } from "../version.js";
|
|
11
|
+
import { disposeAllDevs } from "../vibeables.js";
|
|
12
|
+
import { fail, reply, route } from "./router.js";
|
|
13
|
+
/** The workspace itself: identity and features, settings, the other workspaces, updates. */
|
|
14
|
+
export const workspaceRoutes = [
|
|
15
|
+
// ?probe=1 marks a discovery probe from another instance: answer identity
|
|
16
|
+
// only, skip our own discovery (two instances must not probe each other
|
|
17
|
+
// recursively).
|
|
18
|
+
route({ name: "meta.get", method: "GET", path: "/api/meta", summary: "version, workspace identity, enabled features, the switcher" }, async (c) => {
|
|
19
|
+
const project = await resolveProject(getProjectRoot()).catch(() => null);
|
|
20
|
+
const projectName = project?.config.name ?? (project ? path.basename(project.root) : "");
|
|
21
|
+
const manual = project?.config.switcher ?? [];
|
|
22
|
+
const discovered = c.query.get("probe") === "1" ? [] : await discoverWorkspaces();
|
|
23
|
+
// Manual switcher entries keep their configured name/icon; discovered
|
|
24
|
+
// workspaces that duplicate one (same url, running) only contribute
|
|
25
|
+
// the live flag. Stopped workspaces never collide — they carry a root,
|
|
26
|
+
// and a manual entry pointing at the same port is just a link.
|
|
27
|
+
const norm = (u) => u.replace(/\/+$/, "").replace("127.0.0.1", "localhost").toLowerCase();
|
|
28
|
+
const manualUrls = new Set(manual.map((e) => norm(e.url)));
|
|
29
|
+
const liveUrls = new Set(discovered.filter((d) => d.live).map((d) => norm(d.url)));
|
|
30
|
+
const switcher = [
|
|
31
|
+
...manual.map((e) => (liveUrls.has(norm(e.url)) ? { ...e, live: true } : e)),
|
|
32
|
+
...discovered.filter((d) => !(d.live && manualUrls.has(norm(d.url)))),
|
|
33
|
+
];
|
|
34
|
+
return {
|
|
35
|
+
version: await getPkgVersion(),
|
|
36
|
+
// A differing disk version means an upgrade landed while this process
|
|
37
|
+
// runs — the UI offers a relaunch when a supervisor can respawn us.
|
|
38
|
+
diskVersion: await getDiskVersion(),
|
|
39
|
+
restartable: supervised(),
|
|
40
|
+
projectName,
|
|
41
|
+
projectIcon: project?.config.icon ?? "",
|
|
42
|
+
projectColor: project?.config.color ?? "",
|
|
43
|
+
projectNamed: !!project?.config.name,
|
|
44
|
+
projectRoot: project?.root ?? getProjectRoot(),
|
|
45
|
+
projectRootLabel: tildify(project?.root ?? getProjectRoot()),
|
|
46
|
+
git: (project?.config.git && project.config.git.enabled !== false) === true,
|
|
47
|
+
repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
|
|
48
|
+
vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
|
|
49
|
+
projects: (project?.config.projects && project.config.projects.enabled !== false) === true,
|
|
50
|
+
publicUrl: (project && publicUrlFor(project)) ?? "",
|
|
51
|
+
cloud: cloudStatus(),
|
|
52
|
+
switcher,
|
|
53
|
+
};
|
|
54
|
+
}),
|
|
55
|
+
route({ name: "settings.get", method: "GET", path: "/api/settings", summary: "kraftwerk.yml, parsed, with resolved paths" }, async () => getSettings()),
|
|
56
|
+
route({ name: "settings.save", method: "PUT", path: "/api/settings", summary: "write the UI-editable subset of kraftwerk.yml", errors: 400 }, async (c) => saveSettings(await c.body())),
|
|
57
|
+
route({ name: "workspaces.list", method: "GET", path: "/api/workspaces", summary: "every known workspace incl. this one, with state and counts" }, async () => listWorkspacesDetailed()),
|
|
58
|
+
// Launches `kraftwerk ui` for a known workspace as a detached process (the switcher's Start button).
|
|
59
|
+
route({ name: "workspaces.start", method: "POST", path: "/api/workspaces/start", summary: "start a known workspace {root}", errors: 400 }, async (c) => {
|
|
60
|
+
const { root } = await c.body();
|
|
61
|
+
if (!root)
|
|
62
|
+
fail(400, "root required");
|
|
63
|
+
const result = await startWorkspace(root);
|
|
64
|
+
return reply(result.ok ? 200 : 409, result);
|
|
65
|
+
}),
|
|
66
|
+
route({ name: "workspaces.stop", method: "POST", path: "/api/workspaces/stop", summary: "stop a running workspace's server {root | url}", errors: 400 }, async (c) => {
|
|
67
|
+
const target = await c.body();
|
|
68
|
+
if (!target.root && !target.url)
|
|
69
|
+
fail(400, "root or url required");
|
|
70
|
+
const result = await stopWorkspace(target);
|
|
71
|
+
return reply(result.ok ? 200 : 409, result);
|
|
72
|
+
}),
|
|
73
|
+
route({ name: "workspaces.forget", method: "POST", path: "/api/workspaces/forget", summary: "drop a workspace from the registry {root}", errors: 400 }, async (c) => {
|
|
74
|
+
const { root } = await c.body();
|
|
75
|
+
if (!root)
|
|
76
|
+
fail(400, "root required");
|
|
77
|
+
return { ok: await forgetWorkspace(root) };
|
|
78
|
+
}),
|
|
79
|
+
route({ name: "search.agents", method: "GET", path: "/api/search/agents", summary: "active agents and channels of every reachable workspace (⌘K)" }, async () => searchAgents()),
|
|
80
|
+
route({ name: "update.check", method: "GET", path: "/api/update-check", summary: "the latest version on the npm registry" }, async () => {
|
|
81
|
+
const name = await getPkgName();
|
|
82
|
+
const current = await getDiskVersion();
|
|
83
|
+
try {
|
|
84
|
+
const r = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5_000) });
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
throw new Error(String(r.status));
|
|
87
|
+
const latest = (await r.json()).version ?? "";
|
|
88
|
+
return { name, current, latest };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return reply(502, { name, current, latest: "", error: "npm registry unreachable" });
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
route({ name: "update.status", method: "GET", path: "/api/update", summary: "state and log of the self-update, and whether one can run here" }, async () => {
|
|
95
|
+
const name = await getPkgName();
|
|
96
|
+
const can = canSelfUpdate(name);
|
|
97
|
+
return { ...updateStatus(), name, available: can.ok, reason: can.ok ? undefined : can.reason, restartable: supervised() };
|
|
98
|
+
}),
|
|
99
|
+
// Runs `npm i -g <name>@<version>`; 409 when refused or already running.
|
|
100
|
+
route({ name: "update.start", method: "POST", path: "/api/update", summary: "self-update to {version?} (default latest)", errors: 409 }, async (c) => {
|
|
101
|
+
const name = await getPkgName();
|
|
102
|
+
const body = await c.body();
|
|
103
|
+
const version = typeof body.version === "string" && body.version ? body.version : "latest";
|
|
104
|
+
return reply(202, { ...startUpdate(name, version), name });
|
|
105
|
+
}),
|
|
106
|
+
// Exits with the respawn code; the `kraftwerk ui` supervisor relaunches.
|
|
107
|
+
route({ name: "server.restart", method: "POST", path: "/api/restart", summary: "relaunch the server (needs the `kraftwerk ui` supervisor)" }, async () => {
|
|
108
|
+
if (!supervised())
|
|
109
|
+
fail(400, "not supervised — restart `kraftwerk ui` manually");
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
disposeAllBackends();
|
|
112
|
+
disposeAllDevs();
|
|
113
|
+
process.exit(RESTART_EXIT_CODE);
|
|
114
|
+
}, 150);
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}),
|
|
117
|
+
];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import { getOutputDir, getProjectRoot } from "../context.js";
|
|
4
|
+
import { perWorkspace } from "../workspace.js";
|
|
5
|
+
import { touch } from "../live.js";
|
|
4
6
|
import { knowledgeIndex } from "../knowledge.js";
|
|
5
7
|
import { getRun, listRuns, safeRunDir } from "../runs.js";
|
|
6
8
|
import { listSkills, readSkill } from "../skills.js";
|
|
@@ -19,7 +21,8 @@ import { JOURNAL_FILE, journalExcerpt, readJournal } from "../journal.js";
|
|
|
19
21
|
import { filesSummary } from "../files.js";
|
|
20
22
|
import { appendEvent, attachmentPath, copyChatFiles, listChatMetas, newChatId, readEvents, readMeta, readMetaAt, safeChatDir, writeMeta, } from "./store.js";
|
|
21
23
|
const MAIN = "main";
|
|
22
|
-
|
|
24
|
+
/** Chats with a loaded state, by id — one table per workspace. */
|
|
25
|
+
const chatStates = perWorkspace(() => new Map());
|
|
23
26
|
const newSeat = (key, harness) => ({
|
|
24
27
|
key,
|
|
25
28
|
harness,
|
|
@@ -130,21 +133,25 @@ function dropBackend(state) {
|
|
|
130
133
|
const IDLE_BACKEND_MS = 15 * 60_000;
|
|
131
134
|
const reaper = setInterval(() => {
|
|
132
135
|
const cutoff = Date.now() - IDLE_BACKEND_MS;
|
|
133
|
-
for (const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
for (const [ws, states] of chatStates.entries()) {
|
|
137
|
+
ws.run(() => {
|
|
138
|
+
for (const state of chatStates().values()) {
|
|
139
|
+
const live = [...state.seats.values()].some((s) => s.backend);
|
|
140
|
+
if (live && !isBusy(state) && Date.parse(state.meta.updatedAt) < cutoff) {
|
|
141
|
+
dropBackend(state);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
138
145
|
}
|
|
139
146
|
}, 60_000);
|
|
140
147
|
reaper.unref?.();
|
|
141
148
|
/** Server shutdown: no agent subprocess may outlive the inspector. */
|
|
142
149
|
export function disposeAllBackends() {
|
|
143
|
-
for (const
|
|
144
|
-
dropBackend(state);
|
|
150
|
+
for (const [ws, states] of chatStates.entries())
|
|
151
|
+
ws.run(() => states.forEach((state) => dropBackend(state)));
|
|
145
152
|
}
|
|
146
153
|
async function loadState(id) {
|
|
147
|
-
const existing =
|
|
154
|
+
const existing = chatStates().get(id);
|
|
148
155
|
if (existing)
|
|
149
156
|
return existing;
|
|
150
157
|
const meta = await readMeta(id);
|
|
@@ -162,7 +169,7 @@ async function loadState(id) {
|
|
|
162
169
|
writeChain: Promise.resolve(),
|
|
163
170
|
};
|
|
164
171
|
// Two racing loads: keep whichever registered first.
|
|
165
|
-
const registered =
|
|
172
|
+
const registered = chatStates().get(id) ?? (chatStates().set(id, state), state);
|
|
166
173
|
if (registered === state)
|
|
167
174
|
closeInterruptedWork(state);
|
|
168
175
|
return registered;
|
|
@@ -241,8 +248,13 @@ function emit(state, ev, from) {
|
|
|
241
248
|
.catch(() => { });
|
|
242
249
|
for (const fn of state.subscribers)
|
|
243
250
|
fn(stored);
|
|
251
|
+
// What a list shows about a chat (busy, waiting for you) changed: live watches re-evaluate.
|
|
252
|
+
if (STATUS_EVENTS.has(ev.type))
|
|
253
|
+
touch();
|
|
244
254
|
return stored;
|
|
245
255
|
}
|
|
256
|
+
/** Events that change a chat's state as lists show it — not the streamed text in between. */
|
|
257
|
+
const STATUS_EVENTS = new Set(["user_message", "turn_start", "turn_end", "error", "failure", "permission_request", "permission_resolved", "elicitation_request", "elicitation_resolved", "files_changed"]);
|
|
246
258
|
/* ---------- skills ---------- */
|
|
247
259
|
/**
|
|
248
260
|
* Skills visible to a chat: every discovered skill (workspace skills root
|
|
@@ -858,7 +870,7 @@ export async function createChat(opts) {
|
|
|
858
870
|
updatedAt: now,
|
|
859
871
|
};
|
|
860
872
|
await writeMeta(meta);
|
|
861
|
-
|
|
873
|
+
chatStates().set(meta.id, {
|
|
862
874
|
meta,
|
|
863
875
|
events: [],
|
|
864
876
|
subscribers: new Set(),
|
|
@@ -873,12 +885,12 @@ export async function createChat(opts) {
|
|
|
873
885
|
}
|
|
874
886
|
/** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
|
|
875
887
|
export function chatAwaitingApproval(id) {
|
|
876
|
-
return (
|
|
888
|
+
return (chatStates().get(id)?.pendingInfo.size ?? 0) > 0;
|
|
877
889
|
}
|
|
878
890
|
/** Every request waiting for a human, across the chats in memory. */
|
|
879
891
|
export function waitingRequests() {
|
|
880
892
|
const out = [];
|
|
881
|
-
for (const state of
|
|
893
|
+
for (const state of chatStates().values()) {
|
|
882
894
|
for (const [requestId, info] of state.pendingInfo) {
|
|
883
895
|
out.push({ requestId, ...info, meta: state.meta, seat: state.pendingSeat.get(requestId) ?? MAIN });
|
|
884
896
|
}
|
|
@@ -894,7 +906,7 @@ export function waitingRequests() {
|
|
|
894
906
|
export function agentActivity() {
|
|
895
907
|
const out = new Map();
|
|
896
908
|
const of = (slug) => out.get(slug) ?? out.set(slug, { working: [], waiting: [] }).get(slug);
|
|
897
|
-
for (const state of
|
|
909
|
+
for (const state of chatStates().values()) {
|
|
898
910
|
const { meta } = state;
|
|
899
911
|
for (const seat of state.seats.values()) {
|
|
900
912
|
const slug = meta.scope.kind === "agent" ? meta.scope.slug : meta.scope.kind === "channel" ? seat.key : undefined;
|
|
@@ -912,8 +924,8 @@ export function agentActivity() {
|
|
|
912
924
|
export async function listChats() {
|
|
913
925
|
const metas = await listChatMetas();
|
|
914
926
|
return metas.map((m) => ({
|
|
915
|
-
...(
|
|
916
|
-
busy:
|
|
927
|
+
...(chatStates().get(m.id)?.meta ?? m),
|
|
928
|
+
busy: chatStates().has(m.id) ? isBusy(chatStates().get(m.id)) : false,
|
|
917
929
|
awaitingApproval: chatAwaitingApproval(m.id),
|
|
918
930
|
}));
|
|
919
931
|
}
|
|
@@ -1130,7 +1142,7 @@ export async function ensureChannelChat(channel) {
|
|
|
1130
1142
|
const metas = await listChatMetas();
|
|
1131
1143
|
const existing = metas.find((m) => m.scope.kind === "channel" && m.scope.slug === channel.slug);
|
|
1132
1144
|
if (existing)
|
|
1133
|
-
return
|
|
1145
|
+
return chatStates().get(existing.id)?.meta ?? existing;
|
|
1134
1146
|
const meta = await createChat({ agent: "claude", scope: { kind: "channel", slug: channel.slug }, title: channel.name });
|
|
1135
1147
|
if (channel.project) {
|
|
1136
1148
|
meta.project = channel.project;
|
|
@@ -1191,7 +1203,7 @@ export async function convertChatToChannel(chatId, channel) {
|
|
|
1191
1203
|
}
|
|
1192
1204
|
/** Members left the channel: their processes go, the transcript stays. */
|
|
1193
1205
|
export async function dropChannelSeats(chatId, keep) {
|
|
1194
|
-
const state =
|
|
1206
|
+
const state = chatStates().get(chatId);
|
|
1195
1207
|
if (!state)
|
|
1196
1208
|
return;
|
|
1197
1209
|
for (const [key, seat] of state.seats) {
|
|
@@ -1423,7 +1435,7 @@ export async function deleteChat(id) {
|
|
|
1423
1435
|
for (const resolve of state.pendingElicitations.values())
|
|
1424
1436
|
resolve({ action: "cancel" });
|
|
1425
1437
|
dropBackend(state);
|
|
1426
|
-
|
|
1438
|
+
chatStates().delete(id);
|
|
1427
1439
|
await moveToTrash("chats", id, safeChatDir(id));
|
|
1428
1440
|
return {};
|
|
1429
1441
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
export declare function setOutputDir(dir: string): void;
|
|
2
|
-
export declare function setProjectRoot(dir: string): void;
|
|
3
|
-
/** Absolute output directory (runs/ + chats/ live inside). */
|
|
4
|
-
export declare function getOutputDir(): string;
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* kraftwerk-data/output — always pass projectRoot when known).
|
|
2
|
+
* Shorthands for the current workspace's paths (see workspace.ts for how
|
|
3
|
+
* the current workspace is chosen).
|
|
9
4
|
*/
|
|
5
|
+
/** Absolute output directory (runs/ + chats/ live inside). */
|
|
6
|
+
export declare function getOutputDir(): string;
|
|
7
|
+
/** The workspace root: where kraftwerk.yml lives. */
|
|
10
8
|
export declare function getProjectRoot(): string;
|
|
@@ -1,27 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { currentWorkspace } from "./workspace.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Shorthands for the current workspace's paths (see workspace.ts for how
|
|
4
|
+
* the current workspace is chosen).
|
|
5
5
|
*/
|
|
6
|
-
let outputDir = "";
|
|
7
|
-
let projectRoot = "";
|
|
8
|
-
export function setOutputDir(dir) {
|
|
9
|
-
outputDir = path.resolve(dir);
|
|
10
|
-
}
|
|
11
|
-
export function setProjectRoot(dir) {
|
|
12
|
-
projectRoot = path.resolve(dir);
|
|
13
|
-
}
|
|
14
6
|
/** Absolute output directory (runs/ + chats/ live inside). */
|
|
15
7
|
export function getOutputDir() {
|
|
16
|
-
|
|
17
|
-
throw new Error("inspector context not initialized");
|
|
18
|
-
return outputDir;
|
|
8
|
+
return currentWorkspace().outputDir;
|
|
19
9
|
}
|
|
20
|
-
/**
|
|
21
|
-
* The consumer project root. Set explicitly at startup; falls back to the
|
|
22
|
-
* parent of the output dir (wrong for nested output dirs like
|
|
23
|
-
* kraftwerk-data/output — always pass projectRoot when known).
|
|
24
|
-
*/
|
|
10
|
+
/** The workspace root: where kraftwerk.yml lives. */
|
|
25
11
|
export function getProjectRoot() {
|
|
26
|
-
return
|
|
12
|
+
return currentWorkspace().root;
|
|
27
13
|
}
|
package/dist/inspector/git.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { projectsRootFor, reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
|
|
5
5
|
import { ENV_FILE } from "../runner/docker.js";
|
|
6
6
|
import { getOutputDir, getProjectRoot } from "./context.js";
|
|
7
|
+
import { perWorkspace } from "./workspace.js";
|
|
7
8
|
/**
|
|
8
9
|
* Workspace git sync. Several people run kraftwerk against one git repo, so
|
|
9
10
|
* knowledge, agents, skills, channels and workflows travel between them like
|
|
@@ -325,13 +326,26 @@ export function parseStatus(out) {
|
|
|
325
326
|
}
|
|
326
327
|
return files;
|
|
327
328
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
/**
|
|
330
|
+
* The workspace's sync state: the status cache, the queue of repo-mutating
|
|
331
|
+
* commands, the last fetch/pull/error, and the background timer.
|
|
332
|
+
*/
|
|
333
|
+
const gitState = perWorkspace(() => ({
|
|
334
|
+
statusCache: null,
|
|
335
|
+
/** Bumped by touched(); a status computed under an older value is stale. */
|
|
336
|
+
generation: 0,
|
|
337
|
+
chain: Promise.resolve(),
|
|
338
|
+
lastFetch: undefined,
|
|
339
|
+
lastPull: undefined,
|
|
340
|
+
lastError: undefined,
|
|
341
|
+
timer: null,
|
|
342
|
+
ticking: false,
|
|
343
|
+
lastRun: 0,
|
|
344
|
+
}));
|
|
331
345
|
/** Invalidate the status cache after anything that changes the repo. */
|
|
332
346
|
const touched = () => {
|
|
333
|
-
statusCache = null;
|
|
334
|
-
generation++;
|
|
347
|
+
gitState().statusCache = null;
|
|
348
|
+
gitState().generation++;
|
|
335
349
|
};
|
|
336
350
|
/**
|
|
337
351
|
* One repo-mutating git command at a time. The background sync and the
|
|
@@ -341,24 +355,21 @@ const touched = () => {
|
|
|
341
355
|
* fetch, pull and push queue behind each other. Status stays outside the
|
|
342
356
|
* queue (GIT_OPTIONAL_LOCKS=0 keeps it lock-free).
|
|
343
357
|
*/
|
|
344
|
-
let chain = Promise.resolve();
|
|
345
358
|
function serial(fn) {
|
|
346
|
-
const run = chain.then(fn, fn);
|
|
347
|
-
chain = run.catch(() => { });
|
|
359
|
+
const run = gitState().chain.then(fn, fn);
|
|
360
|
+
gitState().chain = run.catch(() => { });
|
|
348
361
|
return run;
|
|
349
362
|
}
|
|
350
|
-
let lastFetch;
|
|
351
|
-
let lastPull;
|
|
352
|
-
let lastError;
|
|
353
363
|
/**
|
|
354
364
|
* Everything the git screen renders. Each call shells out to git a handful
|
|
355
365
|
* of times, and the nav badge polls it from every open tab, so results are
|
|
356
366
|
* cached for a beat.
|
|
357
367
|
*/
|
|
358
368
|
export async function gitStatus(fresh = false) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
369
|
+
const cached = gitState().statusCache;
|
|
370
|
+
if (!fresh && cached && Date.now() - cached.at < CACHE_MS)
|
|
371
|
+
return cached.value;
|
|
372
|
+
const gen = gitState().generation;
|
|
362
373
|
const opened = await openRepo();
|
|
363
374
|
if (opened.off)
|
|
364
375
|
return { enabled: false };
|
|
@@ -393,17 +404,17 @@ export async function gitStatus(fresh = false) {
|
|
|
393
404
|
diverged: !!ahead && !!behind,
|
|
394
405
|
autosync: repo.cfg.autosync,
|
|
395
406
|
interval: repo.cfg.interval,
|
|
396
|
-
lastFetch,
|
|
397
|
-
lastPull,
|
|
398
|
-
lastError,
|
|
407
|
+
lastFetch: gitState().lastFetch,
|
|
408
|
+
lastPull: gitState().lastPull,
|
|
409
|
+
lastError: gitState().lastError,
|
|
399
410
|
};
|
|
400
411
|
// A failed status (a stale index.lock, a timeout on a huge untracked tree)
|
|
401
412
|
// must not read as "workspace clean": with no file list the commit endpoint
|
|
402
413
|
// refuses everything, and the message should say why.
|
|
403
414
|
if (!status.ok) {
|
|
404
415
|
const value = { ...base, error: `git status failed: ${status.stderr || "unknown error"}` };
|
|
405
|
-
if (gen === generation)
|
|
406
|
-
statusCache = { at: Date.now(), value };
|
|
416
|
+
if (gen === gitState().generation)
|
|
417
|
+
gitState().statusCache = { at: Date.now(), value };
|
|
407
418
|
return value;
|
|
408
419
|
}
|
|
409
420
|
const scope = scopeFor(repo);
|
|
@@ -428,8 +439,8 @@ export async function gitStatus(fresh = false) {
|
|
|
428
439
|
// A commit that finished while this status was running already emptied
|
|
429
440
|
// the cache; caching the pre-commit list on top of that would show the
|
|
430
441
|
// committed file as dirty for another CACHE_MS.
|
|
431
|
-
if (gen === generation)
|
|
432
|
-
statusCache = { at: Date.now(), value };
|
|
442
|
+
if (gen === gitState().generation)
|
|
443
|
+
gitState().statusCache = { at: Date.now(), value };
|
|
433
444
|
return value;
|
|
434
445
|
}
|
|
435
446
|
/**
|
|
@@ -556,11 +567,11 @@ async function fetchNow() {
|
|
|
556
567
|
// put "fetched 5s ago" next to ahead/behind counts that stopped moving
|
|
557
568
|
// hours ago, which is the one thing this line exists to rule out.
|
|
558
569
|
if (!r.ok) {
|
|
559
|
-
lastError = r.stderr || "fetch failed";
|
|
560
|
-
return { ok: false, error: lastError };
|
|
570
|
+
gitState().lastError = r.stderr || "fetch failed";
|
|
571
|
+
return { ok: false, error: gitState().lastError };
|
|
561
572
|
}
|
|
562
|
-
lastFetch = new Date().toISOString();
|
|
563
|
-
lastError = undefined;
|
|
573
|
+
gitState().lastFetch = new Date().toISOString();
|
|
574
|
+
gitState().lastError = undefined;
|
|
564
575
|
return { ok: true };
|
|
565
576
|
}
|
|
566
577
|
/** Fast-forward only. A diverged branch is the user's problem, not ours. */
|
|
@@ -584,13 +595,13 @@ async function pullNow() {
|
|
|
584
595
|
// git's own non-fast-forward output is a wall of hints. Say the one
|
|
585
596
|
// thing that matters and leave the resolution to a terminal.
|
|
586
597
|
const notFf = /non-fast-forward|diverg|cannot be fast-forwarded/i.test(r.stderr);
|
|
587
|
-
lastError = notFf
|
|
598
|
+
gitState().lastError = notFf
|
|
588
599
|
? "The branch has diverged from its upstream. Merge or rebase it in a terminal."
|
|
589
600
|
: r.stderr || "pull failed";
|
|
590
|
-
return { ok: false, error: lastError };
|
|
601
|
+
return { ok: false, error: gitState().lastError };
|
|
591
602
|
}
|
|
592
|
-
lastPull = new Date().toISOString();
|
|
593
|
-
lastError = undefined;
|
|
603
|
+
gitState().lastPull = new Date().toISOString();
|
|
604
|
+
gitState().lastError = undefined;
|
|
594
605
|
return { ok: true };
|
|
595
606
|
}
|
|
596
607
|
export function gitPush() {
|
|
@@ -610,10 +621,10 @@ async function pushNow() {
|
|
|
610
621
|
: await gitNet(netArgs(["push", "--set-upstream"], cfg.remote, `HEAD:${head.branch}`), repoRoot);
|
|
611
622
|
touched();
|
|
612
623
|
if (!r.ok) {
|
|
613
|
-
lastError = r.stderr || "push failed";
|
|
614
|
-
return { ok: false, error: lastError };
|
|
624
|
+
gitState().lastError = r.stderr || "push failed";
|
|
625
|
+
return { ok: false, error: gitState().lastError };
|
|
615
626
|
}
|
|
616
|
-
lastError = undefined;
|
|
627
|
+
gitState().lastError = undefined;
|
|
617
628
|
return { ok: true };
|
|
618
629
|
}
|
|
619
630
|
/* ---------- background sync ---------- */
|
|
@@ -624,9 +635,6 @@ async function pushNow() {
|
|
|
624
635
|
* next restart.
|
|
625
636
|
*/
|
|
626
637
|
const RECHECK_MS = 60_000;
|
|
627
|
-
let timer = null;
|
|
628
|
-
let ticking = false;
|
|
629
|
-
let lastRun = 0;
|
|
630
638
|
/**
|
|
631
639
|
* Fetch on an interval, and fast-forward when `autosync: pull` is set. Never
|
|
632
640
|
* commits. A pull is skipped while the tree is dirty, so nobody's unsaved
|
|
@@ -635,34 +643,35 @@ let lastRun = 0;
|
|
|
635
643
|
* out) before the next one starts, or a short interval piles up processes.
|
|
636
644
|
*/
|
|
637
645
|
export function startGitSync() {
|
|
638
|
-
if (timer || ticking)
|
|
646
|
+
if (gitState().timer || gitState().ticking)
|
|
639
647
|
return;
|
|
640
648
|
schedule(0);
|
|
641
649
|
}
|
|
642
650
|
function schedule(ms) {
|
|
643
|
-
timer = setTimeout(() => void tick(), ms);
|
|
651
|
+
const timer = setTimeout(() => void tick(), ms);
|
|
644
652
|
timer.unref?.();
|
|
653
|
+
gitState().timer = timer;
|
|
645
654
|
}
|
|
646
655
|
async function tick() {
|
|
647
|
-
timer = null;
|
|
648
|
-
ticking = true;
|
|
656
|
+
gitState().timer = null;
|
|
657
|
+
gitState().ticking = true;
|
|
649
658
|
let next = RECHECK_MS;
|
|
650
659
|
try {
|
|
651
660
|
const { repo } = await openRepo();
|
|
652
661
|
if (repo && repo.cfg.interval > 0) {
|
|
653
662
|
const every = repo.cfg.interval * 1000;
|
|
654
|
-
if (Date.now() - lastRun >= every - 500) {
|
|
655
|
-
lastRun = Date.now();
|
|
663
|
+
if (Date.now() - gitState().lastRun >= every - 500) {
|
|
664
|
+
gitState().lastRun = Date.now();
|
|
656
665
|
await sync(repo);
|
|
657
666
|
}
|
|
658
|
-
next = Math.min(RECHECK_MS, Math.max(1_000, lastRun + every - Date.now()));
|
|
667
|
+
next = Math.min(RECHECK_MS, Math.max(1_000, gitState().lastRun + every - Date.now()));
|
|
659
668
|
}
|
|
660
669
|
}
|
|
661
670
|
catch {
|
|
662
671
|
// A failed tick is retried at the next one; the status carries lastError.
|
|
663
672
|
}
|
|
664
673
|
finally {
|
|
665
|
-
ticking = false;
|
|
674
|
+
gitState().ticking = false;
|
|
666
675
|
schedule(next);
|
|
667
676
|
}
|
|
668
677
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live data for watching clients: a watch is a route result some client
|
|
3
|
+
* keeps on screen (the rail's agents, the bell's notifications). The server
|
|
4
|
+
* evaluates each distinct watch once — however many clients share it — and
|
|
5
|
+
* pushes it only when it changed:
|
|
6
|
+
*
|
|
7
|
+
* - right after any change made through the API (`touch()` from the router),
|
|
8
|
+
* - when a chat's state changes (turn start/end, a question, an answer),
|
|
9
|
+
* - and on the watch's interval, for changes nobody announces (an agent
|
|
10
|
+
* writing files, a run progressing on disk).
|
|
11
|
+
*
|
|
12
|
+
* Evaluation runs in the watch's workspace. This module knows nothing about
|
|
13
|
+
* routes: the socket hands it an `evaluate` closure.
|
|
14
|
+
*/
|
|
15
|
+
interface Watcher {
|
|
16
|
+
interval: number;
|
|
17
|
+
push(data: unknown): void;
|
|
18
|
+
}
|
|
19
|
+
interface Watch {
|
|
20
|
+
evaluate(): Promise<{
|
|
21
|
+
ok: boolean;
|
|
22
|
+
data: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
watchers: Set<Watcher>;
|
|
25
|
+
/** The last pushed JSON, to push changes only. */
|
|
26
|
+
last?: string;
|
|
27
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
28
|
+
/** The evaluation in flight, and the one waiting behind it. */
|
|
29
|
+
running?: Promise<void>;
|
|
30
|
+
queued?: {
|
|
31
|
+
promise: Promise<void>;
|
|
32
|
+
forced: Set<Watcher>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Keep `push` fed with the result of `evaluate` (shared by `key`): now, on
|
|
37
|
+
* every change, and at least every `interval` ms. Returns the unwatch.
|
|
38
|
+
*/
|
|
39
|
+
export declare function watch(key: string, evaluate: Watch["evaluate"], interval: number, push: (data: unknown) => void): () => void;
|
|
40
|
+
/** Something changed: re-evaluate every watch of this workspace soon (debounced). */
|
|
41
|
+
export declare function touch(): void;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { perWorkspace } from "./workspace.js";
|
|
2
|
+
const MIN_INTERVAL = 1000;
|
|
3
|
+
const TOUCH_DEBOUNCE_MS = 60;
|
|
4
|
+
const state = perWorkspace(() => ({ watches: new Map(), touchTimer: undefined }));
|
|
5
|
+
/**
|
|
6
|
+
* Evaluate after whatever evaluation is in flight (it may predate the
|
|
7
|
+
* change that asked for this one). Requests that arrive while one waits
|
|
8
|
+
* share it. Changed data goes to every watcher; `force` watchers (just
|
|
9
|
+
* joined) get it even unchanged.
|
|
10
|
+
*/
|
|
11
|
+
function run(w, force) {
|
|
12
|
+
if (w.queued) {
|
|
13
|
+
if (force)
|
|
14
|
+
w.queued.forced.add(force);
|
|
15
|
+
return w.queued.promise;
|
|
16
|
+
}
|
|
17
|
+
const forced = new Set(force ? [force] : []);
|
|
18
|
+
const promise = (w.running ?? Promise.resolve()).then(async () => {
|
|
19
|
+
w.queued = undefined;
|
|
20
|
+
try {
|
|
21
|
+
const r = await w.evaluate();
|
|
22
|
+
// A failed evaluation keeps the last good value on every client.
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return;
|
|
25
|
+
const json = JSON.stringify(r.data) ?? "null";
|
|
26
|
+
const changed = json !== w.last;
|
|
27
|
+
w.last = json;
|
|
28
|
+
for (const x of w.watchers)
|
|
29
|
+
if (changed || forced.has(x))
|
|
30
|
+
x.push(r.data);
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
});
|
|
34
|
+
w.queued = { promise, forced };
|
|
35
|
+
w.running = promise;
|
|
36
|
+
return promise;
|
|
37
|
+
}
|
|
38
|
+
function schedule(w) {
|
|
39
|
+
clearTimeout(w.timer);
|
|
40
|
+
if (!w.watchers.size)
|
|
41
|
+
return;
|
|
42
|
+
const every = Math.max(MIN_INTERVAL, Math.min(...[...w.watchers].map((x) => x.interval)));
|
|
43
|
+
w.timer = setTimeout(() => void run(w, undefined).finally(() => schedule(w)), every);
|
|
44
|
+
w.timer.unref?.();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Keep `push` fed with the result of `evaluate` (shared by `key`): now, on
|
|
48
|
+
* every change, and at least every `interval` ms. Returns the unwatch.
|
|
49
|
+
*/
|
|
50
|
+
export function watch(key, evaluate, interval, push) {
|
|
51
|
+
const { watches } = state();
|
|
52
|
+
let w = watches.get(key);
|
|
53
|
+
if (!w) {
|
|
54
|
+
w = { evaluate, watchers: new Set() };
|
|
55
|
+
watches.set(key, w);
|
|
56
|
+
}
|
|
57
|
+
const watcher = { interval, push };
|
|
58
|
+
w.watchers.add(watcher);
|
|
59
|
+
// A new watcher gets the current value at once, changed or not.
|
|
60
|
+
void run(w, watcher).finally(() => schedule(w));
|
|
61
|
+
const current = w;
|
|
62
|
+
return () => {
|
|
63
|
+
current.watchers.delete(watcher);
|
|
64
|
+
if (!current.watchers.size) {
|
|
65
|
+
clearTimeout(current.timer);
|
|
66
|
+
if (watches.get(key) === current)
|
|
67
|
+
watches.delete(key);
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
schedule(current);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Something changed: re-evaluate every watch of this workspace soon (debounced). */
|
|
74
|
+
export function touch() {
|
|
75
|
+
// Outside any workspace (a unit test, a CLI command) nobody can be watching.
|
|
76
|
+
let s;
|
|
77
|
+
try {
|
|
78
|
+
s = state();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!s.watches.size || s.touchTimer)
|
|
84
|
+
return;
|
|
85
|
+
s.touchTimer = setTimeout(() => {
|
|
86
|
+
s.touchTimer = undefined;
|
|
87
|
+
for (const w of s.watches.values())
|
|
88
|
+
void run(w, undefined).finally(() => schedule(w));
|
|
89
|
+
}, TOUCH_DEBOUNCE_MS);
|
|
90
|
+
s.touchTimer.unref?.();
|
|
91
|
+
}
|