@netnodeag/kraftwerk 0.34.1 → 0.35.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.
Files changed (36) hide show
  1. package/README.md +15 -1
  2. package/dist/config.d.ts +24 -0
  3. package/dist/config.js +42 -2
  4. package/dist/inspector/git.d.ts +83 -0
  5. package/dist/inspector/git.js +651 -0
  6. package/dist/inspector/instances.js +9 -1
  7. package/dist/inspector/server.js +132 -3
  8. package/dist/inspector/settings.d.ts +11 -1
  9. package/dist/inspector/settings.js +45 -0
  10. package/dist/runner/docker.d.ts +1 -0
  11. package/dist/runner/docker.js +1 -1
  12. package/inspector/dist/assets/{editor-CXrDJOv_.js → editor-BABv2LmA.js} +3 -3
  13. package/inspector/dist/assets/index-BXspF2KR.css +1 -0
  14. package/inspector/dist/assets/{index-DAi1VtJ8.js → index-BbTer62d.js} +1 -1
  15. package/inspector/dist/assets/{index-C3hpUVfp.js → index-Bmm4zJNU.js} +1 -1
  16. package/inspector/dist/assets/{index-x5G6Np7T.js → index-ByWpqzAI.js} +1 -1
  17. package/inspector/dist/assets/{index-Upf-BEsx.js → index-CCACy6uF.js} +1 -1
  18. package/inspector/dist/assets/{index-BNcXQYbv.js → index-Ca9Qs5LZ.js} +1 -1
  19. package/inspector/dist/assets/{index-Bumwg9SM.js → index-CdwTmXTV.js} +1 -1
  20. package/inspector/dist/assets/{index-CMMYSoL3.js → index-CjlJeW9z.js} +1 -1
  21. package/inspector/dist/assets/{index-CszaAR9T.js → index-CzPQygR7.js} +1 -1
  22. package/inspector/dist/assets/{index-DrSWAGft.js → index-DCR7u4Xa.js} +1 -1
  23. package/inspector/dist/assets/{index-BxEG-IdY.js → index-DbWUNbt-.js} +1 -1
  24. package/inspector/dist/assets/{index-BULB2rC_.js → index-DuPp3MUc.js} +1 -1
  25. package/inspector/dist/assets/{index-CNJyAniG.js → index-DuwmzZAr.js} +1 -1
  26. package/inspector/dist/assets/{index-CUCCMUMY.js → index-Gx93brFq.js} +1 -1
  27. package/inspector/dist/assets/{index-BIDsfw8C.js → index-HBdsdJhD.js} +1 -1
  28. package/inspector/dist/assets/{index-7J4zAS6J.js → index-hkVov8T4.js} +1 -1
  29. package/inspector/dist/assets/{index-BQMgADlc.js → index-sLxulPOm.js} +1 -1
  30. package/inspector/dist/assets/index-uEig4--d.js +217 -0
  31. package/inspector/dist/assets/{index-ChuqpBnv.js → index-wkYwz0Tl.js} +1 -1
  32. package/inspector/dist/index.html +2 -2
  33. package/package.json +5 -2
  34. package/schema/kraftwerk.schema.json +35 -1
  35. package/inspector/dist/assets/index-B7rwNdVj.css +0 -1
  36. package/inspector/dist/assets/index-BBGjdXk1.js +0 -213
