@netnodeag/kraftwerk 0.39.0 → 0.40.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 +29 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -0
- package/dist/inspector/chat/acp.js +23 -0
- package/dist/inspector/chat/backend.d.ts +6 -0
- package/dist/inspector/chat/permissions.d.ts +36 -0
- package/dist/inspector/chat/permissions.js +42 -0
- package/dist/inspector/chat/sessions.d.ts +17 -0
- package/dist/inspector/chat/sessions.js +149 -26
- package/dist/inspector/diagnose.d.ts +32 -0
- package/dist/inspector/diagnose.js +80 -0
- package/dist/inspector/git.js +17 -4
- package/dist/inspector/notifications.d.ts +69 -0
- package/dist/inspector/notifications.js +89 -0
- package/dist/inspector/routines.d.ts +2 -0
- package/dist/inspector/routines.js +15 -1
- package/dist/inspector/runner.js +20 -1
- package/dist/inspector/server.js +50 -1
- package/dist/inspector/settings.js +13 -2
- package/dist/inspector/vibeables.d.ts +6 -5
- package/dist/inspector/vibeables.js +131 -12
- package/inspector/dist/assets/{dist-BECiZEqH.js → dist-AXonJXol.js} +1 -1
- package/inspector/dist/assets/{dist-BCM6ehYa.js → dist-BJLTdJRF.js} +1 -1
- package/inspector/dist/assets/{dist-3Nzkrylv.js → dist-Bv3fOisq.js} +1 -1
- package/inspector/dist/assets/{dist-Dd-u7fXY.js → dist-Cex4_N6v.js} +1 -1
- package/inspector/dist/assets/{dist-DXIk6Ebu.js → dist-D9-MFS0x.js} +1 -1
- package/inspector/dist/assets/{dist-D3tgdU3w.js → dist-DdbyO9w9.js} +1 -1
- package/inspector/dist/assets/{dist-CI-2-8Tz.js → dist-Dyv2EX0S.js} +1 -1
- package/inspector/dist/assets/{dist-nc_mc1w0.js → dist-HuQTcyVK.js} +1 -1
- package/inspector/dist/assets/dist-UTSke4yL.js +1 -0
- package/inspector/dist/assets/{dist-DwyxsFyN.js → dist-XL4xXfDo.js} +1 -1
- package/inspector/dist/assets/{dist-DTW4FhJ_.js → dist-uQIhtGKj.js} +1 -1
- package/inspector/dist/assets/{editor-6yCJBeDg.js → editor--4qv8VP5.js} +3 -3
- package/inspector/dist/assets/index-BSqc9eg2.js +218 -0
- package/inspector/dist/assets/index-E1E7-_NW.css +1 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-BbhR2mEU.js +0 -1
- package/inspector/dist/assets/index-Dom87GNV.css +0 -1
- package/inspector/dist/assets/index-DurTvcNI.js +0 -218
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getOutputDir } from "./context.js";
|
|
3
|
+
import { getAgent } from "./agents.js";
|
|
4
|
+
import { getRun } from "./runs.js";
|
|
5
|
+
import { listRoutines } from "./routines.js";
|
|
6
|
+
import { createChat, getChat, postMessage } from "./chat/sessions.js";
|
|
7
|
+
import { chatHref, getNotification, markNotificationsRead } from "./notifications.js";
|
|
8
|
+
const FIX_INSTRUCTIONS = `Work in this order:\n` +
|
|
9
|
+
`1. Find the root cause. Read the evidence before guessing, and say in plain words what went wrong and where.\n` +
|
|
10
|
+
`2. Decide whether the fix belongs in the workflow definition (prompts, steps, gates, agent configs), in the project, ` +
|
|
11
|
+
`or outside (credentials, network, a missing tool). Say which.\n` +
|
|
12
|
+
`3. If the fix is in the workflow or project files, apply it and show the diff. If it is outside, say exactly what the user must do.\n` +
|
|
13
|
+
`4. End with how to verify: the command to re-run, and what a good result looks like.`;
|
|
14
|
+
function runPrompt(run) {
|
|
15
|
+
const phases = run.phases
|
|
16
|
+
.map((p) => `- ${p.phase} [${p.status}]${p.summary ? `: ${p.summary}` : ""}`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
const failedPhase = run.phases.find((p) => p.status === "failed" || p.status === "blocked");
|
|
19
|
+
return (`Workflow run ${run.id} of "${run.workflow ?? "?"}" ended with status ${run.status}` +
|
|
20
|
+
(failedPhase ? ` in phase "${failedPhase.phase}" (${failedPhase.status})` : "") +
|
|
21
|
+
`.\nRequest: ${run.request ?? "(none)"}\n\nPhases:\n${phases || "(none)"}\n\n` +
|
|
22
|
+
`Please diagnose this run. The evidence is in this folder: trace.jsonl (every event), trigger.log ` +
|
|
23
|
+
`(process output), and the phase outputs. The workflow definition lives in the project root, which you may edit.\n\n` +
|
|
24
|
+
FIX_INSTRUCTIONS +
|
|
25
|
+
`\nRe-run with: KRAFTWERK_YES=1 npx kraftwerk run ${run.workflow ?? "<workflow>"} "${(run.request ?? "").replace(/"/g, '\\"')}"`);
|
|
26
|
+
}
|
|
27
|
+
async function routinePrompt(ref, agentExists) {
|
|
28
|
+
const routine = (await listRoutines(ref.agent).catch(() => []))
|
|
29
|
+
.find((r) => r.id === ref.routine);
|
|
30
|
+
const head = routine
|
|
31
|
+
? `The routine "${routine.name}" (schedule ${routine.schedule}) of agent "${ref.agent}" failed.\nIts prompt was:\n"""\n${routine.prompt}\n"""\n`
|
|
32
|
+
: `A routine (${ref.routine}) of agent "${ref.agent}" failed.\n`;
|
|
33
|
+
let evidence;
|
|
34
|
+
if (ref.chatId) {
|
|
35
|
+
const chat = await getChat(ref.chatId).catch(() => null);
|
|
36
|
+
const errors = chat?.events.filter((e) => e.type === "error").map((e) => e.message) ?? [];
|
|
37
|
+
evidence =
|
|
38
|
+
`The failed session's full event log is ${path.join(getOutputDir(), "chats", ref.chatId, "events.jsonl")} ` +
|
|
39
|
+
`(one JSON event per line: user_message, text, tool_call, error, turn_end).` +
|
|
40
|
+
(errors.length ? `\nErrors recorded:\n${errors.map((m) => `- ${m}`).join("\n")}` : "");
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
evidence = agentExists
|
|
44
|
+
? `The session could not even start. Check the agent's definition under agents/${ref.agent}/ (agent.yml, routines.yml) and whether its harness is installed and authenticated.`
|
|
45
|
+
: `The session could not start because agent "${ref.agent}" was not found — check agents/${ref.agent}/agent.yml and the routine in agents/${ref.agent}/routines.yml.`;
|
|
46
|
+
}
|
|
47
|
+
return `${head}\n${evidence}\n\nPlease diagnose this failure.\n\n${FIX_INSTRUCTIONS}\nRe-run from the agent page ("run" next to the routine) or wait for the next scheduled time.`;
|
|
48
|
+
}
|
|
49
|
+
/** Where and how the diagnosis chat starts, for a failure item's reference. */
|
|
50
|
+
export async function diagnosePlan(ref, title) {
|
|
51
|
+
if (ref.kind === "run") {
|
|
52
|
+
const run = await getRun(ref.runId).catch(() => null);
|
|
53
|
+
if (!run)
|
|
54
|
+
return null;
|
|
55
|
+
return { agent: "claude", scope: { kind: "run", runId: run.id }, title: `🩺 ${title}`, prompt: runPrompt(run) };
|
|
56
|
+
}
|
|
57
|
+
const def = await getAgent(ref.agent).catch(() => null);
|
|
58
|
+
const prompt = await routinePrompt(ref, !!def);
|
|
59
|
+
// A session that died mid-run: the agent itself looks at its own log.
|
|
60
|
+
// No session at all (agent or harness broken): the kraftwerk-aware chat.
|
|
61
|
+
const scope = def && ref.chatId ? { kind: "agent", slug: def.slug } : { kind: "kraftwerk" };
|
|
62
|
+
return { agent: def && ref.chatId ? def.harness : "claude", scope, title: `🩺 ${title}`, prompt };
|
|
63
|
+
}
|
|
64
|
+
/** Create the chat, send the first message, mark the item read. */
|
|
65
|
+
export async function startDiagnosis(notificationId) {
|
|
66
|
+
const item = await getNotification(notificationId);
|
|
67
|
+
if (!item)
|
|
68
|
+
return { error: "notification not found", status: 404 };
|
|
69
|
+
if (!item.diagnose)
|
|
70
|
+
return { error: "nothing to diagnose for this item", status: 409 };
|
|
71
|
+
const plan = await diagnosePlan(item.diagnose, item.title);
|
|
72
|
+
if (!plan)
|
|
73
|
+
return { error: "the run this item refers to is gone", status: 410 };
|
|
74
|
+
const meta = await createChat({ agent: plan.agent, scope: plan.scope, title: plan.title });
|
|
75
|
+
const { error } = await postMessage(meta.id, plan.prompt);
|
|
76
|
+
if (error)
|
|
77
|
+
return { error, status: 409 };
|
|
78
|
+
void markNotificationsRead([item.id]);
|
|
79
|
+
return { chatId: meta.id, href: chatHref(meta) };
|
|
80
|
+
}
|
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 { reposRootFor, resolveProject, vibeablesRootFor } from "../config.js";
|
|
4
|
+
import { reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
|
|
5
5
|
import { ENV_FILE } from "../runner/docker.js";
|
|
6
6
|
import { getOutputDir, getProjectRoot } from "./context.js";
|
|
7
7
|
/**
|
|
@@ -130,6 +130,19 @@ export async function sshCommandFor(repoRoot) {
|
|
|
130
130
|
export async function gitNet(args, repoRoot) {
|
|
131
131
|
return git(args, repoRoot, NET_TIMEOUT, { GIT_SSH_COMMAND: await sshCommandFor(repoRoot) });
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Remote/branch names from kraftwerk.yml become git positionals. Config
|
|
135
|
+
* load and the settings API already refuse dash-led names, but the file is
|
|
136
|
+
* shared through the workspace repo itself, so the call site checks again
|
|
137
|
+
* and separates options from positionals.
|
|
138
|
+
*/
|
|
139
|
+
function netArgs(cmd, ...names) {
|
|
140
|
+
for (const n of names) {
|
|
141
|
+
if (!isSafeGitName(n))
|
|
142
|
+
throw new Error(`refusing git name "${n}" (looks like an option)`);
|
|
143
|
+
}
|
|
144
|
+
return [...cmd, "--end-of-options", ...names];
|
|
145
|
+
}
|
|
133
146
|
/**
|
|
134
147
|
* Pathspecs are globs by default, so a file called `note[1].md` would not
|
|
135
148
|
* match itself. `:(literal)` turns the pattern off.
|
|
@@ -522,7 +535,7 @@ async function fetchNow() {
|
|
|
522
535
|
const { repo, error } = await openRepo();
|
|
523
536
|
if (!repo)
|
|
524
537
|
return { ok: false, error };
|
|
525
|
-
const r = await gitNet(["fetch", repo.cfg.remote
|
|
538
|
+
const r = await gitNet(netArgs(["fetch"], repo.cfg.remote), repo.repoRoot);
|
|
526
539
|
touched();
|
|
527
540
|
// Only a fetch that worked counts. Stamping the time on a failure would
|
|
528
541
|
// put "fetched 5s ago" next to ahead/behind counts that stopped moving
|
|
@@ -550,7 +563,7 @@ async function pullNow() {
|
|
|
550
563
|
const upstream = (await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)).ok;
|
|
551
564
|
const r = upstream
|
|
552
565
|
? await gitNet(["pull", "--ff-only"], repoRoot)
|
|
553
|
-
: await gitNet(["pull", "--ff-only", cfg.remote, head.branch
|
|
566
|
+
: await gitNet(netArgs(["pull", "--ff-only"], cfg.remote, head.branch), repoRoot);
|
|
554
567
|
touched();
|
|
555
568
|
if (!r.ok) {
|
|
556
569
|
// git's own non-fast-forward output is a wall of hints. Say the one
|
|
@@ -579,7 +592,7 @@ async function pushNow() {
|
|
|
579
592
|
const upstream = (await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)).ok;
|
|
580
593
|
const r = upstream
|
|
581
594
|
? await gitNet(["push"], repoRoot)
|
|
582
|
-
: await gitNet(["push", "--set-upstream", cfg.remote, `HEAD:${head.branch}`
|
|
595
|
+
: await gitNet(netArgs(["push", "--set-upstream"], cfg.remote, `HEAD:${head.branch}`), repoRoot);
|
|
583
596
|
touched();
|
|
584
597
|
if (!r.ok) {
|
|
585
598
|
lastError = r.stderr || "push failed";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ChatMeta } from "./chat/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Attention items: things that happened while nobody was looking and that
|
|
4
|
+
* the user should see — a session waiting for a permission answer, a routine
|
|
5
|
+
* that finished or failed, a workflow run that ended. The inspector shows
|
|
6
|
+
* them behind the bell in the top bar (count, list, browser notification);
|
|
7
|
+
* this module is the one writer. Items are run-state, not config, so they
|
|
8
|
+
* live in <output>/notifications.json (capped, newest first).
|
|
9
|
+
*
|
|
10
|
+
* Approval items carry a `key` so the same request is never listed twice
|
|
11
|
+
* and disappears again the moment someone answers (dismissKey).
|
|
12
|
+
*/
|
|
13
|
+
export type NotificationKind = "approval" | "routine_done" | "routine_failed" | "run_done" | "run_failed";
|
|
14
|
+
/**
|
|
15
|
+
* What a "diagnose" on a failure item should look at: a workflow run (chat
|
|
16
|
+
* in the run folder) or a routine (its agent, and the session that failed
|
|
17
|
+
* when there was one). The prompt is built when the chat starts, from the
|
|
18
|
+
* current state of the run/session.
|
|
19
|
+
*/
|
|
20
|
+
export type DiagnoseRef = {
|
|
21
|
+
kind: "run";
|
|
22
|
+
runId: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "routine";
|
|
25
|
+
agent: string;
|
|
26
|
+
routine: string;
|
|
27
|
+
chatId?: string;
|
|
28
|
+
};
|
|
29
|
+
export interface Notification {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: NotificationKind;
|
|
32
|
+
/** One line: who/what — "⏰ Morning digest needs approval". */
|
|
33
|
+
title: string;
|
|
34
|
+
/** Optional detail: the tool title, the summary's first lines, the error. */
|
|
35
|
+
body?: string;
|
|
36
|
+
/** Inspector route without the leading "#" — "/agents/<slug>/chat/<id>". */
|
|
37
|
+
href: string;
|
|
38
|
+
at: string;
|
|
39
|
+
readAt?: string;
|
|
40
|
+
/** Dedupe/dismiss handle for items that represent a live state. */
|
|
41
|
+
key?: string;
|
|
42
|
+
/** Present on failure items that a chat can investigate ("diagnose" in the bell). */
|
|
43
|
+
diagnose?: DiagnoseRef;
|
|
44
|
+
}
|
|
45
|
+
export interface NotificationsView {
|
|
46
|
+
items: Notification[];
|
|
47
|
+
unread: number;
|
|
48
|
+
}
|
|
49
|
+
/** Trim a summary/error to a short body; first paragraph, no markdown noise. */
|
|
50
|
+
export declare function clipBody(text: string | undefined): string | undefined;
|
|
51
|
+
/** Inspector route for a chat — mirrors chatHref in the UI. */
|
|
52
|
+
export declare function chatHref(meta: ChatMeta): string;
|
|
53
|
+
export declare function pushNotification(input: {
|
|
54
|
+
kind: NotificationKind;
|
|
55
|
+
title: string;
|
|
56
|
+
body?: string;
|
|
57
|
+
href: string;
|
|
58
|
+
key?: string;
|
|
59
|
+
diagnose?: DiagnoseRef;
|
|
60
|
+
}): Promise<Notification>;
|
|
61
|
+
/** Drop the item(s) for a live state that no longer exists (a permission got answered). */
|
|
62
|
+
export declare function dismissKey(key: string): Promise<void>;
|
|
63
|
+
export declare function getNotification(id: string): Promise<Notification | null>;
|
|
64
|
+
export declare function listNotifications(): Promise<NotificationsView>;
|
|
65
|
+
/** Mark the given ids (or everything) read. */
|
|
66
|
+
export declare function markNotificationsRead(ids: string[] | "all"): Promise<NotificationsView>;
|
|
67
|
+
export declare function clearNotifications(): Promise<void>;
|
|
68
|
+
/** Tests / project switches: forget the in-memory copy so the next read hits disk. */
|
|
69
|
+
export declare function resetNotificationsCache(): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { getOutputDir } from "./context.js";
|
|
5
|
+
const MAX_ITEMS = 200;
|
|
6
|
+
const BODY_MAX = 280;
|
|
7
|
+
let cache = null;
|
|
8
|
+
let writeChain = Promise.resolve();
|
|
9
|
+
const file = () => path.join(getOutputDir(), "notifications.json");
|
|
10
|
+
async function load() {
|
|
11
|
+
if (cache)
|
|
12
|
+
return cache;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(await fs.readFile(file(), "utf8"));
|
|
15
|
+
cache = Array.isArray(parsed) ? parsed : [];
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
cache = [];
|
|
19
|
+
}
|
|
20
|
+
return cache;
|
|
21
|
+
}
|
|
22
|
+
function persist(items) {
|
|
23
|
+
cache = items;
|
|
24
|
+
writeChain = writeChain
|
|
25
|
+
.then(async () => {
|
|
26
|
+
await fs.mkdir(getOutputDir(), { recursive: true });
|
|
27
|
+
await fs.writeFile(file(), JSON.stringify(items, null, 2));
|
|
28
|
+
})
|
|
29
|
+
.catch(() => { });
|
|
30
|
+
}
|
|
31
|
+
/** Trim a summary/error to a short body; first paragraph, no markdown noise. */
|
|
32
|
+
export function clipBody(text) {
|
|
33
|
+
const t = (text ?? "").replace(/\r/g, "").trim();
|
|
34
|
+
if (!t)
|
|
35
|
+
return undefined;
|
|
36
|
+
const para = t.split(/\n{2,}/)[0].replace(/\s+/g, " ").replace(/^#+\s*/, "").trim();
|
|
37
|
+
return para.length > BODY_MAX ? para.slice(0, BODY_MAX - 1).trimEnd() + "…" : para;
|
|
38
|
+
}
|
|
39
|
+
/** Inspector route for a chat — mirrors chatHref in the UI. */
|
|
40
|
+
export function chatHref(meta) {
|
|
41
|
+
return meta.scope.kind === "agent" ? `/agents/${meta.scope.slug}/chat/${meta.id}` : `/agents/chats/${meta.id}`;
|
|
42
|
+
}
|
|
43
|
+
export async function pushNotification(input) {
|
|
44
|
+
const items = await load();
|
|
45
|
+
const item = {
|
|
46
|
+
id: randomUUID(),
|
|
47
|
+
kind: input.kind,
|
|
48
|
+
title: input.title,
|
|
49
|
+
...(input.body ? { body: clipBody(input.body) } : {}),
|
|
50
|
+
href: input.href,
|
|
51
|
+
at: new Date().toISOString(),
|
|
52
|
+
...(input.key ? { key: input.key } : {}),
|
|
53
|
+
...(input.diagnose ? { diagnose: input.diagnose } : {}),
|
|
54
|
+
};
|
|
55
|
+
// Same live state again (e.g. the request re-emitted): replace, don't stack.
|
|
56
|
+
const rest = input.key ? items.filter((n) => n.key !== input.key) : items;
|
|
57
|
+
persist([item, ...rest].slice(0, MAX_ITEMS));
|
|
58
|
+
return item;
|
|
59
|
+
}
|
|
60
|
+
/** Drop the item(s) for a live state that no longer exists (a permission got answered). */
|
|
61
|
+
export async function dismissKey(key) {
|
|
62
|
+
const items = await load();
|
|
63
|
+
const next = items.filter((n) => n.key !== key);
|
|
64
|
+
if (next.length !== items.length)
|
|
65
|
+
persist(next);
|
|
66
|
+
}
|
|
67
|
+
export async function getNotification(id) {
|
|
68
|
+
return (await load()).find((n) => n.id === id) ?? null;
|
|
69
|
+
}
|
|
70
|
+
export async function listNotifications() {
|
|
71
|
+
const items = await load();
|
|
72
|
+
return { items, unread: items.filter((n) => !n.readAt).length };
|
|
73
|
+
}
|
|
74
|
+
/** Mark the given ids (or everything) read. */
|
|
75
|
+
export async function markNotificationsRead(ids) {
|
|
76
|
+
const items = await load();
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
const pick = ids === "all" ? null : new Set(ids);
|
|
79
|
+
persist(items.map((n) => (!n.readAt && (!pick || pick.has(n.id)) ? { ...n, readAt: now } : n)));
|
|
80
|
+
return listNotifications();
|
|
81
|
+
}
|
|
82
|
+
export async function clearNotifications() {
|
|
83
|
+
await load();
|
|
84
|
+
persist([]);
|
|
85
|
+
}
|
|
86
|
+
/** Tests / project switches: forget the in-memory copy so the next read hits disk. */
|
|
87
|
+
export function resetNotificationsCache() {
|
|
88
|
+
cache = null;
|
|
89
|
+
}
|
|
@@ -24,6 +24,8 @@ export interface RoutineStatus extends Routine {
|
|
|
24
24
|
lastRunAt?: string;
|
|
25
25
|
lastChatId?: string;
|
|
26
26
|
lastError?: string;
|
|
27
|
+
/** The last run's session holds a permission request waiting for a human. */
|
|
28
|
+
awaitingApproval?: boolean;
|
|
27
29
|
}
|
|
28
30
|
interface CronSpec {
|
|
29
31
|
minute: Set<number>;
|
|
@@ -3,7 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
4
|
import { getOutputDir } from "./context.js";
|
|
5
5
|
import { getAgent, listAgents, safeAgentSlug, agentsRoot } from "./agents.js";
|
|
6
|
-
import { createChat, postMessage } from "./chat/sessions.js";
|
|
6
|
+
import { chatAwaitingApproval, createChat, postMessage } from "./chat/sessions.js";
|
|
7
|
+
import { pushNotification } from "./notifications.js";
|
|
7
8
|
/* ---------- cron ---------- */
|
|
8
9
|
const ALIASES = {
|
|
9
10
|
"@hourly": "0 * * * *",
|
|
@@ -174,6 +175,7 @@ export async function routineStatuses(slug) {
|
|
|
174
175
|
...(r.enabled ? { nextRunAt: nextRun(r.schedule) } : {}),
|
|
175
176
|
...(s.lastRunAt ? { lastRunAt: s.lastRunAt } : {}),
|
|
176
177
|
...(s.lastChatId ? { lastChatId: s.lastChatId } : {}),
|
|
178
|
+
...(s.lastChatId && chatAwaitingApproval(s.lastChatId) ? { awaitingApproval: true } : {}),
|
|
177
179
|
...(s.lastError ? { lastError: s.lastError } : {}),
|
|
178
180
|
};
|
|
179
181
|
});
|
|
@@ -194,6 +196,16 @@ async function fireRoutine(slug, routine) {
|
|
|
194
196
|
throw new Error(error);
|
|
195
197
|
return meta.id;
|
|
196
198
|
}
|
|
199
|
+
/** A routine could not even start (agent gone, harness missing): the bell says so. */
|
|
200
|
+
function notifyRoutineFailed(slug, routine, message) {
|
|
201
|
+
return pushNotification({
|
|
202
|
+
kind: "routine_failed",
|
|
203
|
+
title: `⏰ ${routine.name} could not start`,
|
|
204
|
+
body: message,
|
|
205
|
+
href: `/agents/${slug}`,
|
|
206
|
+
diagnose: { kind: "routine", agent: slug, routine: routine.id },
|
|
207
|
+
}).catch(() => { });
|
|
208
|
+
}
|
|
197
209
|
export async function runRoutineNow(slug, id) {
|
|
198
210
|
const routine = (await listRoutines(slug)).find((r) => r.id === id);
|
|
199
211
|
if (!routine)
|
|
@@ -210,6 +222,7 @@ export async function runRoutineNow(slug, id) {
|
|
|
210
222
|
catch (err) {
|
|
211
223
|
state[key] = { ...state[key], lastError: err.message };
|
|
212
224
|
await writeState(state);
|
|
225
|
+
void notifyRoutineFailed(slug, routine, err.message);
|
|
213
226
|
throw err;
|
|
214
227
|
}
|
|
215
228
|
}
|
|
@@ -251,6 +264,7 @@ async function tick() {
|
|
|
251
264
|
}
|
|
252
265
|
catch (err) {
|
|
253
266
|
state[key] = { ...state[key], lastError: err.message };
|
|
267
|
+
void notifyRoutineFailed(m.slug, r, err.message);
|
|
254
268
|
}
|
|
255
269
|
}
|
|
256
270
|
}
|
package/dist/inspector/runner.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdirSync, openSync, closeSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { getProjectRoot } from "./context.js";
|
|
5
|
-
import { RUN_ID_RE, safeRunDir } from "./runs.js";
|
|
5
|
+
import { RUN_ID_RE, getRun, safeRunDir } from "./runs.js";
|
|
6
|
+
import { pushNotification } from "./notifications.js";
|
|
6
7
|
import { newRunId } from "../workflow.js";
|
|
7
8
|
/**
|
|
8
9
|
* Workflow trigger for the web UI. The inspector stays decoupled from the
|
|
@@ -54,6 +55,24 @@ export function triggerRun(opts) {
|
|
|
54
55
|
});
|
|
55
56
|
child.unref();
|
|
56
57
|
closeSync(log);
|
|
58
|
+
// Detached, but as long as the inspector lives it still hears the exit:
|
|
59
|
+
// that is when the run's outcome goes to the bell (read from the trace,
|
|
60
|
+
// the exit code alone does not say whether a gate blocked).
|
|
61
|
+
child.on("exit", () => {
|
|
62
|
+
void getRun(runId)
|
|
63
|
+
.then((run) => {
|
|
64
|
+
const ok = run?.status === "ok";
|
|
65
|
+
const lastSummary = run?.phases.filter((p) => p.summary).at(-1)?.summary;
|
|
66
|
+
return pushNotification({
|
|
67
|
+
kind: ok ? "run_done" : "run_failed",
|
|
68
|
+
title: `${opts.workflowName} run ${ok ? "finished" : (run?.status ?? "failed")}`,
|
|
69
|
+
body: lastSummary ?? opts.request,
|
|
70
|
+
href: `/runs/${runId}`,
|
|
71
|
+
...(ok ? {} : { diagnose: { kind: "run", runId } }),
|
|
72
|
+
});
|
|
73
|
+
})
|
|
74
|
+
.catch(() => { });
|
|
75
|
+
});
|
|
57
76
|
return { runId };
|
|
58
77
|
}
|
|
59
78
|
export function stopRun(runId) {
|
package/dist/inspector/server.js
CHANGED
|
@@ -6,6 +6,8 @@ import { resolveProject } from "../config.js";
|
|
|
6
6
|
import { listRuns, getRun, readRunFile } from "./runs.js";
|
|
7
7
|
import { listWorkflows, getWorkflow } from "./workflows.js";
|
|
8
8
|
import { dockerStatus, triggerRun, stopRun } from "./runner.js";
|
|
9
|
+
import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
|
|
10
|
+
import { startDiagnosis } from "./diagnose.js";
|
|
9
11
|
import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, } from "./chat/sessions.js";
|
|
10
12
|
import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
|
|
11
13
|
import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
|
|
@@ -14,7 +16,7 @@ import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectS
|
|
|
14
16
|
import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
|
|
15
17
|
import { getSettings, saveSettings } from "./settings.js";
|
|
16
18
|
import { addRepo, listRepos, openRepos, removeRepo, updateRepo } from "./repos.js";
|
|
17
|
-
import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
|
|
19
|
+
import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, proxyUpgrade, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
|
|
18
20
|
import { searchAgents } from "./search.js";
|
|
19
21
|
import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
|
|
20
22
|
/**
|
|
@@ -23,6 +25,8 @@ import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutin
|
|
|
23
25
|
* frontend polls. Realtime is polling — no daemon, no socket, works on a
|
|
24
26
|
* plain filesystem.
|
|
25
27
|
*/
|
|
28
|
+
/** CSP for raw run files: same shape as the vibeable preview — scripts allowed, origin opaque. */
|
|
29
|
+
const RUN_FILE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";
|
|
26
30
|
const MIME = {
|
|
27
31
|
".html": "text/html; charset=utf-8",
|
|
28
32
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -186,6 +190,38 @@ async function handleApi(req, res, url) {
|
|
|
186
190
|
if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
|
|
187
191
|
return json(res, { error: "cross-origin request refused" }, 403);
|
|
188
192
|
}
|
|
193
|
+
// GET /api/notifications — the bell: attention items, newest first, plus unread count.
|
|
194
|
+
// POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
|
|
195
|
+
// DELETE /api/notifications — clear the list.
|
|
196
|
+
if (seg.length >= 2 && seg[1] === "notifications") {
|
|
197
|
+
if (seg.length === 2 && method === "GET")
|
|
198
|
+
return json(res, await listNotifications());
|
|
199
|
+
if (seg.length === 2 && method === "DELETE") {
|
|
200
|
+
await clearNotifications();
|
|
201
|
+
return json(res, { ok: true });
|
|
202
|
+
}
|
|
203
|
+
// POST /api/notifications/:id/diagnose — open a chat that investigates the failure behind an item.
|
|
204
|
+
if (seg.length === 4 && seg[3] === "diagnose" && method === "POST") {
|
|
205
|
+
const result = await startDiagnosis(seg[2]);
|
|
206
|
+
if ("error" in result)
|
|
207
|
+
return json(res, result, result.status);
|
|
208
|
+
return json(res, result);
|
|
209
|
+
}
|
|
210
|
+
if (seg.length === 3 && seg[2] === "read" && method === "POST") {
|
|
211
|
+
let body = {};
|
|
212
|
+
try {
|
|
213
|
+
const raw = await readBody(req);
|
|
214
|
+
if (raw)
|
|
215
|
+
body = JSON.parse(raw);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return json(res, { error: "invalid JSON body" }, 400);
|
|
219
|
+
}
|
|
220
|
+
const ids = Array.isArray(body.ids) ? body.ids.map(String) : "all";
|
|
221
|
+
return json(res, await markNotificationsRead(ids));
|
|
222
|
+
}
|
|
223
|
+
return json(res, { error: "not found" }, 404);
|
|
224
|
+
}
|
|
189
225
|
// GET /api/meta — package version + project name ("environment") for the UI.
|
|
190
226
|
// ?probe=1 marks a discovery probe from another instance: answer identity
|
|
191
227
|
// only, skip our own discovery (two instances must not probe each other
|
|
@@ -486,9 +522,16 @@ async function handleApi(req, res, url) {
|
|
|
486
522
|
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
487
523
|
const buf = await fs.readFile(file.absPath);
|
|
488
524
|
if (raw) {
|
|
525
|
+
// Agent-written files must not run as the inspector's origin: the
|
|
526
|
+
// sandbox keeps scripts working (interactive reports) but makes the
|
|
527
|
+
// origin opaque, so no fetch() into /api and no cookies. PDFs are
|
|
528
|
+
// exempt — Chrome's viewer refuses sandboxed documents, and PDF
|
|
529
|
+
// scripting has no DOM or origin access.
|
|
489
530
|
res.writeHead(200, {
|
|
490
531
|
"content-type": MIME[ext] ?? "text/plain; charset=utf-8",
|
|
491
532
|
"cache-control": "no-store",
|
|
533
|
+
"x-content-type-options": "nosniff",
|
|
534
|
+
...(ext === ".pdf" ? {} : { "content-security-policy": RUN_FILE_CSP }),
|
|
492
535
|
});
|
|
493
536
|
return void res.end(buf);
|
|
494
537
|
}
|
|
@@ -955,6 +998,12 @@ export function startInspector(opts) {
|
|
|
955
998
|
json(res, { error: err.message }, 500);
|
|
956
999
|
}
|
|
957
1000
|
});
|
|
1001
|
+
// WebSocket upgrades exist for one reason: a vibeable's dev server (HMR).
|
|
1002
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1003
|
+
if (!hostAllowed(req))
|
|
1004
|
+
return void socket.destroy();
|
|
1005
|
+
proxyUpgrade(req, socket, head);
|
|
1006
|
+
});
|
|
958
1007
|
return new Promise((resolve, reject) => {
|
|
959
1008
|
server.once("error", reject);
|
|
960
1009
|
server.listen(opts.port, INSPECTOR_HOST, () => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
|
-
import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT } from "../config.js";
|
|
5
|
-
import { ensureVibeablesRoot } from "./vibeables.js";
|
|
4
|
+
import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
|
|
5
|
+
import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
|
|
6
6
|
import { getProjectRoot } from "./context.js";
|
|
7
7
|
import { ensureReposRoot } from "./repos.js";
|
|
8
8
|
const GIT_DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
|
|
@@ -47,9 +47,13 @@ function cleanGit(value) {
|
|
|
47
47
|
throw new Error("git.enabled must be true or false");
|
|
48
48
|
const out = {};
|
|
49
49
|
const remote = typeof value.remote === "string" ? value.remote.trim() : "";
|
|
50
|
+
if (remote && !isSafeGitName(remote))
|
|
51
|
+
throw new Error('git.remote must be a plain name (no leading "-", no whitespace)');
|
|
50
52
|
if (remote && remote !== GIT_DEFAULTS.remote)
|
|
51
53
|
out.remote = remote;
|
|
52
54
|
const branch = typeof value.branch === "string" ? value.branch.trim() : "";
|
|
55
|
+
if (branch && !isSafeGitName(branch))
|
|
56
|
+
throw new Error('git.branch must be a plain name (no leading "-", no whitespace)');
|
|
53
57
|
if (branch)
|
|
54
58
|
out.branch = branch;
|
|
55
59
|
if (value.interval !== undefined && value.interval !== "") {
|
|
@@ -142,6 +146,13 @@ export async function saveSettings(input) {
|
|
|
142
146
|
doc.delete("vibeables");
|
|
143
147
|
else
|
|
144
148
|
doc.set("vibeables", vibeables);
|
|
149
|
+
// Off, or a different root: running dev servers belong to folders the
|
|
150
|
+
// API can no longer address, so they must not outlive this save.
|
|
151
|
+
const before = project.config.vibeables;
|
|
152
|
+
const rootBefore = before && before.enabled !== false ? before.root ?? VIBEABLES_DEFAULT_ROOT : undefined;
|
|
153
|
+
const rootAfter = vibeables && vibeables.enabled !== false ? vibeables.root ?? VIBEABLES_DEFAULT_ROOT : undefined;
|
|
154
|
+
if (rootBefore !== rootAfter)
|
|
155
|
+
disposeAllDevs();
|
|
145
156
|
}
|
|
146
157
|
await fs.writeFile(configPath, doc.toString());
|
|
147
158
|
// Turning repositories on prepares the folder so the first clone and the
|
|
@@ -132,6 +132,12 @@ export declare function vibeableStatus(slug: string): Promise<VibeableStatus>;
|
|
|
132
132
|
* trailing slash redirects so the page's relative links resolve.
|
|
133
133
|
*/
|
|
134
134
|
export declare function serveVibeable(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* WebSocket upgrades under /vibeables/<slug>/ go to the dev server too — that
|
|
137
|
+
* is how Vite-style HMR reaches the pane. Anything else is closed: the
|
|
138
|
+
* inspector has no sockets of its own.
|
|
139
|
+
*/
|
|
140
|
+
export declare function proxyUpgrade(req: http.IncomingMessage, socket: import("node:stream").Duplex, head: Buffer): void;
|
|
135
141
|
/**
|
|
136
142
|
* Stream a vibeable's events: file changes (debounced, node_modules and
|
|
137
143
|
* the like excluded) and dev-server state. The watcher exists while
|
|
@@ -139,11 +145,6 @@ export declare function serveVibeable(req: http.IncomingMessage, res: http.Serve
|
|
|
139
145
|
* where it cannot watch, the pane still has its manual reload.
|
|
140
146
|
*/
|
|
141
147
|
export declare function subscribeVibeable(slug: string, dir: string, fn: (ev: VibeableEvent) => void): () => void;
|
|
142
|
-
/**
|
|
143
|
-
* Start the vibeable's dev command. Idempotent while it runs. The child
|
|
144
|
-
* leads its own process group so stopping it also stops what it spawned
|
|
145
|
-
* (npm → vite → esbuild).
|
|
146
|
-
*/
|
|
147
148
|
export declare function startDev(slug: string): Promise<VibeableStatus>;
|
|
148
149
|
/** SIGTERM the dev process group, SIGKILL what is still there after 3 s. */
|
|
149
150
|
export declare function stopDev(slug: string): Promise<VibeableStatus>;
|