@netnodeag/kraftwerk 0.35.0 → 0.37.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 +65 -20
- package/dist/cli/doctor.js +18 -1
- package/dist/cli/init.js +19 -12
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/cli/projects.d.ts +14 -0
- package/dist/cli/projects.js +1 -1
- package/dist/cli/repos.d.ts +2 -0
- package/dist/cli/repos.js +115 -0
- package/dist/cli/routines.d.ts +10 -0
- package/dist/cli/routines.js +10 -10
- package/dist/config.d.ts +29 -2
- package/dist/config.js +56 -2
- package/dist/inspector/{team.d.ts → agents.d.ts} +21 -12
- package/dist/inspector/{team.js → agents.js} +34 -19
- package/dist/inspector/chat/backend.d.ts +1 -1
- package/dist/inspector/chat/sessions.js +72 -45
- package/dist/inspector/chat/store.js +13 -1
- package/dist/inspector/chat/types.d.ts +2 -2
- package/dist/inspector/git.d.ts +14 -0
- package/dist/inspector/git.js +11 -9
- package/dist/inspector/instances.d.ts +12 -0
- package/dist/inspector/instances.js +28 -0
- package/dist/inspector/repos.d.ts +118 -0
- package/dist/inspector/repos.js +249 -0
- package/dist/inspector/routines.d.ts +4 -4
- package/dist/inspector/routines.js +11 -11
- package/dist/inspector/search.d.ts +25 -0
- package/dist/inspector/search.js +31 -0
- package/dist/inspector/server.js +77 -30
- package/dist/inspector/settings.d.ts +7 -1
- package/dist/inspector/settings.js +32 -1
- package/dist/inspector/skills.d.ts +6 -6
- package/dist/inspector/skills.js +8 -8
- package/inspector/dist/assets/{editor-BABv2LmA.js → editor-vM6JSj5h.js} +3 -3
- package/inspector/dist/assets/{index-CCACy6uF.js → index-BAAjszPI.js} +1 -1
- package/inspector/dist/assets/{index-DuwmzZAr.js → index-BKcOp35r.js} +1 -1
- package/inspector/dist/assets/index-Ba8VU6Wz.js +217 -0
- package/inspector/dist/assets/{index-Ca9Qs5LZ.js → index-Bb08wK-l.js} +1 -1
- package/inspector/dist/assets/{index-ByWpqzAI.js → index-BrCwDkJZ.js} +1 -1
- package/inspector/dist/assets/{index-hkVov8T4.js → index-Bs1U0rWW.js} +1 -1
- package/inspector/dist/assets/{index-CdwTmXTV.js → index-C1UXlW32.js} +1 -1
- package/inspector/dist/assets/{index-HBdsdJhD.js → index-CQ2XKqmD.js} +1 -1
- package/inspector/dist/assets/{index-DCR7u4Xa.js → index-CReepirH.js} +1 -1
- package/inspector/dist/assets/{index-CzPQygR7.js → index-CUGfxoYt.js} +1 -1
- package/inspector/dist/assets/{index-sLxulPOm.js → index-CaKmxcD6.js} +1 -1
- package/inspector/dist/assets/{index-Bmm4zJNU.js → index-CtMGH62C.js} +1 -1
- package/inspector/dist/assets/{index-BXspF2KR.css → index-D4_mLTSH.css} +1 -1
- package/inspector/dist/assets/{index-BbTer62d.js → index-D_11IZBV.js} +1 -1
- package/inspector/dist/assets/{index-wkYwz0Tl.js → index-DfZT0IoM.js} +1 -1
- package/inspector/dist/assets/{index-Gx93brFq.js → index-DykDNrYm.js} +1 -1
- package/inspector/dist/assets/{index-DuPp3MUc.js → index-MWv5H1oP.js} +1 -1
- package/inspector/dist/assets/{index-CjlJeW9z.js → index-oIQqfbwm.js} +1 -1
- package/inspector/dist/assets/{index-DbWUNbt-.js → index-yGZsomcy.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +15 -0
- package/inspector/dist/assets/index-uEig4--d.js +0 -217
package/dist/inspector/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { resolveProject } from "../config.js";
|
|
4
|
+
import { reposRootFor, resolveProject } from "../config.js";
|
|
5
5
|
import { ENV_FILE } from "../runner/docker.js";
|
|
6
6
|
import { getOutputDir, getProjectRoot } from "./context.js";
|
|
7
7
|
/**
|
|
@@ -44,7 +44,7 @@ const DENY = [
|
|
|
44
44
|
const DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
|
|
45
45
|
/** Local operations are quick; anything touching the network gets longer. */
|
|
46
46
|
const LOCAL_TIMEOUT = 15_000;
|
|
47
|
-
const NET_TIMEOUT = 60_000;
|
|
47
|
+
export const NET_TIMEOUT = 60_000;
|
|
48
48
|
const MAX_OUTPUT = 32 * 1024 * 1024;
|
|
49
49
|
/** Longest diff the screen gets; a dumped database under knowledge/ is not something to read in a browser. */
|
|
50
50
|
const MAX_DIFF = 512 * 1024;
|
|
@@ -70,7 +70,7 @@ const gitEnv = (extra) => ({
|
|
|
70
70
|
GIT_OPTIONAL_LOCKS: "0",
|
|
71
71
|
...extra,
|
|
72
72
|
});
|
|
73
|
-
function git(args, cwd, timeoutMs = LOCAL_TIMEOUT, env) {
|
|
73
|
+
export function git(args, cwd, timeoutMs = LOCAL_TIMEOUT, env) {
|
|
74
74
|
return new Promise((resolve) => {
|
|
75
75
|
let child;
|
|
76
76
|
try {
|
|
@@ -115,7 +115,7 @@ function git(args, cwd, timeoutMs = LOCAL_TIMEOUT, env) {
|
|
|
115
115
|
* is left alone: whoever set it owns it.
|
|
116
116
|
*/
|
|
117
117
|
const sshCache = new Map();
|
|
118
|
-
async function sshCommandFor(repoRoot) {
|
|
118
|
+
export async function sshCommandFor(repoRoot) {
|
|
119
119
|
if (process.env.GIT_SSH_COMMAND)
|
|
120
120
|
return process.env.GIT_SSH_COMMAND;
|
|
121
121
|
const hit = sshCache.get(repoRoot);
|
|
@@ -127,7 +127,7 @@ async function sshCommandFor(repoRoot) {
|
|
|
127
127
|
return cmd;
|
|
128
128
|
}
|
|
129
129
|
/** A git call that may touch the network: longer timeout, non-interactive ssh. */
|
|
130
|
-
async function gitNet(args, repoRoot) {
|
|
130
|
+
export async function gitNet(args, repoRoot) {
|
|
131
131
|
return git(args, repoRoot, NET_TIMEOUT, { GIT_SSH_COMMAND: await sshCommandFor(repoRoot) });
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
@@ -136,7 +136,7 @@ async function gitNet(args, repoRoot) {
|
|
|
136
136
|
*/
|
|
137
137
|
const literal = (p) => `:(literal)${p}`;
|
|
138
138
|
/** Repo root for a directory, or null when it is not inside a git repo. */
|
|
139
|
-
async function repoRootFor(dir) {
|
|
139
|
+
export async function repoRootFor(dir) {
|
|
140
140
|
const r = await git(["rev-parse", "--show-toplevel"], dir);
|
|
141
141
|
return r.ok ? path.resolve(r.stdout.trim()) : null;
|
|
142
142
|
}
|
|
@@ -220,10 +220,12 @@ function scopeFor({ project, repoRoot }) {
|
|
|
220
220
|
project.configPath,
|
|
221
221
|
];
|
|
222
222
|
const mapped = roots.filter((r) => !!r).map(rel).filter((r) => !!r);
|
|
223
|
-
|
|
223
|
+
// Clones under the repos root are other people's history, never this
|
|
224
|
+
// repo's: excluded like run artifacts, whatever the configured roots say.
|
|
225
|
+
const excluded = [getOutputDir(), reposRootFor(project)].filter((r) => !!r);
|
|
224
226
|
return {
|
|
225
227
|
include: [...new Set(mapped.filter((r) => r !== "."))],
|
|
226
|
-
exclude:
|
|
228
|
+
exclude: excluded.map(rel).filter((r) => !!r && r !== "."),
|
|
227
229
|
wholeRepo: mapped.includes("."),
|
|
228
230
|
};
|
|
229
231
|
}
|
|
@@ -242,7 +244,7 @@ function reasonFor(file, code, scope) {
|
|
|
242
244
|
if (DENY.some((re) => re.test(file)))
|
|
243
245
|
return "never synced (secret or key)";
|
|
244
246
|
if (scope.exclude.some((e) => within(file, e)))
|
|
245
|
-
return "run artifacts, not synced";
|
|
247
|
+
return "run artifacts or repositories, not synced";
|
|
246
248
|
if (!scope.include.some((r) => within(file, r)))
|
|
247
249
|
return "outside the workspace paths";
|
|
248
250
|
if (/[RC]/.test(code))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentSummary } from "./agents.js";
|
|
1
2
|
/** Durable per-project record. The root is the key; everything else derives from it. */
|
|
2
3
|
export interface ProjectRecord {
|
|
3
4
|
root: string;
|
|
@@ -6,6 +7,8 @@ export interface ProjectRecord {
|
|
|
6
7
|
/** Set on clean shutdown; older than lastStarted means the last run died. */
|
|
7
8
|
lastStopped?: string;
|
|
8
9
|
startCount: number;
|
|
10
|
+
/** Active agents, as last seen by the instance serving this root. */
|
|
11
|
+
agents?: AgentSummary[];
|
|
9
12
|
}
|
|
10
13
|
/** A verified running instance, shaped like a switcher entry. */
|
|
11
14
|
export interface DiscoveredInstance {
|
|
@@ -39,12 +42,21 @@ export interface WorkspaceEntry {
|
|
|
39
42
|
export declare const tildify: (p: string) => string;
|
|
40
43
|
/** Write this instance's registry file (call once the server listens). */
|
|
41
44
|
export declare function registerInstance(port: number, root: string): Promise<void>;
|
|
45
|
+
/** Where this instance answers, as the switcher links it (null before listen). */
|
|
46
|
+
export declare const currentInstanceUrl: () => string | null;
|
|
42
47
|
/** Remove this instance's registry file. Sync so exit handlers can call it. */
|
|
43
48
|
export declare function unregisterInstance(): void;
|
|
44
49
|
/** All other running instances, verified live. Cached briefly — /api/meta is polled. */
|
|
45
50
|
export declare function discoverInstances(): Promise<DiscoveredInstance[]>;
|
|
46
51
|
/** Upsert the project record for a root (call on every inspector start). */
|
|
47
52
|
export declare function registerProject(root: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Write the roster into the project's record. Called on every roster read
|
|
55
|
+
* (the UI polls it, routines tick it), so a record only changes when the
|
|
56
|
+
* roster did. Roots with no record (a CLI run in a project that never
|
|
57
|
+
* started the inspector) are left alone.
|
|
58
|
+
*/
|
|
59
|
+
export declare function syncProjectAgents(root: string, agents: AgentSummary[]): Promise<void>;
|
|
48
60
|
/** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
|
|
49
61
|
export declare function markProjectStopped(): void;
|
|
50
62
|
/** All known projects, most recently started first. */
|
|
@@ -20,6 +20,9 @@ import { selfCommand } from "./self-command.js";
|
|
|
20
20
|
* the root's kraftwerk.yml at read time, so it is always current. A
|
|
21
21
|
* project that is not running can be started again from the switcher or
|
|
22
22
|
* `kraftwerk projects start` — that is what makes a killed UI findable.
|
|
23
|
+
* The record also carries the project's agent roster (written whenever the
|
|
24
|
+
* instance reads it), so the ⌘K palette can list every workspace's agents
|
|
25
|
+
* from a handful of small files without probing anything.
|
|
23
26
|
*/
|
|
24
27
|
const HOME = path.join(os.homedir(), ".kraftwerk");
|
|
25
28
|
const INSTANCES_DIR = path.join(HOME, "instances");
|
|
@@ -47,6 +50,8 @@ export async function registerInstance(port, root) {
|
|
|
47
50
|
}
|
|
48
51
|
catch { } // best-effort — without it discovery just won't see us
|
|
49
52
|
}
|
|
53
|
+
/** Where this instance answers, as the switcher links it (null before listen). */
|
|
54
|
+
export const currentInstanceUrl = () => (selfPort ? `http://localhost:${selfPort}` : null);
|
|
50
55
|
/** Remove this instance's registry file. Sync so exit handlers can call it. */
|
|
51
56
|
export function unregisterInstance() {
|
|
52
57
|
try {
|
|
@@ -137,6 +142,7 @@ async function readProject(file) {
|
|
|
137
142
|
lastStarted: rec.lastStarted ?? rec.firstSeen ?? "",
|
|
138
143
|
lastStopped: rec.lastStopped,
|
|
139
144
|
startCount: rec.startCount ?? 1,
|
|
145
|
+
...(Array.isArray(rec.agents) ? { agents: rec.agents } : {}),
|
|
140
146
|
};
|
|
141
147
|
}
|
|
142
148
|
catch {
|
|
@@ -155,11 +161,33 @@ export async function registerProject(root) {
|
|
|
155
161
|
firstSeen: prev?.firstSeen || now,
|
|
156
162
|
lastStarted: now,
|
|
157
163
|
startCount: (prev?.startCount ?? 0) + 1,
|
|
164
|
+
...(prev?.agents ? { agents: prev.agents } : {}),
|
|
158
165
|
};
|
|
159
166
|
await fs.writeFile(projectFile(abs), JSON.stringify(rec, null, 2));
|
|
160
167
|
}
|
|
161
168
|
catch { } // best-effort, like the instance file
|
|
162
169
|
}
|
|
170
|
+
let syncedAgents = "";
|
|
171
|
+
/**
|
|
172
|
+
* Write the roster into the project's record. Called on every roster read
|
|
173
|
+
* (the UI polls it, routines tick it), so a record only changes when the
|
|
174
|
+
* roster did. Roots with no record (a CLI run in a project that never
|
|
175
|
+
* started the inspector) are left alone.
|
|
176
|
+
*/
|
|
177
|
+
export async function syncProjectAgents(root, agents) {
|
|
178
|
+
const key = `${root}\n${JSON.stringify(agents)}`;
|
|
179
|
+
if (key === syncedAgents)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
const file = projectFile(root);
|
|
183
|
+
const rec = await readProject(file);
|
|
184
|
+
if (!rec)
|
|
185
|
+
return;
|
|
186
|
+
await fs.writeFile(file, JSON.stringify({ ...rec, agents }, null, 2));
|
|
187
|
+
syncedAgents = key;
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
163
191
|
/** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
|
|
164
192
|
export function markProjectStopped() {
|
|
165
193
|
if (!selfRoot)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type Project } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Repositories: the git clones an agent works on, kept under one root
|
|
4
|
+
* inside the project (`repos.root` in kraftwerk.yml, default repos/). The
|
|
5
|
+
* folder is the registry: whatever has a .git directory directly under the
|
|
6
|
+
* root is a repository, whoever put it there — this module, the CLI, or an
|
|
7
|
+
* agent running plain `git clone`. Everything shown about a clone is read
|
|
8
|
+
* from git on request, so nothing here can go stale.
|
|
9
|
+
*
|
|
10
|
+
* The root lives inside the project on purpose: chat sessions run in the
|
|
11
|
+
* project root, so every harness reaches the clones without extra grants.
|
|
12
|
+
* It must stay out of the workspace git: `kraftwerk init` ignores it, and
|
|
13
|
+
* the workspace sync never stages it.
|
|
14
|
+
*
|
|
15
|
+
* Like git.ts, every call passes an argument array (nothing reaches a
|
|
16
|
+
* shell), never prompts, and times out — a clone from a web UI must fail
|
|
17
|
+
* loudly, not park on a credential prompt nobody sees.
|
|
18
|
+
*/
|
|
19
|
+
export interface RepoInfo {
|
|
20
|
+
/** Folder name under the root; the id everywhere. */
|
|
21
|
+
slug: string;
|
|
22
|
+
/** Absolute path of the clone. */
|
|
23
|
+
path: string;
|
|
24
|
+
/** origin url, absent when the clone has no origin. */
|
|
25
|
+
url?: string;
|
|
26
|
+
/** Checked-out branch, or "HEAD" when detached. */
|
|
27
|
+
branch?: string;
|
|
28
|
+
/** Short hash of HEAD. */
|
|
29
|
+
head?: string;
|
|
30
|
+
/** Subject of the HEAD commit. */
|
|
31
|
+
subject?: string;
|
|
32
|
+
/** ISO timestamp of the HEAD commit. */
|
|
33
|
+
committedAt?: string;
|
|
34
|
+
/** Modified, added, deleted or untracked paths (porcelain count). */
|
|
35
|
+
dirty: number;
|
|
36
|
+
/** Commits not on the upstream, or not on any remote when the branch tracks nothing. Absent when git could not tell. */
|
|
37
|
+
ahead?: number;
|
|
38
|
+
/** Commits on the upstream not here. Absent when the branch tracks nothing. */
|
|
39
|
+
behind?: number;
|
|
40
|
+
/** Set when git could not be read for this folder. */
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ReposView {
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
root?: string;
|
|
46
|
+
repos: RepoInfo[];
|
|
47
|
+
/** Set when the feature is on but unusable, or off. */
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface AddRepoInput {
|
|
51
|
+
url: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
branch?: string;
|
|
54
|
+
/** Shallow clone with this many commits of history (`git clone --depth`). */
|
|
55
|
+
depth?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A clone is the one call here that can legitimately take long: a large
|
|
59
|
+
* repository on a slow link is minutes, not seconds. Prompts are already
|
|
60
|
+
* disabled (git.ts), so a long timeout only ever cuts a transfer that is
|
|
61
|
+
* still running, and the partial clone is deleted when it does.
|
|
62
|
+
*/
|
|
63
|
+
export declare const CLONE_TIMEOUT: number;
|
|
64
|
+
export declare function safeRepoSlug(slug: string): string;
|
|
65
|
+
/** "https://github.com/org/repo.git" → "repo"; "git@host:org/repo" → "repo". */
|
|
66
|
+
export declare function slugFromUrl(url: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The url git gets. `github:org/repo` is the same shorthand `--from`
|
|
69
|
+
* accepts. Anything starting with a dash would be read as an option, and
|
|
70
|
+
* empty is empty.
|
|
71
|
+
*/
|
|
72
|
+
export declare function cloneUrl(input: string): string;
|
|
73
|
+
/** Resolve the project and its repos block. `off` when the feature is not on. */
|
|
74
|
+
export declare function openRepos(): Promise<{
|
|
75
|
+
project?: Project;
|
|
76
|
+
root?: string;
|
|
77
|
+
off?: boolean;
|
|
78
|
+
error?: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** Every clone under the root, alphabetically. Folders without .git are skipped. */
|
|
81
|
+
export declare function listRepos(): Promise<ReposView>;
|
|
82
|
+
/**
|
|
83
|
+
* Clone into <root>/<slug>. The folder is claimed with a plain mkdir first:
|
|
84
|
+
* it fails when anything is there already, whatever it is, and two adds of
|
|
85
|
+
* the same name racing each other cannot both win. Only what this call
|
|
86
|
+
* created is deleted when the clone fails.
|
|
87
|
+
*/
|
|
88
|
+
export declare function addRepo(input: AddRepoInput): Promise<RepoInfo>;
|
|
89
|
+
/**
|
|
90
|
+
* Fetch, then fast-forward when the clone is behind and clean. Never merges:
|
|
91
|
+
* a diverged or dirty clone is reported, and the agent (or a terminal)
|
|
92
|
+
* sorts it out inside the repo.
|
|
93
|
+
*/
|
|
94
|
+
export declare function updateRepo(slug: string): Promise<{
|
|
95
|
+
ok: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
off?: boolean;
|
|
98
|
+
repo?: RepoInfo;
|
|
99
|
+
}>;
|
|
100
|
+
/**
|
|
101
|
+
* Delete the clone. Refuses while it holds unpushed or uncommitted work, or
|
|
102
|
+
* while git cannot tell, unless forced: the clone may be the only copy.
|
|
103
|
+
*/
|
|
104
|
+
export declare function removeRepo(slug: string, force?: boolean): Promise<{
|
|
105
|
+
ok: boolean;
|
|
106
|
+
error?: string;
|
|
107
|
+
off?: boolean;
|
|
108
|
+
conflict?: boolean;
|
|
109
|
+
}>;
|
|
110
|
+
/**
|
|
111
|
+
* Make sure the root exists and is ignored by the workspace git: a clone
|
|
112
|
+
* inside a tracked tree shows up as an untracked gitlink for everyone.
|
|
113
|
+
* Best effort — a missing .gitignore is created, one that already covers
|
|
114
|
+
* the root is left alone. Runs before every clone as well as when the
|
|
115
|
+
* feature is switched on, so a root enabled by editing kraftwerk.yml is
|
|
116
|
+
* ignored by the time the first clone lands.
|
|
117
|
+
*/
|
|
118
|
+
export declare function ensureReposRoot(): Promise<void>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gitignoreHas, ignoreEntryFor, reposRootFor, resolveProject } from "../config.js";
|
|
4
|
+
import { getProjectRoot } from "./context.js";
|
|
5
|
+
import { git, gitNet, sshCommandFor } from "./git.js";
|
|
6
|
+
/**
|
|
7
|
+
* A clone is the one call here that can legitimately take long: a large
|
|
8
|
+
* repository on a slow link is minutes, not seconds. Prompts are already
|
|
9
|
+
* disabled (git.ts), so a long timeout only ever cuts a transfer that is
|
|
10
|
+
* still running, and the partial clone is deleted when it does.
|
|
11
|
+
*/
|
|
12
|
+
export const CLONE_TIMEOUT = 30 * 60_000;
|
|
13
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
14
|
+
export function safeRepoSlug(slug) {
|
|
15
|
+
if (!SLUG_RE.test(slug) || slug === "." || slug === "..")
|
|
16
|
+
throw new Error(`invalid repository name "${slug}"`);
|
|
17
|
+
return slug;
|
|
18
|
+
}
|
|
19
|
+
/** "https://github.com/org/repo.git" → "repo"; "git@host:org/repo" → "repo". */
|
|
20
|
+
export function slugFromUrl(url) {
|
|
21
|
+
const last = url.replace(/[/:]+$/, "").split(/[/:]/).pop() ?? "";
|
|
22
|
+
const slug = last.replace(/\.git$/, "");
|
|
23
|
+
return safeRepoSlug(slug);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The url git gets. `github:org/repo` is the same shorthand `--from`
|
|
27
|
+
* accepts. Anything starting with a dash would be read as an option, and
|
|
28
|
+
* empty is empty.
|
|
29
|
+
*/
|
|
30
|
+
export function cloneUrl(input) {
|
|
31
|
+
const url = input.trim();
|
|
32
|
+
if (!url)
|
|
33
|
+
throw new Error("url is required");
|
|
34
|
+
if (url.startsWith("-"))
|
|
35
|
+
throw new Error(`invalid url "${url}"`);
|
|
36
|
+
const gh = /^github:([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(url);
|
|
37
|
+
if (gh)
|
|
38
|
+
return `https://github.com/${gh[1]}.git`;
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve the project and its repos block. `off` when the feature is not on. */
|
|
42
|
+
export async function openRepos() {
|
|
43
|
+
let project;
|
|
44
|
+
try {
|
|
45
|
+
project = await resolveProject(getProjectRoot());
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return { error: err.message };
|
|
49
|
+
}
|
|
50
|
+
const root = reposRootFor(project);
|
|
51
|
+
if (!root)
|
|
52
|
+
return { off: true, error: "repositories are off (enable them in settings or add `repos:` to kraftwerk.yml)" };
|
|
53
|
+
return { project, root };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Everything about a clone in three git calls. `status --porcelain=v2
|
|
57
|
+
* --branch` carries the branch, its upstream, ahead/behind and the change
|
|
58
|
+
* list in one spawn; `log` the head; `remote` the origin. Only a branch
|
|
59
|
+
* without upstream costs one more call.
|
|
60
|
+
*/
|
|
61
|
+
async function inspect(root, slug) {
|
|
62
|
+
const dir = path.join(root, slug);
|
|
63
|
+
const info = { slug, path: dir, dirty: 0 };
|
|
64
|
+
const [status, log, origin] = await Promise.all([
|
|
65
|
+
git(["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], dir),
|
|
66
|
+
git(["log", "-1", "--format=%h%x00%s%x00%cI"], dir),
|
|
67
|
+
git(["remote", "get-url", "origin"], dir),
|
|
68
|
+
]);
|
|
69
|
+
if (!status.ok)
|
|
70
|
+
return { ...info, error: status.stderr || "not a git repository" };
|
|
71
|
+
if (origin.ok)
|
|
72
|
+
info.url = origin.stdout.trim();
|
|
73
|
+
if (log.ok) {
|
|
74
|
+
const [head, subject, committedAt] = log.stdout.trim().split("\0");
|
|
75
|
+
info.head = head;
|
|
76
|
+
info.subject = subject;
|
|
77
|
+
info.committedAt = committedAt;
|
|
78
|
+
}
|
|
79
|
+
let hasUpstream = false;
|
|
80
|
+
for (const line of status.stdout.split("\n")) {
|
|
81
|
+
if (!line.trim())
|
|
82
|
+
continue;
|
|
83
|
+
if (!line.startsWith("# ")) {
|
|
84
|
+
info.dirty++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const [key, ...rest] = line.slice(2).split(" ");
|
|
88
|
+
const value = rest.join(" ");
|
|
89
|
+
if (key === "branch.head")
|
|
90
|
+
info.branch = value === "(detached)" ? "HEAD" : value;
|
|
91
|
+
else if (key === "branch.upstream")
|
|
92
|
+
hasUpstream = true;
|
|
93
|
+
else if (key === "branch.ab") {
|
|
94
|
+
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
|
95
|
+
if (m) {
|
|
96
|
+
info.ahead = Number(m[1]);
|
|
97
|
+
info.behind = Number(m[2]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!hasUpstream || info.ahead === undefined) {
|
|
102
|
+
// No upstream (a branch made locally, a detached HEAD): count what no
|
|
103
|
+
// remote has, so the remove guard still knows about unpushed work.
|
|
104
|
+
const local = await git(["rev-list", "--count", "HEAD", "--not", "--remotes"], dir);
|
|
105
|
+
if (local.ok && /^\d+$/.test(local.stdout.trim()))
|
|
106
|
+
info.ahead = Number(local.stdout.trim());
|
|
107
|
+
}
|
|
108
|
+
return info;
|
|
109
|
+
}
|
|
110
|
+
/** Every clone under the root, alphabetically. Folders without .git are skipped. */
|
|
111
|
+
export async function listRepos() {
|
|
112
|
+
const opened = await openRepos();
|
|
113
|
+
if (!opened.root)
|
|
114
|
+
return { enabled: false, repos: [], error: opened.error };
|
|
115
|
+
const root = opened.root;
|
|
116
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
117
|
+
const slugs = [];
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
if (!e.isDirectory() || !SLUG_RE.test(e.name))
|
|
120
|
+
continue;
|
|
121
|
+
if (await fs.stat(path.join(root, e.name, ".git")).catch(() => null))
|
|
122
|
+
slugs.push(e.name);
|
|
123
|
+
}
|
|
124
|
+
slugs.sort((a, b) => a.localeCompare(b));
|
|
125
|
+
const repos = await Promise.all(slugs.map((s) => inspect(root, s)));
|
|
126
|
+
return { enabled: true, root, repos };
|
|
127
|
+
}
|
|
128
|
+
const cloneError = (r) => (r.stderr || r.stdout || "git clone failed").split("\n").filter((l) => l.trim()).slice(-3).join(" ");
|
|
129
|
+
/**
|
|
130
|
+
* Clone into <root>/<slug>. The folder is claimed with a plain mkdir first:
|
|
131
|
+
* it fails when anything is there already, whatever it is, and two adds of
|
|
132
|
+
* the same name racing each other cannot both win. Only what this call
|
|
133
|
+
* created is deleted when the clone fails.
|
|
134
|
+
*/
|
|
135
|
+
export async function addRepo(input) {
|
|
136
|
+
const opened = await openRepos();
|
|
137
|
+
if (!opened.root || !opened.project)
|
|
138
|
+
throw new Error(opened.error ?? "repositories are off");
|
|
139
|
+
const url = cloneUrl(input.url);
|
|
140
|
+
const slug = input.name?.trim() ? safeRepoSlug(input.name.trim()) : slugFromUrl(url);
|
|
141
|
+
const branch = input.branch?.trim();
|
|
142
|
+
if (branch && (branch.startsWith("-") || /\s/.test(branch)))
|
|
143
|
+
throw new Error(`invalid branch "${branch}"`);
|
|
144
|
+
if (input.depth !== undefined && (!Number.isInteger(input.depth) || input.depth < 1))
|
|
145
|
+
throw new Error(`invalid depth "${input.depth}"`);
|
|
146
|
+
const dest = path.join(opened.root, slug);
|
|
147
|
+
await prepareRoot(opened.project, opened.root);
|
|
148
|
+
try {
|
|
149
|
+
await fs.mkdir(dest);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err.code === "EEXIST")
|
|
153
|
+
throw new Error(`"${slug}" already exists under ${opened.root}`);
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
const args = [
|
|
157
|
+
"-c", "protocol.ext.allow=never", "clone",
|
|
158
|
+
...(branch ? ["--branch", branch] : []),
|
|
159
|
+
...(input.depth ? ["--depth", String(input.depth)] : []),
|
|
160
|
+
"--", url, dest,
|
|
161
|
+
];
|
|
162
|
+
const r = await git(args, opened.root, CLONE_TIMEOUT, { GIT_SSH_COMMAND: await sshCommandFor(opened.project.root) });
|
|
163
|
+
if (!r.ok) {
|
|
164
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
165
|
+
throw new Error(cloneError(r));
|
|
166
|
+
}
|
|
167
|
+
return inspect(opened.root, slug);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fetch, then fast-forward when the clone is behind and clean. Never merges:
|
|
171
|
+
* a diverged or dirty clone is reported, and the agent (or a terminal)
|
|
172
|
+
* sorts it out inside the repo.
|
|
173
|
+
*/
|
|
174
|
+
export async function updateRepo(slug) {
|
|
175
|
+
safeRepoSlug(slug);
|
|
176
|
+
const opened = await openRepos();
|
|
177
|
+
if (!opened.root)
|
|
178
|
+
return { ok: false, off: true, error: opened.error };
|
|
179
|
+
const dir = path.join(opened.root, slug);
|
|
180
|
+
if (!(await fs.stat(path.join(dir, ".git")).catch(() => null)))
|
|
181
|
+
return { ok: false, error: `no repository "${slug}"` };
|
|
182
|
+
const fetched = await gitNet(["fetch", "--prune"], dir);
|
|
183
|
+
if (!fetched.ok)
|
|
184
|
+
return { ok: false, error: fetched.stderr || "git fetch failed", repo: await inspect(opened.root, slug) };
|
|
185
|
+
let repo = await inspect(opened.root, slug);
|
|
186
|
+
if (repo.behind && !repo.ahead && repo.dirty === 0) {
|
|
187
|
+
const pulled = await git(["merge", "--ff-only", "@{upstream}"], dir);
|
|
188
|
+
if (!pulled.ok)
|
|
189
|
+
return { ok: false, error: pulled.stderr || "fast-forward failed", repo };
|
|
190
|
+
repo = await inspect(opened.root, slug);
|
|
191
|
+
}
|
|
192
|
+
else if (repo.behind && (repo.ahead || repo.dirty)) {
|
|
193
|
+
return { ok: true, error: repo.ahead ? "diverged from upstream, not fast-forwarded" : "local changes, not fast-forwarded", repo };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, repo };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Delete the clone. Refuses while it holds unpushed or uncommitted work, or
|
|
199
|
+
* while git cannot tell, unless forced: the clone may be the only copy.
|
|
200
|
+
*/
|
|
201
|
+
export async function removeRepo(slug, force = false) {
|
|
202
|
+
safeRepoSlug(slug);
|
|
203
|
+
const opened = await openRepos();
|
|
204
|
+
if (!opened.root)
|
|
205
|
+
return { ok: false, off: true, error: opened.error };
|
|
206
|
+
const dir = path.join(opened.root, slug);
|
|
207
|
+
if (!(await fs.stat(path.join(dir, ".git")).catch(() => null)))
|
|
208
|
+
return { ok: false, error: `no repository "${slug}"` };
|
|
209
|
+
if (!force) {
|
|
210
|
+
const repo = await inspect(opened.root, slug);
|
|
211
|
+
if (repo.error)
|
|
212
|
+
return { ok: false, conflict: true, error: `"${slug}" cannot be read (${repo.error}); remove it with --force` };
|
|
213
|
+
if (repo.dirty)
|
|
214
|
+
return { ok: false, conflict: true, error: `"${slug}" has ${repo.dirty} uncommitted change(s)` };
|
|
215
|
+
if (repo.ahead === undefined)
|
|
216
|
+
return { ok: false, conflict: true, error: `"${slug}": cannot tell whether it has unpushed commits; remove it with --force` };
|
|
217
|
+
if (repo.ahead)
|
|
218
|
+
return { ok: false, conflict: true, error: `"${slug}" has ${repo.ahead} unpushed commit(s)` };
|
|
219
|
+
}
|
|
220
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Make sure the root exists and is ignored by the workspace git: a clone
|
|
225
|
+
* inside a tracked tree shows up as an untracked gitlink for everyone.
|
|
226
|
+
* Best effort — a missing .gitignore is created, one that already covers
|
|
227
|
+
* the root is left alone. Runs before every clone as well as when the
|
|
228
|
+
* feature is switched on, so a root enabled by editing kraftwerk.yml is
|
|
229
|
+
* ignored by the time the first clone lands.
|
|
230
|
+
*/
|
|
231
|
+
export async function ensureReposRoot() {
|
|
232
|
+
const project = await resolveProject(getProjectRoot());
|
|
233
|
+
const root = reposRootFor(project);
|
|
234
|
+
if (!root)
|
|
235
|
+
return;
|
|
236
|
+
await prepareRoot(project, root);
|
|
237
|
+
}
|
|
238
|
+
async function prepareRoot(project, root) {
|
|
239
|
+
await fs.mkdir(root, { recursive: true });
|
|
240
|
+
const entry = ignoreEntryFor(project.root, root);
|
|
241
|
+
if (!entry)
|
|
242
|
+
return;
|
|
243
|
+
const ignorePath = path.join(project.root, ".gitignore");
|
|
244
|
+
const existing = await fs.readFile(ignorePath, "utf8").catch(() => null);
|
|
245
|
+
if (existing !== null && gitignoreHas(existing, entry))
|
|
246
|
+
return;
|
|
247
|
+
const line = `${entry}/\n`;
|
|
248
|
+
await fs.writeFile(ignorePath, existing === null ? line : `${existing}${existing.endsWith("\n") ? "" : "\n"}${line}`).catch(() => { });
|
|
249
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Routines: per-
|
|
3
|
-
* weekday at 9". Definitions live next to the
|
|
2
|
+
* Routines: per-agent scheduled prompts — "message your agent every
|
|
3
|
+
* weekday at 9". Definitions live next to the agent (git-tracked):
|
|
4
4
|
*
|
|
5
5
|
* agents/<slug>/routines.yml # list of { id, name, schedule, prompt, enabled }
|
|
6
6
|
*
|
|
7
7
|
* Runtime state (last run / last session / errors) is run-state, not
|
|
8
8
|
* config, so it lives in <output>/routines-state.json. The scheduler is a
|
|
9
9
|
* plain interval inside the inspector server: each due routine opens a new
|
|
10
|
-
* session (chat) for the
|
|
11
|
-
* the
|
|
10
|
+
* session (chat) for the agent and posts the prompt — the run shows up in
|
|
11
|
+
* the agent's sessions sidebar like any conversation. Schedules use
|
|
12
12
|
* standard 5-field cron in the server's local time.
|
|
13
13
|
*/
|
|
14
14
|
export interface Routine {
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
4
|
import { getOutputDir } from "./context.js";
|
|
5
|
-
import {
|
|
5
|
+
import { getAgent, listAgents, safeAgentSlug, agentsRoot } from "./agents.js";
|
|
6
6
|
import { createChat, postMessage } from "./chat/sessions.js";
|
|
7
7
|
/* ---------- cron ---------- */
|
|
8
8
|
const ALIASES = {
|
|
@@ -86,7 +86,7 @@ export function nextRun(schedule, from = new Date()) {
|
|
|
86
86
|
}
|
|
87
87
|
/* ---------- definition CRUD (agents/<slug>/routines.yml) ---------- */
|
|
88
88
|
async function routinesFile(slug) {
|
|
89
|
-
return path.join(await
|
|
89
|
+
return path.join(await agentsRoot(), safeAgentSlug(slug), "routines.yml");
|
|
90
90
|
}
|
|
91
91
|
function normalizeRoutine(raw) {
|
|
92
92
|
const id = String(raw.id ?? "").trim();
|
|
@@ -179,14 +179,14 @@ export async function routineStatuses(slug) {
|
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
/* ---------- firing ---------- */
|
|
182
|
-
/** Open a fresh session for the
|
|
182
|
+
/** Open a fresh session for the agent and post the routine's prompt. */
|
|
183
183
|
async function fireRoutine(slug, routine) {
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
186
|
-
throw new Error(`
|
|
184
|
+
const def = await getAgent(slug);
|
|
185
|
+
if (!def)
|
|
186
|
+
throw new Error(`agent "${slug}" not found`);
|
|
187
187
|
const meta = await createChat({
|
|
188
|
-
agent:
|
|
189
|
-
scope: { kind: "
|
|
188
|
+
agent: def.harness,
|
|
189
|
+
scope: { kind: "agent", slug: slug, routine: routine.id },
|
|
190
190
|
title: `⏰ ${routine.name}`,
|
|
191
191
|
});
|
|
192
192
|
const { error } = await postMessage(meta.id, routine.prompt);
|
|
@@ -219,16 +219,16 @@ async function tick() {
|
|
|
219
219
|
const now = new Date();
|
|
220
220
|
const p = (n) => String(n).padStart(2, "0");
|
|
221
221
|
const minuteKey = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
222
|
-
let
|
|
222
|
+
let agents;
|
|
223
223
|
try {
|
|
224
|
-
|
|
224
|
+
agents = await listAgents();
|
|
225
225
|
}
|
|
226
226
|
catch {
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
229
229
|
const state = await readState();
|
|
230
230
|
let dirty = false;
|
|
231
|
-
for (const m of
|
|
231
|
+
for (const m of agents) {
|
|
232
232
|
for (const r of await listRoutines(m.slug).catch(() => [])) {
|
|
233
233
|
if (!r.enabled)
|
|
234
234
|
continue;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type AgentSummary } from "./agents.js";
|
|
2
|
+
/**
|
|
3
|
+
* The ⌘K palette: every active agent in every workspace this machine
|
|
4
|
+
* knows, so a user can jump to one without switching workspaces first.
|
|
5
|
+
* This instance reads its own roster from disk; the others come from the
|
|
6
|
+
* project registry, where each instance records its roster whenever it
|
|
7
|
+
* reads it (see instances.ts). Stopped workspaces are listed too — the
|
|
8
|
+
* palette starts them on the way to the agent.
|
|
9
|
+
*/
|
|
10
|
+
export interface WorkspaceAgents {
|
|
11
|
+
name: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
/** Where the workspace's UI lives — hits link to `${url}/#/agents/<slug>`. */
|
|
14
|
+
url: string;
|
|
15
|
+
/** Project root (what /api/projects/start takes for a stopped workspace). */
|
|
16
|
+
root?: string;
|
|
17
|
+
/** The instance answering this request: hits navigate in place. */
|
|
18
|
+
current: boolean;
|
|
19
|
+
live: boolean;
|
|
20
|
+
agents: AgentSummary[];
|
|
21
|
+
}
|
|
22
|
+
export interface AgentSearch {
|
|
23
|
+
workspaces: WorkspaceAgents[];
|
|
24
|
+
}
|
|
25
|
+
export declare function searchAgents(): Promise<AgentSearch>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveProject } from "../config.js";
|
|
3
|
+
import { getProjectRoot } from "./context.js";
|
|
4
|
+
import { listAgents, toSummary } from "./agents.js";
|
|
5
|
+
import { currentInstanceUrl, discoverWorkspaces, listProjects } from "./instances.js";
|
|
6
|
+
export async function searchAgents() {
|
|
7
|
+
const project = await resolveProject(getProjectRoot()).catch(() => null);
|
|
8
|
+
const self = {
|
|
9
|
+
name: project?.config.name ?? path.basename(project?.root ?? getProjectRoot()),
|
|
10
|
+
icon: project?.config.icon || undefined,
|
|
11
|
+
url: currentInstanceUrl() ?? "",
|
|
12
|
+
root: project?.root ?? getProjectRoot(),
|
|
13
|
+
current: true,
|
|
14
|
+
live: true,
|
|
15
|
+
agents: (await listAgents().catch(() => [])).filter((a) => !a.archived).map(toSummary),
|
|
16
|
+
};
|
|
17
|
+
const [workspaces, projects] = await Promise.all([discoverWorkspaces(), listProjects()]);
|
|
18
|
+
const rosters = new Map(projects.map((p) => [p.root, p.agents ?? []]));
|
|
19
|
+
const others = workspaces
|
|
20
|
+
.filter((w) => w.root && w.exists !== false)
|
|
21
|
+
.map((w) => ({
|
|
22
|
+
name: w.name,
|
|
23
|
+
icon: w.icon,
|
|
24
|
+
url: w.url,
|
|
25
|
+
root: w.root,
|
|
26
|
+
current: false,
|
|
27
|
+
live: w.live,
|
|
28
|
+
agents: rosters.get(w.root) ?? [],
|
|
29
|
+
}));
|
|
30
|
+
return { workspaces: [self, ...others] };
|
|
31
|
+
}
|