@@ -0,0 +1,651 @@
1
+ import { spawn } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveProject } from "../config.js";
5
+ import { ENV_FILE } from "../runner/docker.js";
6
+ import { getOutputDir, getProjectRoot } from "./context.js";
7
+ /**
8
+ * Workspace git sync. Several people run kraftwerk against one git repo, so
9
+ * knowledge, agents, skills and workflows travel between them like any other
10
+ * tracked file. The inspector shows what changed, lets a human pick files and
11
+ * commit them, and pushes on request.
12
+ *
13
+ * Deliberate limits, because this runs git on someone's repo from a web UI
14
+ * that has no authentication of its own:
15
+ *
16
+ * - Only the roots kraftwerk.yml already declares are ever staged, never the
17
+ * whole repo, and never the output directory (runs and chats are noise).
18
+ * - DENY matches (.env, keys) are never staged, and `git add -f` is never
19
+ * called, so .gitignore keeps its meaning.
20
+ * - Reads are scoped too: the diff endpoint only shows a path the status
21
+ * screen lists as syncable, and status never lists what .gitignore hides,
22
+ * so an untracked .env cannot be read through it.
23
+ * - Commit and push are manual. The background timer only fetches, and pulls
24
+ * fast-forward when `autosync: pull` is set, the configured branch is the
25
+ * one checked out, and no tracked file is modified.
26
+ * - A diverged branch is reported, never merged or rebased from here.
27
+ *
28
+ * Every git call passes an argument array, so nothing reaches a shell, and
29
+ * every call is async with a timeout: git runs inside the single-threaded
30
+ * inspector, so a network operation must never block the event loop, and a
31
+ * remote asking for credentials must fail instead of waiting forever on a
32
+ * terminal nobody is watching.
33
+ */
34
+ /** Files that must never be staged, whatever the scope says. */
35
+ const DENY = [
36
+ /(^|\/)\.env($|\.)/,
37
+ /(^|\/)\.envrc$/,
38
+ new RegExp(`(^|/)${ENV_FILE.replace(/\./g, "\\.")}$`),
39
+ /\.(pem|key|p12|pfx)$/,
40
+ /(^|\/)id_(rsa|ed25519|ecdsa|dsa)/,
41
+ /(^|\/)\.(netrc|npmrc|pypirc)$/,
42
+ /(^|\/)credentials\.json$/,
43
+ ];
44
+ const DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
45
+ /** Local operations are quick; anything touching the network gets longer. */
46
+ const LOCAL_TIMEOUT = 15_000;
47
+ const NET_TIMEOUT = 60_000;
48
+ const MAX_OUTPUT = 32 * 1024 * 1024;
49
+ /** Longest diff the screen gets; a dumped database under knowledge/ is not something to read in a browser. */
50
+ const MAX_DIFF = 512 * 1024;
51
+ /** How long a status is reused; the nav badge polls it from every open tab. */
52
+ const CACHE_MS = 3_000;
53
+ /**
54
+ * How many blocked files the status may carry. A repo with a large untracked
55
+ * tree (node_modules before someone ignores it) has tens of thousands of
56
+ * them, and this payload is polled every few seconds by every open tab.
57
+ * Syncable files are never capped: the commit endpoint checks the selection
58
+ * against them.
59
+ */
60
+ const MAX_BLOCKED = 200;
61
+ /**
62
+ * Never prompt. Without this a remote that wants a password parks git on a
63
+ * terminal read that never returns, and the inspector hangs with it.
64
+ */
65
+ const gitEnv = (extra) => ({
66
+ ...process.env,
67
+ GIT_TERMINAL_PROMPT: "0",
68
+ GIT_ASKPASS: "",
69
+ SSH_ASKPASS: "",
70
+ GIT_OPTIONAL_LOCKS: "0",
71
+ ...extra,
72
+ });
73
+ function git(args, cwd, timeoutMs = LOCAL_TIMEOUT, env) {
74
+ return new Promise((resolve) => {
75
+ let child;
76
+ try {
77
+ child = spawn("git", args, { cwd, env: gitEnv(env) });
78
+ }
79
+ catch (err) {
80
+ return resolve({ ok: false, code: null, stdout: "", stderr: err.message });
81
+ }
82
+ let stdout = "";
83
+ let stderr = "";
84
+ let settled = false;
85
+ const finish = (r) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ resolve(r);
91
+ };
92
+ const timer = setTimeout(() => {
93
+ child.kill("SIGKILL");
94
+ finish({ ok: false, code: null, stdout, stderr: `git ${args[0]} timed out after ${Math.round(timeoutMs / 1000)}s` });
95
+ }, timeoutMs);
96
+ timer.unref?.();
97
+ child.stdout?.on("data", (d) => {
98
+ if (stdout.length < MAX_OUTPUT)
99
+ stdout += d.toString();
100
+ });
101
+ child.stderr?.on("data", (d) => {
102
+ if (stderr.length < MAX_OUTPUT)
103
+ stderr += d.toString();
104
+ });
105
+ child.once("error", (err) => finish({ ok: false, code: null, stdout, stderr: err.message }));
106
+ child.once("close", (code) => finish({ ok: code === 0, code, stdout, stderr: stderr.trim() }));
107
+ });
108
+ }
109
+ /**
110
+ * ssh must not prompt either, but GIT_SSH_COMMAND takes precedence over
111
+ * core.sshCommand, so setting it blindly would replace a per-repo key setup
112
+ * (`ssh -i ~/.ssh/work_key`) with plain ssh and fail with "permission denied"
113
+ * on a repo that works fine in a terminal. Take whatever is configured and
114
+ * append BatchMode to it. An explicit GIT_SSH_COMMAND in our own environment
115
+ * is left alone: whoever set it owns it.
116
+ */
117
+ const sshCache = new Map();
118
+ async function sshCommandFor(repoRoot) {
119
+ if (process.env.GIT_SSH_COMMAND)
120
+ return process.env.GIT_SSH_COMMAND;
121
+ const hit = sshCache.get(repoRoot);
122
+ if (hit && Date.now() - hit.at < 60_000)
123
+ return hit.cmd;
124
+ const configured = (await git(["config", "--get", "core.sshCommand"], repoRoot)).stdout.trim();
125
+ const cmd = `${configured || "ssh"} -o BatchMode=yes`;
126
+ sshCache.set(repoRoot, { at: Date.now(), cmd });
127
+ return cmd;
128
+ }
129
+ /** A git call that may touch the network: longer timeout, non-interactive ssh. */
130
+ async function gitNet(args, repoRoot) {
131
+ return git(args, repoRoot, NET_TIMEOUT, { GIT_SSH_COMMAND: await sshCommandFor(repoRoot) });
132
+ }
133
+ /**
134
+ * Pathspecs are globs by default, so a file called `note[1].md` would not
135
+ * match itself. `:(literal)` turns the pattern off.
136
+ */
137
+ const literal = (p) => `:(literal)${p}`;
138
+ /** Repo root for a directory, or null when it is not inside a git repo. */
139
+ async function repoRootFor(dir) {
140
+ const r = await git(["rev-parse", "--show-toplevel"], dir);
141
+ return r.ok ? path.resolve(r.stdout.trim()) : null;
142
+ }
143
+ /**
144
+ * Resolve the project, its git block and the repo root in one go. `off` when
145
+ * kraftwerk.yml has no git block; `error` when it has one but the repo is
146
+ * unusable. The config is re-read every time, so an edit to kraftwerk.yml
147
+ * takes effect without a restart.
148
+ */
149
+ async function openRepo() {
150
+ let project;
151
+ try {
152
+ project = await resolveProject(getProjectRoot());
153
+ }
154
+ catch (err) {
155
+ return { error: err.message };
156
+ }
157
+ const g = project.config.git;
158
+ if (!g || g.enabled === false)
159
+ return { off: true, error: "git sync is off" };
160
+ const cfg = {
161
+ remote: g.remote ?? DEFAULTS.remote,
162
+ branch: g.branch,
163
+ interval: g.interval ?? DEFAULTS.interval,
164
+ autosync: g.autosync ?? DEFAULTS.autosync,
165
+ };
166
+ const repoRoot = await repoRootFor(project.root);
167
+ if (!repoRoot)
168
+ return { cfg, error: `${project.root} is not inside a git repository.` };
169
+ return { repo: { cfg, project, repoRoot } };
170
+ }
171
+ /**
172
+ * The checked-out branch, or why it cannot be synced: a detached HEAD, or a
173
+ * configured branch that is not the one checked out. Without the second
174
+ * check the background timer would fast-forward whatever branch happens to
175
+ * be out, and a push would report success for the wrong ref.
176
+ */
177
+ async function checkedOut(repo) {
178
+ // symbolic-ref, not `rev-parse --abbrev-ref HEAD`: on a repo with no
179
+ // commits yet rev-parse exits 128, which would render a blank branch name
180
+ // and report a detached HEAD that isn't one. symbolic-ref answers on an
181
+ // unborn branch and fails only when HEAD really is detached.
182
+ const head = await git(["symbolic-ref", "--short", "HEAD"], repo.repoRoot);
183
+ const branch = head.ok ? head.stdout.trim() : "";
184
+ if (!branch)
185
+ return { error: "HEAD is detached. Check out a branch to sync." };
186
+ if (repo.cfg.branch && repo.cfg.branch !== branch) {
187
+ return { branch, error: `kraftwerk.yml syncs "${repo.cfg.branch}" but "${branch}" is checked out.` };
188
+ }
189
+ return { branch };
190
+ }
191
+ /**
192
+ * The roots that may be committed, as repo-relative paths: the workflows,
193
+ * knowledge, agents and skills roots this project declares, plus
194
+ * kraftwerk.yml. The output directory is excluded even when it lives inside
195
+ * one of them.
196
+ */
197
+ function scopeFor({ project, repoRoot }) {
198
+ // rev-parse reports the repo root with symlinks resolved; the project root
199
+ // may be spelled through one (/var → /private/var on macOS, a linked
200
+ // checkout). Compared unresolved, every root would look outside the repo.
201
+ const real = (abs) => {
202
+ try {
203
+ return realpathSync.native(abs);
204
+ }
205
+ catch {
206
+ return path.resolve(abs);
207
+ }
208
+ };
209
+ const rel = (abs) => {
210
+ const r = path.relative(repoRoot, real(abs));
211
+ if (r.startsWith(".."))
212
+ return null;
213
+ return r.split(path.sep).join("/") || ".";
214
+ };
215
+ const roots = [
216
+ project.workflowsRoot,
217
+ path.resolve(project.root, project.config.knowledge ?? "knowledge"),
218
+ path.resolve(project.root, project.config.agents ?? "agents"),
219
+ path.resolve(project.root, project.config.skills ?? "skills"),
220
+ project.configPath,
221
+ ];
222
+ const mapped = roots.filter((r) => !!r).map(rel).filter((r) => !!r);
223
+ const outputRel = rel(getOutputDir());
224
+ return {
225
+ include: [...new Set(mapped.filter((r) => r !== "."))],
226
+ exclude: outputRel ? [outputRel] : [],
227
+ wholeRepo: mapped.includes("."),
228
+ };
229
+ }
230
+ const within = (file, root) => file === root || file.startsWith(root.endsWith("/") ? root : root + "/");
231
+ /**
232
+ * Why a repo-relative path may not be synced, or undefined when it may. The
233
+ * one place that decides: status marks files with it, and commit and diff
234
+ * only accept what status marked syncable.
235
+ */
236
+ function reasonFor(file, code, scope) {
237
+ // `status --untracked-files=all` expands every directory except a nested
238
+ // git repository, which it lists as `dir/`. Adding that records a gitlink
239
+ // to a commit nobody else has: a broken entry for everyone who pulls.
240
+ if (file.endsWith("/"))
241
+ return "a nested git repository, not synced";
242
+ if (DENY.some((re) => re.test(file)))
243
+ return "never synced (secret or key)";
244
+ if (scope.exclude.some((e) => within(file, e)))
245
+ return "run artifacts, not synced";
246
+ if (!scope.include.some((r) => within(file, r)))
247
+ return "outside the workspace paths";
248
+ if (/[RC]/.test(code))
249
+ return "renamed, commit it in a terminal";
250
+ if (/U/.test(code) || code === "AA" || code === "DD")
251
+ return "conflicted, resolve it in a terminal";
252
+ return undefined;
253
+ }
254
+ /**
255
+ * True when tracked files differ from HEAD, in the worktree or the index.
256
+ * Untracked files are deliberately not counted: they do not stop
257
+ * `pull --ff-only`, and treating them as dirt would disable autosync for
258
+ * good in any repo with a scratch file lying around. A git error counts as
259
+ * dirty, because not knowing is not a reason to merge.
260
+ */
261
+ async function hasLocalChanges(repoRoot) {
262
+ if (!(await git(["diff", "--quiet"], repoRoot)).ok)
263
+ return true;
264
+ return !(await git(["diff", "--cached", "--quiet"], repoRoot)).ok;
265
+ }
266
+ /** Label for a porcelain code, using whichever half is set. */
267
+ function label(code) {
268
+ if (code === "??")
269
+ return "untracked";
270
+ const c = code.replace(/ /g, "");
271
+ if (c.includes("U") || c === "AA" || c === "DD")
272
+ return "conflicted";
273
+ if (c.includes("D"))
274
+ return "deleted";
275
+ if (c.includes("R"))
276
+ return "renamed";
277
+ if (c.includes("C"))
278
+ return "copied";
279
+ if (c.includes("A"))
280
+ return "added";
281
+ if (c.includes("M"))
282
+ return "modified";
283
+ if (c.includes("T"))
284
+ return "typechange";
285
+ return c || "changed";
286
+ }
287
+ /** Parse `git status --porcelain=v1 -z`, which NUL-separates and doubles up on renames and copies. */
288
+ function parseStatus(out) {
289
+ const parts = out.split("\0").filter((p) => p.length > 0);
290
+ const files = [];
291
+ for (let i = 0; i < parts.length; i++) {
292
+ const entry = parts[i];
293
+ if (entry.length < 4)
294
+ continue;
295
+ const code = entry.slice(0, 2);
296
+ files.push({ path: entry.slice(3), code });
297
+ // A rename or copy entry is followed by its source path in its own record.
298
+ if (/[RC]/.test(code))
299
+ i++;
300
+ }
301
+ return files;
302
+ }
303
+ let statusCache = null;
304
+ /** Bumped by touched(); a status computed under an older value is stale. */
305
+ let generation = 0;
306
+ /** Invalidate the status cache after anything that changes the repo. */
307
+ const touched = () => {
308
+ statusCache = null;
309
+ generation++;
310
+ };
311
+ /**
312
+ * One repo-mutating git command at a time. The background sync and the
313
+ * request handlers share the repo: a `pull --ff-only` still holding
314
+ * .git/index.lock when a commit arrives makes the commit fail with "index.lock:
315
+ * File exists", and the rollback after it hits the same lock. So commit,
316
+ * fetch, pull and push queue behind each other. Status stays outside the
317
+ * queue (GIT_OPTIONAL_LOCKS=0 keeps it lock-free).
318
+ */
319
+ let chain = Promise.resolve();
320
+ function serial(fn) {
321
+ const run = chain.then(fn, fn);
322
+ chain = run.catch(() => { });
323
+ return run;
324
+ }
325
+ let lastFetch;
326
+ let lastPull;
327
+ let lastError;
328
+ /**
329
+ * Everything the git screen renders. Each call shells out to git a handful
330
+ * of times, and the nav badge polls it from every open tab, so results are
331
+ * cached for a beat.
332
+ */
333
+ export async function gitStatus(fresh = false) {
334
+ if (!fresh && statusCache && Date.now() - statusCache.at < CACHE_MS)
335
+ return statusCache.value;
336
+ const gen = generation;
337
+ const opened = await openRepo();
338
+ if (opened.off)
339
+ return { enabled: false };
340
+ const { repo, cfg } = opened;
341
+ if (!repo)
342
+ return { enabled: true, error: opened.error, autosync: cfg?.autosync, interval: cfg?.interval };
343
+ const { repoRoot } = repo;
344
+ const [head, upstreamRes, status] = await Promise.all([
345
+ checkedOut(repo),
346
+ git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot),
347
+ git(["status", "--porcelain=v1", "-z", "--untracked-files=all"], repoRoot),
348
+ ]);
349
+ const upstream = upstreamRes.ok ? upstreamRes.stdout.trim() : undefined;
350
+ let ahead;
351
+ let behind;
352
+ if (upstream) {
353
+ const counts = await git(["rev-list", "--left-right", "--count", `${upstream}...HEAD`], repoRoot);
354
+ if (counts.ok) {
355
+ const [b, a] = counts.stdout.trim().split(/\s+/).map(Number);
356
+ behind = Number.isFinite(b) ? b : undefined;
357
+ ahead = Number.isFinite(a) ? a : undefined;
358
+ }
359
+ }
360
+ const base = {
361
+ enabled: true,
362
+ repoRoot,
363
+ branch: head.branch,
364
+ upstream,
365
+ remote: cfg?.remote ?? repo.cfg.remote,
366
+ ahead,
367
+ behind,
368
+ diverged: !!ahead && !!behind,
369
+ autosync: repo.cfg.autosync,
370
+ interval: repo.cfg.interval,
371
+ lastFetch,
372
+ lastPull,
373
+ lastError,
374
+ };
375
+ // A failed status (a stale index.lock, a timeout on a huge untracked tree)
376
+ // must not read as "workspace clean": with no file list the commit endpoint
377
+ // refuses everything, and the message should say why.
378
+ if (!status.ok) {
379
+ const value = { ...base, error: `git status failed: ${status.stderr || "unknown error"}` };
380
+ if (gen === generation)
381
+ statusCache = { at: Date.now(), value };
382
+ return value;
383
+ }
384
+ const scope = scopeFor(repo);
385
+ const all = parseStatus(status.stdout).map((f) => {
386
+ const reason = reasonFor(f.path, f.code, scope);
387
+ return { path: f.path, code: f.code, status: label(f.code), syncable: !reason, reason };
388
+ });
389
+ const byPath = (a, b) => a.path.localeCompare(b.path);
390
+ const syncable = all.filter((f) => f.syncable).sort(byPath);
391
+ const blocked = all.filter((f) => !f.syncable).sort(byPath);
392
+ const blockedHidden = Math.max(0, blocked.length - MAX_BLOCKED);
393
+ const scopeError = scope.wholeRepo
394
+ ? "A configured root points at the repository root, which would put the whole repo in scope. Narrow workflows/knowledge/agents/skills in kraftwerk.yml."
395
+ : undefined;
396
+ const value = {
397
+ ...base,
398
+ error: head.error ?? scopeError,
399
+ files: [...syncable, ...blocked.slice(0, MAX_BLOCKED)],
400
+ blockedHidden: blockedHidden || undefined,
401
+ scope: scope.include,
402
+ };
403
+ // A commit that finished while this status was running already emptied
404
+ // the cache; caching the pre-commit list on top of that would show the
405
+ // committed file as dirty for another CACHE_MS.
406
+ if (gen === generation)
407
+ statusCache = { at: Date.now(), value };
408
+ return value;
409
+ }
410
+ /**
411
+ * Unified diff for one changed file. Reading is as sensitive as writing
412
+ * here: this endpoint has no auth of its own, and a diff of an untracked
413
+ * .env is the file itself. So the status list is the one source of readable
414
+ * paths: a file not listed there, or listed as blocked, is not shown.
415
+ * Status never lists ignored files, which covers .gitignore too.
416
+ */
417
+ export async function gitDiff(file) {
418
+ if (file.includes("\0") || path.isAbsolute(file) || file.split("/").includes("..")) {
419
+ return { diff: "", error: "invalid path" };
420
+ }
421
+ const st = await gitStatus();
422
+ if (!st.enabled)
423
+ return { diff: "", error: "git sync is off" };
424
+ if (!st.repoRoot || !st.files)
425
+ return { diff: "", error: st.error ?? "not a git repository" };
426
+ const entry = st.files.find((f) => f.path === file);
427
+ if (!entry)
428
+ return { diff: "", error: "not shown: not a changed file under the workspace paths" };
429
+ if (!entry.syncable)
430
+ return { diff: "", error: `not shown: ${entry.reason}` };
431
+ const { repoRoot } = st;
432
+ // A repo without commits yet has nothing for `diff HEAD` to compare with;
433
+ // there, as for an untracked file, the whole file is the change.
434
+ const hasHead = (await git(["rev-parse", "--verify", "-q", "HEAD"], repoRoot)).ok;
435
+ if (entry.code === "??" || !hasHead) {
436
+ // --no-index exits 1 when the files differ, which is the normal case here.
437
+ const r = await git(["diff", "--no-index", "--", "/dev/null", file], repoRoot);
438
+ return r.stdout ? capDiff(r.stdout) : { diff: "", error: r.stderr || "no diff available" };
439
+ }
440
+ const r = await git(["diff", "HEAD", "--", literal(file)], repoRoot);
441
+ return r.ok ? capDiff(r.stdout) : { diff: "", error: r.stderr || "diff failed" };
442
+ }
443
+ function capDiff(diff) {
444
+ if (diff.length <= MAX_DIFF)
445
+ return { diff };
446
+ const cut = diff.lastIndexOf("\n", MAX_DIFF);
447
+ return { diff: diff.slice(0, cut > 0 ? cut : MAX_DIFF), truncated: true };
448
+ }
449
+ /** Stage the given repo-relative paths and commit just those. */
450
+ export function gitCommit(paths, message) {
451
+ return serial(() => commitNow(paths, message));
452
+ }
453
+ async function commitNow(paths, message) {
454
+ const { repo, error } = await openRepo();
455
+ if (!repo)
456
+ return { ok: false, error };
457
+ const { repoRoot } = repo;
458
+ const text = message.trim();
459
+ if (!text)
460
+ return { ok: false, error: "commit message required" };
461
+ if (paths.length === 0)
462
+ return { ok: false, error: "no files selected" };
463
+ // Re-check the selection server-side. Matching the posted string against
464
+ // the scope is not enough: `git add -- knowledge` is a directory pathspec
465
+ // that would stage everything beneath it, denied files included. So every
466
+ // path must be one the current status already reports as syncable.
467
+ // Uncached: the status cache is fine for a badge, but a path that just
468
+ // stopped being syncable must not slip through on a stale read. A status
469
+ // error (detached HEAD, a root covering the whole repo) blocks the commit
470
+ // rather than producing one no branch points at.
471
+ const current = await gitStatus(true);
472
+ if (current.error)
473
+ return { ok: false, error: current.error };
474
+ const allowed = new Map((current.files ?? []).filter((f) => f.syncable).map((f) => [f.path, f]));
475
+ for (const p of paths) {
476
+ if (!allowed.has(p)) {
477
+ return { ok: false, error: `${p} is not a syncable change (directories and out-of-scope paths are refused)` };
478
+ }
479
+ }
480
+ const specs = paths.map(literal);
481
+ // Only untracked paths are staged. `git commit -- <paths>` commits the
482
+ // worktree content of a tracked path without going through the index at
483
+ // all, so a partially staged file (code "MM") keeps whatever the user had
484
+ // staged when the commit is rejected by a pre-commit hook or a signing
485
+ // failure. Untracked files have no index entry for a pathspec to match, so
486
+ // they must be added first — and they are the only thing rolled back.
487
+ const ours = paths.filter((p) => allowed.get(p)?.code === "??");
488
+ if (ours.length) {
489
+ const add = await git(["add", "--", ...ours.map(literal)], repoRoot);
490
+ if (!add.ok) {
491
+ touched();
492
+ return { ok: false, error: add.stderr || "git add failed" };
493
+ }
494
+ }
495
+ const commit = await git(["commit", "-m", text, "--", ...specs], repoRoot);
496
+ if (!commit.ok) {
497
+ let error = commit.stderr || commit.stdout.trim() || "git commit failed";
498
+ if (ours.length) {
499
+ const specs2 = ours.map(literal);
500
+ // `reset HEAD` has no HEAD to reset to in a repo without commits yet;
501
+ // there, dropping the index entry is the same rollback.
502
+ let reset = await git(["reset", "-q", "HEAD", "--", ...specs2], repoRoot);
503
+ if (!reset.ok)
504
+ reset = await git(["rm", "-q", "--cached", "--", ...specs2], repoRoot);
505
+ if (!reset.ok)
506
+ error += ` (${ours.length} path(s) left staged)`;
507
+ }
508
+ touched();
509
+ return { ok: false, error };
510
+ }
511
+ touched();
512
+ return { ok: true, committed: paths.length };
513
+ }
514
+ export function gitFetch() {
515
+ return serial(fetchNow);
516
+ }
517
+ async function fetchNow() {
518
+ const { repo, error } = await openRepo();
519
+ if (!repo)
520
+ return { ok: false, error };
521
+ const r = await gitNet(["fetch", repo.cfg.remote], repo.repoRoot);
522
+ touched();
523
+ // Only a fetch that worked counts. Stamping the time on a failure would
524
+ // put "fetched 5s ago" next to ahead/behind counts that stopped moving
525
+ // hours ago, which is the one thing this line exists to rule out.
526
+ if (!r.ok) {
527
+ lastError = r.stderr || "fetch failed";
528
+ return { ok: false, error: lastError };
529
+ }
530
+ lastFetch = new Date().toISOString();
531
+ lastError = undefined;
532
+ return { ok: true };
533
+ }
534
+ /** Fast-forward only. A diverged branch is the user's problem, not ours. */
535
+ export function gitPull() {
536
+ return serial(pullNow);
537
+ }
538
+ async function pullNow() {
539
+ const { repo, error } = await openRepo();
540
+ if (!repo)
541
+ return { ok: false, error };
542
+ const { repoRoot, cfg } = repo;
543
+ const head = await checkedOut(repo);
544
+ if (head.error || !head.branch)
545
+ return { ok: false, error: head.error };
546
+ const upstream = (await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)).ok;
547
+ const r = upstream
548
+ ? await gitNet(["pull", "--ff-only"], repoRoot)
549
+ : await gitNet(["pull", "--ff-only", cfg.remote, head.branch], repoRoot);
550
+ touched();
551
+ if (!r.ok) {
552
+ // git's own non-fast-forward output is a wall of hints. Say the one
553
+ // thing that matters and leave the resolution to a terminal.
554
+ const notFf = /non-fast-forward|diverg|cannot be fast-forwarded/i.test(r.stderr);
555
+ lastError = notFf
556
+ ? "The branch has diverged from its upstream. Merge or rebase it in a terminal."
557
+ : r.stderr || "pull failed";
558
+ return { ok: false, error: lastError };
559
+ }
560
+ lastPull = new Date().toISOString();
561
+ lastError = undefined;
562
+ return { ok: true };
563
+ }
564
+ export function gitPush() {
565
+ return serial(pushNow);
566
+ }
567
+ async function pushNow() {
568
+ const { repo, error } = await openRepo();
569
+ if (!repo)
570
+ return { ok: false, error };
571
+ const { repoRoot, cfg } = repo;
572
+ const head = await checkedOut(repo);
573
+ if (head.error || !head.branch)
574
+ return { ok: false, error: head.error };
575
+ const upstream = (await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)).ok;
576
+ const r = upstream
577
+ ? await gitNet(["push"], repoRoot)
578
+ : await gitNet(["push", "--set-upstream", cfg.remote, `HEAD:${head.branch}`], repoRoot);
579
+ touched();
580
+ if (!r.ok) {
581
+ lastError = r.stderr || "push failed";
582
+ return { ok: false, error: lastError };
583
+ }
584
+ lastError = undefined;
585
+ return { ok: true };
586
+ }
587
+ /* ---------- background sync ---------- */
588
+ /**
589
+ * How often the timer re-reads kraftwerk.yml while the feature is off or its
590
+ * interval is 0, and the longest it waits between reads while it is on, so
591
+ * an edit to the git block takes effect within a minute instead of at the
592
+ * next restart.
593
+ */
594
+ const RECHECK_MS = 60_000;
595
+ let timer = null;
596
+ let ticking = false;
597
+ let lastRun = 0;
598
+ /**
599
+ * Fetch on an interval, and fast-forward when `autosync: pull` is set. Never
600
+ * commits. A pull is skipped while the tree is dirty, so nobody's unsaved
601
+ * work is disturbed by a background merge. Ticks are chained, not scheduled
602
+ * on a fixed interval: a fetch against a slow remote must finish (or time
603
+ * out) before the next one starts, or a short interval piles up processes.
604
+ */
605
+ export function startGitSync() {
606
+ if (timer || ticking)
607
+ return;
608
+ schedule(0);
609
+ }
610
+ function schedule(ms) {
611
+ timer = setTimeout(() => void tick(), ms);
612
+ timer.unref?.();
613
+ }
614
+ async function tick() {
615
+ timer = null;
616
+ ticking = true;
617
+ let next = RECHECK_MS;
618
+ try {
619
+ const { repo } = await openRepo();
620
+ if (repo && repo.cfg.interval > 0) {
621
+ const every = repo.cfg.interval * 1000;
622
+ if (Date.now() - lastRun >= every - 500) {
623
+ lastRun = Date.now();
624
+ await sync(repo);
625
+ }
626
+ next = Math.min(RECHECK_MS, Math.max(1_000, lastRun + every - Date.now()));
627
+ }
628
+ }
629
+ catch {
630
+ // A failed tick is retried at the next one; the status carries lastError.
631
+ }
632
+ finally {
633
+ ticking = false;
634
+ schedule(next);
635
+ }
636
+ }
637
+ async function sync(repo) {
638
+ const fetched = await gitFetch();
639
+ if (!fetched.ok || repo.cfg.autosync !== "pull")
640
+ return;
641
+ const st = await gitStatus();
642
+ if (st.error || !st.behind || st.diverged || !st.repoRoot)
643
+ return;
644
+ // A local modification can make --ff-only refuse, and a failed pull
645
+ // every interval would park a permanent error in the UI. Untracked
646
+ // files do not count: they only collide when an incoming commit adds
647
+ // the same path, and that shows up as one pull error, not silence.
648
+ if (await hasLocalChanges(st.repoRoot))
649
+ return;
650
+ await gitPull();
651
+ }
@@ -60,8 +60,16 @@ export function unregisterInstance() {
60
60
  * each other's /api/meta must not recurse.
61
61
  */
62
62
  async function probe(port) {
63
+ // `listen(port, "localhost")` binds one address family, whichever the
64
+ // resolver returns first (::1 on this platform), so an instance started
65
+ // with KRAFTWERK_UI_HOST=localhost or ::1 is not on 127.0.0.1. A refused
66
+ // connection returns at once, so the second try costs nothing when the
67
+ // first one answers.
68
+ return (await probeAt("127.0.0.1", port)) ?? (await probeAt("[::1]", port));
69
+ }
70
+ async function probeAt(host, port) {
63
71
  try {
64
- const r = await fetch(`http://127.0.0.1:${port}/api/meta?probe=1`, {
72
+ const r = await fetch(`http://${host}:${port}/api/meta?probe=1`, {
65
73
  signal: AbortSignal.timeout(400),
66
74
  });
67
75
  if (!r.ok)