@netnodeag/kraftwerk 0.39.1 → 0.41.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 +72 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -0
- package/dist/inspector/channels.d.ts +48 -0
- package/dist/inspector/channels.js +130 -0
- package/dist/inspector/chat/acp.js +17 -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 +20 -1
- package/dist/inspector/chat/sessions.js +384 -59
- package/dist/inspector/chat/types.d.ts +18 -0
- 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 +91 -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 +117 -2
- package/dist/inspector/settings.js +5 -1
- package/inspector/dist/assets/{dist-DYqdkWRb.js → dist-BO4gDbWB.js} +1 -1
- package/inspector/dist/assets/{dist-D01HLRsD.js → dist-BiIrAs8U.js} +1 -1
- package/inspector/dist/assets/{dist-Cn3OeZ9x.js → dist-Bj7XitTo.js} +1 -1
- package/inspector/dist/assets/{dist-DQujkoko.js → dist-BvHfHbzl.js} +1 -1
- package/inspector/dist/assets/{dist-BUzvZfC2.js → dist-CASrSR5E.js} +1 -1
- package/inspector/dist/assets/{dist-DfmPNdMI.js → dist-CoT4dTT9.js} +1 -1
- package/inspector/dist/assets/{dist-BAHymHVh.js → dist-CqbReWDG.js} +1 -1
- package/inspector/dist/assets/{dist-D0WPeXAf.js → dist-DKds1beX.js} +1 -1
- package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
- package/inspector/dist/assets/{dist-CMK0Dv1Q.js → dist-g0pveAaF.js} +1 -1
- package/inspector/dist/assets/{dist-FnKl1oDp.js → dist-h7tJ3T-Z.js} +1 -1
- package/inspector/dist/assets/{editor-CVAfyi-9.js → editor-BiyVJ7Wz.js} +3 -3
- package/inspector/dist/assets/index-CHI0eOQa.css +1 -0
- package/inspector/dist/assets/index-lwVGUk9H.js +218 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-CWxCXHGC.js +0 -1
- package/inspector/dist/assets/index-Ba-uGp4N.js +0 -218
- package/inspector/dist/assets/index-CL7d8WY1.css +0 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ChatAgentId, ChatScope } from "./chat/types.js";
|
|
2
|
+
import { type DiagnoseRef } from "./notifications.js";
|
|
3
|
+
/**
|
|
4
|
+
* "Diagnose" on a failure item in the bell: open a chat in the place that
|
|
5
|
+
* knows most about the failure and hand it a first message that asks for
|
|
6
|
+
* root cause and a fix.
|
|
7
|
+
*
|
|
8
|
+
* workflow run failed -> run chat (cwd = run folder, project root granted)
|
|
9
|
+
* routine session died -> a fresh session of the same agent, pointed at the
|
|
10
|
+
* failed session's event log
|
|
11
|
+
* routine could not start -> kraftwerk-aware chat (the agent/harness setup is the problem)
|
|
12
|
+
*
|
|
13
|
+
* The prompt is built here, at click time, so it reflects the run's final
|
|
14
|
+
* state, and the chat shows it as the user's own first message — nothing
|
|
15
|
+
* hidden.
|
|
16
|
+
*/
|
|
17
|
+
export interface DiagnosePlan {
|
|
18
|
+
agent: ChatAgentId;
|
|
19
|
+
scope: ChatScope;
|
|
20
|
+
title: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
}
|
|
23
|
+
/** Where and how the diagnosis chat starts, for a failure item's reference. */
|
|
24
|
+
export declare function diagnosePlan(ref: DiagnoseRef, title: string): Promise<DiagnosePlan | null>;
|
|
25
|
+
/** Create the chat, send the first message, mark the item read. */
|
|
26
|
+
export declare function startDiagnosis(notificationId: string): Promise<{
|
|
27
|
+
chatId: string;
|
|
28
|
+
href: string;
|
|
29
|
+
} | {
|
|
30
|
+
error: string;
|
|
31
|
+
status: number;
|
|
32
|
+
}>;
|
|
@@ -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,91 @@
|
|
|
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
|
+
if (meta.scope.kind === "channel")
|
|
42
|
+
return `/channels/${meta.scope.slug}`;
|
|
43
|
+
return meta.scope.kind === "agent" ? `/agents/${meta.scope.slug}/chat/${meta.id}` : `/agents/chats/${meta.id}`;
|
|
44
|
+
}
|
|
45
|
+
export async function pushNotification(input) {
|
|
46
|
+
const items = await load();
|
|
47
|
+
const item = {
|
|
48
|
+
id: randomUUID(),
|
|
49
|
+
kind: input.kind,
|
|
50
|
+
title: input.title,
|
|
51
|
+
...(input.body ? { body: clipBody(input.body) } : {}),
|
|
52
|
+
href: input.href,
|
|
53
|
+
at: new Date().toISOString(),
|
|
54
|
+
...(input.key ? { key: input.key } : {}),
|
|
55
|
+
...(input.diagnose ? { diagnose: input.diagnose } : {}),
|
|
56
|
+
};
|
|
57
|
+
// Same live state again (e.g. the request re-emitted): replace, don't stack.
|
|
58
|
+
const rest = input.key ? items.filter((n) => n.key !== input.key) : items;
|
|
59
|
+
persist([item, ...rest].slice(0, MAX_ITEMS));
|
|
60
|
+
return item;
|
|
61
|
+
}
|
|
62
|
+
/** Drop the item(s) for a live state that no longer exists (a permission got answered). */
|
|
63
|
+
export async function dismissKey(key) {
|
|
64
|
+
const items = await load();
|
|
65
|
+
const next = items.filter((n) => n.key !== key);
|
|
66
|
+
if (next.length !== items.length)
|
|
67
|
+
persist(next);
|
|
68
|
+
}
|
|
69
|
+
export async function getNotification(id) {
|
|
70
|
+
return (await load()).find((n) => n.id === id) ?? null;
|
|
71
|
+
}
|
|
72
|
+
export async function listNotifications() {
|
|
73
|
+
const items = await load();
|
|
74
|
+
return { items, unread: items.filter((n) => !n.readAt).length };
|
|
75
|
+
}
|
|
76
|
+
/** Mark the given ids (or everything) read. */
|
|
77
|
+
export async function markNotificationsRead(ids) {
|
|
78
|
+
const items = await load();
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const pick = ids === "all" ? null : new Set(ids);
|
|
81
|
+
persist(items.map((n) => (!n.readAt && (!pick || pick.has(n.id)) ? { ...n, readAt: now } : n)));
|
|
82
|
+
return listNotifications();
|
|
83
|
+
}
|
|
84
|
+
export async function clearNotifications() {
|
|
85
|
+
await load();
|
|
86
|
+
persist([]);
|
|
87
|
+
}
|
|
88
|
+
/** Tests / project switches: forget the in-memory copy so the next read hits disk. */
|
|
89
|
+
export function resetNotificationsCache() {
|
|
90
|
+
cache = null;
|
|
91
|
+
}
|
|
@@ -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,7 +6,10 @@ 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 {
|
|
9
|
+
import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
|
|
10
|
+
import { startDiagnosis } from "./diagnose.js";
|
|
11
|
+
import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
|
|
12
|
+
import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
|
|
10
13
|
import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
|
|
11
14
|
import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
|
|
12
15
|
import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
|
|
@@ -23,6 +26,8 @@ import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutin
|
|
|
23
26
|
* frontend polls. Realtime is polling — no daemon, no socket, works on a
|
|
24
27
|
* plain filesystem.
|
|
25
28
|
*/
|
|
29
|
+
/** CSP for raw run files: same shape as the vibeable preview — scripts allowed, origin opaque. */
|
|
30
|
+
const RUN_FILE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";
|
|
26
31
|
const MIME = {
|
|
27
32
|
".html": "text/html; charset=utf-8",
|
|
28
33
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -186,6 +191,108 @@ async function handleApi(req, res, url) {
|
|
|
186
191
|
if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
|
|
187
192
|
return json(res, { error: "cross-origin request refused" }, 403);
|
|
188
193
|
}
|
|
194
|
+
// Channels — shared transcripts with several agents (channels/<slug>/channel.yml + one chat).
|
|
195
|
+
// GET /api/channels | POST /api/channels {name, purpose?, members, responder?}
|
|
196
|
+
// POST /api/channels/from-chat {chatId, name, members, ...} — an agent session becomes a channel
|
|
197
|
+
// GET/PUT/DELETE /api/channels/:slug
|
|
198
|
+
if (seg.length >= 2 && seg[1] === "channels") {
|
|
199
|
+
const view = async (c) => {
|
|
200
|
+
const meta = await ensureChannelChat(c);
|
|
201
|
+
const live = (await listChats()).find((x) => x.id === meta.id);
|
|
202
|
+
return { ...c, chatId: meta.id, busy: live?.busy ?? false, awaitingApproval: live?.awaitingApproval ?? false, updatedAt: meta.updatedAt };
|
|
203
|
+
};
|
|
204
|
+
let body = {};
|
|
205
|
+
if (method === "POST" || method === "PUT") {
|
|
206
|
+
try {
|
|
207
|
+
const raw = await readBody(req);
|
|
208
|
+
if (raw)
|
|
209
|
+
body = JSON.parse(raw);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return json(res, { error: "invalid JSON body" }, 400);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
if (seg.length === 2 && method === "GET") {
|
|
217
|
+
const channels = await listChannels();
|
|
218
|
+
return json(res, { root: await channelsRoot(), channels: await Promise.all(channels.map(view)) });
|
|
219
|
+
}
|
|
220
|
+
if (seg.length === 2 && method === "POST") {
|
|
221
|
+
const { chatId: _c, slug: _s, ...input } = body;
|
|
222
|
+
return json(res, await view(await saveChannel(input)));
|
|
223
|
+
}
|
|
224
|
+
if (seg.length === 3 && seg[2] === "from-chat" && method === "POST") {
|
|
225
|
+
const { chatId, slug: _s, ...input } = body;
|
|
226
|
+
if (!chatId)
|
|
227
|
+
return json(res, { error: "chatId is required" }, 400);
|
|
228
|
+
const channel = await saveChannel(input);
|
|
229
|
+
const converted = await convertChatToChannel(chatId, channel);
|
|
230
|
+
if (converted.error) {
|
|
231
|
+
await deleteChannel(channel.slug).catch(() => { });
|
|
232
|
+
return json(res, { error: converted.error }, 409);
|
|
233
|
+
}
|
|
234
|
+
return json(res, await view(channel));
|
|
235
|
+
}
|
|
236
|
+
if (seg.length === 3) {
|
|
237
|
+
const slug = decodeURIComponent(seg[2]);
|
|
238
|
+
const channel = await getChannel(slug);
|
|
239
|
+
if (!channel)
|
|
240
|
+
return json(res, { error: "channel not found" }, 404);
|
|
241
|
+
if (method === "GET")
|
|
242
|
+
return json(res, await view(channel));
|
|
243
|
+
if (method === "PUT") {
|
|
244
|
+
const { chatId: _c, slug: _s, ...input } = body;
|
|
245
|
+
const saved = await saveChannel({ ...input, slug });
|
|
246
|
+
const meta = await ensureChannelChat(saved);
|
|
247
|
+
await dropChannelSeats(meta.id, saved.members);
|
|
248
|
+
return json(res, await view(saved));
|
|
249
|
+
}
|
|
250
|
+
if (method === "DELETE") {
|
|
251
|
+
const meta = (await listChats()).find((c) => c.scope.kind === "channel" && c.scope.slug === slug);
|
|
252
|
+
if (meta)
|
|
253
|
+
await deleteChat(meta.id);
|
|
254
|
+
await deleteChannel(slug);
|
|
255
|
+
return json(res, { ok: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
return json(res, { error: err.message }, 400);
|
|
261
|
+
}
|
|
262
|
+
return json(res, { error: "not found" }, 404);
|
|
263
|
+
}
|
|
264
|
+
// GET /api/notifications — the bell: attention items, newest first, plus unread count.
|
|
265
|
+
// POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
|
|
266
|
+
// DELETE /api/notifications — clear the list.
|
|
267
|
+
if (seg.length >= 2 && seg[1] === "notifications") {
|
|
268
|
+
if (seg.length === 2 && method === "GET")
|
|
269
|
+
return json(res, await listNotifications());
|
|
270
|
+
if (seg.length === 2 && method === "DELETE") {
|
|
271
|
+
await clearNotifications();
|
|
272
|
+
return json(res, { ok: true });
|
|
273
|
+
}
|
|
274
|
+
// POST /api/notifications/:id/diagnose — open a chat that investigates the failure behind an item.
|
|
275
|
+
if (seg.length === 4 && seg[3] === "diagnose" && method === "POST") {
|
|
276
|
+
const result = await startDiagnosis(seg[2]);
|
|
277
|
+
if ("error" in result)
|
|
278
|
+
return json(res, result, result.status);
|
|
279
|
+
return json(res, result);
|
|
280
|
+
}
|
|
281
|
+
if (seg.length === 3 && seg[2] === "read" && method === "POST") {
|
|
282
|
+
let body = {};
|
|
283
|
+
try {
|
|
284
|
+
const raw = await readBody(req);
|
|
285
|
+
if (raw)
|
|
286
|
+
body = JSON.parse(raw);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return json(res, { error: "invalid JSON body" }, 400);
|
|
290
|
+
}
|
|
291
|
+
const ids = Array.isArray(body.ids) ? body.ids.map(String) : "all";
|
|
292
|
+
return json(res, await markNotificationsRead(ids));
|
|
293
|
+
}
|
|
294
|
+
return json(res, { error: "not found" }, 404);
|
|
295
|
+
}
|
|
189
296
|
// GET /api/meta — package version + project name ("environment") for the UI.
|
|
190
297
|
// ?probe=1 marks a discovery probe from another instance: answer identity
|
|
191
298
|
// only, skip our own discovery (two instances must not probe each other
|
|
@@ -486,9 +593,16 @@ async function handleApi(req, res, url) {
|
|
|
486
593
|
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
487
594
|
const buf = await fs.readFile(file.absPath);
|
|
488
595
|
if (raw) {
|
|
596
|
+
// Agent-written files must not run as the inspector's origin: the
|
|
597
|
+
// sandbox keeps scripts working (interactive reports) but makes the
|
|
598
|
+
// origin opaque, so no fetch() into /api and no cookies. PDFs are
|
|
599
|
+
// exempt — Chrome's viewer refuses sandboxed documents, and PDF
|
|
600
|
+
// scripting has no DOM or origin access.
|
|
489
601
|
res.writeHead(200, {
|
|
490
602
|
"content-type": MIME[ext] ?? "text/plain; charset=utf-8",
|
|
491
603
|
"cache-control": "no-store",
|
|
604
|
+
"x-content-type-options": "nosniff",
|
|
605
|
+
...(ext === ".pdf" ? {} : { "content-security-policy": RUN_FILE_CSP }),
|
|
492
606
|
});
|
|
493
607
|
return void res.end(buf);
|
|
494
608
|
}
|
|
@@ -871,7 +985,8 @@ async function handleApi(req, res, url) {
|
|
|
871
985
|
const text = String(body.text ?? "").trim();
|
|
872
986
|
if (!text)
|
|
873
987
|
return json(res, { error: "text is required" }, 400);
|
|
874
|
-
|
|
988
|
+
// Channels: the poster's display name signs the message.
|
|
989
|
+
result = await postMessage(seg[2], text, { from: typeof body.from === "string" ? body.from : undefined });
|
|
875
990
|
}
|
|
876
991
|
else if (seg[3] === "permission") {
|
|
877
992
|
result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
|
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
4
|
+
import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
|
|
5
5
|
import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
|
|
6
6
|
import { getProjectRoot } from "./context.js";
|
|
7
7
|
import { ensureReposRoot } from "./repos.js";
|
|
@@ -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 !== "") {
|