@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.
- package/README.md +15 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.js +42 -2
- package/dist/inspector/git.d.ts +83 -0
- package/dist/inspector/git.js +651 -0
- package/dist/inspector/instances.js +9 -1
- package/dist/inspector/server.js +132 -3
- package/dist/inspector/settings.d.ts +11 -1
- package/dist/inspector/settings.js +45 -0
- package/dist/runner/docker.d.ts +1 -0
- package/dist/runner/docker.js +1 -1
- package/inspector/dist/assets/{editor-CXrDJOv_.js → editor-BABv2LmA.js} +3 -3
- package/inspector/dist/assets/index-BXspF2KR.css +1 -0
- package/inspector/dist/assets/{index-DAi1VtJ8.js → index-BbTer62d.js} +1 -1
- package/inspector/dist/assets/{index-C3hpUVfp.js → index-Bmm4zJNU.js} +1 -1
- package/inspector/dist/assets/{index-x5G6Np7T.js → index-ByWpqzAI.js} +1 -1
- package/inspector/dist/assets/{index-Upf-BEsx.js → index-CCACy6uF.js} +1 -1
- package/inspector/dist/assets/{index-BNcXQYbv.js → index-Ca9Qs5LZ.js} +1 -1
- package/inspector/dist/assets/{index-Bumwg9SM.js → index-CdwTmXTV.js} +1 -1
- package/inspector/dist/assets/{index-CMMYSoL3.js → index-CjlJeW9z.js} +1 -1
- package/inspector/dist/assets/{index-CszaAR9T.js → index-CzPQygR7.js} +1 -1
- package/inspector/dist/assets/{index-DrSWAGft.js → index-DCR7u4Xa.js} +1 -1
- package/inspector/dist/assets/{index-BxEG-IdY.js → index-DbWUNbt-.js} +1 -1
- package/inspector/dist/assets/{index-BULB2rC_.js → index-DuPp3MUc.js} +1 -1
- package/inspector/dist/assets/{index-CNJyAniG.js → index-DuwmzZAr.js} +1 -1
- package/inspector/dist/assets/{index-CUCCMUMY.js → index-Gx93brFq.js} +1 -1
- package/inspector/dist/assets/{index-BIDsfw8C.js → index-HBdsdJhD.js} +1 -1
- package/inspector/dist/assets/{index-7J4zAS6J.js → index-hkVov8T4.js} +1 -1
- package/inspector/dist/assets/{index-BQMgADlc.js → index-sLxulPOm.js} +1 -1
- package/inspector/dist/assets/index-uEig4--d.js +217 -0
- package/inspector/dist/assets/{index-ChuqpBnv.js → index-wkYwz0Tl.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +5 -2
- package/schema/kraftwerk.schema.json +35 -1
- package/inspector/dist/assets/index-B7rwNdVj.css +0 -1
- 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
|
|
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)
|