@fullstackhouse/cerber 0.10.0 → 0.10.1
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 +11 -0
- package/dist/core/artifact.js +25 -0
- package/dist/core/state.js +15 -9
- package/dist/runner/chat.js +5 -0
- package/dist/runner/claude.js +73 -15
- package/dist/runner/progress.js +88 -0
- package/dist/runner/review.js +5 -0
- package/dist/server/daemon.js +1 -0
- package/dist/server/index.js +74 -16
- package/dist/server/progress.js +72 -0
- package/package.json +1 -1
- package/web/dist/assets/index-C25hWJar.js +236 -0
- package/web/dist/assets/{index-BGy7f14m.css → index-si1RfFZU.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CmpTqzKD.js +0 -236
package/README.md
CHANGED
|
@@ -222,6 +222,17 @@ you already asked for the change. What it changed shows up in the transcript
|
|
|
222
222
|
blind rather than left to describe files from memory.
|
|
223
223
|
- **It can say no.** The prompt tells it not to fold under push-back. If you
|
|
224
224
|
are wrong about something it checked, it says so and changes nothing.
|
|
225
|
+
- **The wait is not a held request.** Re-reading the code takes minutes, so a
|
|
226
|
+
turn runs detached the way a re-review does: your question appears in the
|
|
227
|
+
transcript straight away and the cockpit polls for the answer. Reload, close
|
|
228
|
+
the tab, or sit behind a proxy that times connections out at 60s — the answer
|
|
229
|
+
still lands on the review, and a turn that fails says so against the question
|
|
230
|
+
that caused it.
|
|
231
|
+
- **You can watch it work.** Those minutes aren't a spinner: the run narrates
|
|
232
|
+
itself under your question — *thinking…*, *reading src/core/refresh.ts*,
|
|
233
|
+
*searching for carryOverComments in src*, *not allowed to use Bash* — so you
|
|
234
|
+
can tell a turn that is checking your claim from one that is about to answer
|
|
235
|
+
from memory. `cerber review` and the daemon log print the same lines.
|
|
225
236
|
- **Your words are yours.** Comments you wrote or edited are off limits: it
|
|
226
237
|
will tell you one of them needs changing rather than rewriting it. Say so
|
|
227
238
|
explicitly and it will.
|
package/dist/core/artifact.js
CHANGED
|
@@ -144,6 +144,29 @@ export const ChatTurnSchema = z.object({
|
|
|
144
144
|
refused: z.array(RefusalSchema).default([]),
|
|
145
145
|
costUsd: z.number().nullable().default(null),
|
|
146
146
|
});
|
|
147
|
+
/**
|
|
148
|
+
* The chat turn being answered right now — or the one that failed.
|
|
149
|
+
*
|
|
150
|
+
* A turn takes minutes: the reviewer re-reads the code before it answers. So
|
|
151
|
+
* the turn runs detached and this is what the cockpit polls. The question is on
|
|
152
|
+
* the artifact from the moment it is asked, which is what makes a reload
|
|
153
|
+
* mid-turn show it still being answered rather than a conversation that lost
|
|
154
|
+
* it, and what carries a failure back to a request that already returned.
|
|
155
|
+
*/
|
|
156
|
+
export const PendingChatSchema = z.object({
|
|
157
|
+
/** The message being answered — shown in the transcript while we wait. */
|
|
158
|
+
message: z.string(),
|
|
159
|
+
refs: ChatTurnSchema.shape.refs,
|
|
160
|
+
startedAt: z.string(),
|
|
161
|
+
/**
|
|
162
|
+
* What the turn has been doing, in its own words — "reading src/core/diff.ts",
|
|
163
|
+
* "searching for carryOverComments". The answer takes minutes and this is the
|
|
164
|
+
* only honest account of them; it is thrown away when the answer lands.
|
|
165
|
+
*/
|
|
166
|
+
progress: z.array(z.string()).default([]),
|
|
167
|
+
/** Why the turn failed. Null while it is still running. */
|
|
168
|
+
error: z.string().nullable().default(null),
|
|
169
|
+
});
|
|
147
170
|
/** The parts of a review a chat turn may rewrite — the reset target. */
|
|
148
171
|
export const ReviewSnapshotSchema = z.object({
|
|
149
172
|
at: z.string(),
|
|
@@ -175,6 +198,8 @@ export const ArtifactSchema = z.object({
|
|
|
175
198
|
calibration: CalibrationSchema.nullable().default(null),
|
|
176
199
|
/** The conversation about this review. Never sent to GitHub. */
|
|
177
200
|
chat: z.array(ChatTurnSchema).default([]),
|
|
201
|
+
/** A turn in flight, or the one that failed. Null when nothing is pending. */
|
|
202
|
+
pendingChat: PendingChatSchema.nullable().default(null),
|
|
178
203
|
/** The review as it stood before the first chat turn — "reset" restores this. */
|
|
179
204
|
preChat: ReviewSnapshotSchema.nullable().default(null),
|
|
180
205
|
});
|
package/dist/core/state.js
CHANGED
|
@@ -92,25 +92,31 @@ export async function updateArtifactByKey(key, mutate) {
|
|
|
92
92
|
return updated;
|
|
93
93
|
}
|
|
94
94
|
/**
|
|
95
|
-
* Clear
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
95
|
+
* Clear AI work left in flight by a process that died mid-run — a review marked
|
|
96
|
+
* "running", or a chat turn still waiting for its answer. Nothing in a freshly
|
|
97
|
+
* started process is running, so anything still marked so is a leftover; without
|
|
98
|
+
* this it stays wedged forever, with no way to retry it from the cockpit. A
|
|
99
|
+
* `cerber review` running in another terminal is the one false positive; it
|
|
100
|
+
* overwrites the artifact when it finishes anyway.
|
|
100
101
|
*/
|
|
101
102
|
export async function reconcileRunning() {
|
|
102
103
|
const artifacts = await listArtifacts();
|
|
103
104
|
let cleared = 0;
|
|
104
105
|
for (const artifact of artifacts) {
|
|
105
|
-
|
|
106
|
+
const stuckRun = artifact.status === "running";
|
|
107
|
+
const stuckChat = artifact.pendingChat && artifact.pendingChat.error == null ? artifact.pendingChat : null;
|
|
108
|
+
if (!stuckRun && !stuckChat)
|
|
106
109
|
continue;
|
|
107
110
|
await saveArtifact({
|
|
108
111
|
...artifact,
|
|
109
|
-
status: "failed",
|
|
112
|
+
status: stuckRun ? "failed" : artifact.status,
|
|
110
113
|
updatedAt: new Date().toISOString(),
|
|
111
|
-
run: artifact.run
|
|
114
|
+
run: stuckRun && artifact.run
|
|
112
115
|
? { ...artifact.run, error: "interrupted — cerber restarted while this review was running" }
|
|
113
|
-
:
|
|
116
|
+
: artifact.run,
|
|
117
|
+
pendingChat: stuckChat
|
|
118
|
+
? { ...stuckChat, error: "interrupted — cerber restarted while this turn was running" }
|
|
119
|
+
: artifact.pendingChat,
|
|
114
120
|
});
|
|
115
121
|
cleared++;
|
|
116
122
|
}
|
package/dist/runner/chat.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRunDir, prepareCheckout, removeRunDir, sourceDir } from "../core/
|
|
|
5
5
|
import { applyRevisions, snapshotReview } from "../core/revise.js";
|
|
6
6
|
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
7
7
|
import { beginReview, endReview } from "./inflight.js";
|
|
8
|
+
import { describeEvent } from "./progress.js";
|
|
8
9
|
import { buildChatPrompt } from "./prompt.js";
|
|
9
10
|
/**
|
|
10
11
|
* One turn of a conversation about a drafted review.
|
|
@@ -126,6 +127,10 @@ async function turn(artifact, message, opts) {
|
|
|
126
127
|
const empty = await createRunDir();
|
|
127
128
|
const claudeOpts = {
|
|
128
129
|
model: opts.model,
|
|
130
|
+
// The turn narrates itself while it works. Nothing waits on this — it is
|
|
131
|
+
// the difference between a minute of spinner and a minute of "reading
|
|
132
|
+
// src/core/refresh.ts".
|
|
133
|
+
onEvent: (event) => describeEvent(event).forEach(log),
|
|
129
134
|
cwd: source ?? empty,
|
|
130
135
|
env: unauthenticatedEnv(process.env, empty),
|
|
131
136
|
allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
|
package/dist/runner/claude.js
CHANGED
|
@@ -39,7 +39,10 @@ export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
|
39
39
|
/** How long a killed run gets to exit on its own before SIGKILL. */
|
|
40
40
|
const SIGKILL_GRACE_MS = 5_000;
|
|
41
41
|
export function buildClaudeArgs(opts) {
|
|
42
|
-
|
|
42
|
+
// stream-json, not json: the same final result arrives in the closing event,
|
|
43
|
+
// and everything before it is the run saying what it is doing. `--verbose` is
|
|
44
|
+
// not optional — the CLI refuses the combination without it.
|
|
45
|
+
const args = ["-p", "--output-format", "stream-json", "--verbose"];
|
|
43
46
|
if (opts.resumeSessionId)
|
|
44
47
|
args.push("--resume", opts.resumeSessionId);
|
|
45
48
|
if (opts.model)
|
|
@@ -53,9 +56,39 @@ export function buildClaudeArgs(opts) {
|
|
|
53
56
|
}
|
|
54
57
|
return args;
|
|
55
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Split a stream-json stdout chunk into whole events, keeping whatever trailing
|
|
61
|
+
* fragment has not been terminated yet. A tool result can be megabytes, so a
|
|
62
|
+
* single event routinely arrives across several chunks.
|
|
63
|
+
*/
|
|
64
|
+
export function readEvents(buffer) {
|
|
65
|
+
const lines = buffer.split("\n");
|
|
66
|
+
// The last piece has no newline after it — it may be half an event.
|
|
67
|
+
const rest = lines.pop() ?? "";
|
|
68
|
+
const events = [];
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
if (!line.trim())
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(line);
|
|
74
|
+
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
75
|
+
events.push(parsed);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// A line we can't parse is a line we don't need — the run's own result
|
|
80
|
+
// comes from the closing event, and dying here would throw away a
|
|
81
|
+
// finished review over a stray byte.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { events, rest };
|
|
85
|
+
}
|
|
56
86
|
/**
|
|
57
87
|
* Run `claude -p` headless with the prompt on stdin, return the result text.
|
|
58
88
|
* Rides the user's existing Claude Code login — no API key handling here.
|
|
89
|
+
*
|
|
90
|
+
* Reads the run as a stream so {@link ClaudeOptions.onEvent} can report what it
|
|
91
|
+
* is doing while it does it; the result itself is the closing event.
|
|
59
92
|
*/
|
|
60
93
|
export function runClaude(prompt, opts = {}) {
|
|
61
94
|
const args = buildClaudeArgs(opts);
|
|
@@ -65,7 +98,10 @@ export function runClaude(prompt, opts = {}) {
|
|
|
65
98
|
cwd: opts.cwd,
|
|
66
99
|
env: opts.env,
|
|
67
100
|
});
|
|
68
|
-
let
|
|
101
|
+
let unread = "";
|
|
102
|
+
let result = null;
|
|
103
|
+
/** Kept only to explain a failure — the events carry everything we use. */
|
|
104
|
+
let tail = "";
|
|
69
105
|
let stderr = "";
|
|
70
106
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
71
107
|
let settled = false;
|
|
@@ -89,7 +125,26 @@ export function runClaude(prompt, opts = {}) {
|
|
|
89
125
|
child.stdin.destroy();
|
|
90
126
|
settle(() => reject(new Error(`claude did not finish within ${Math.round(timeoutMs / 60_000)} minutes — run killed.`)));
|
|
91
127
|
}, timeoutMs);
|
|
92
|
-
|
|
128
|
+
const ingest = (chunk) => {
|
|
129
|
+
const read = readEvents(unread + chunk);
|
|
130
|
+
unread = read.rest;
|
|
131
|
+
for (const event of read.events) {
|
|
132
|
+
if (event.type === "result")
|
|
133
|
+
result = event;
|
|
134
|
+
// A throwing listener must not take the run down with it: the answer is
|
|
135
|
+
// the point, the narration is not.
|
|
136
|
+
try {
|
|
137
|
+
opts.onEvent?.(event);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
child.stdout.on("data", (d) => {
|
|
145
|
+
tail = (tail + d).slice(-2_000);
|
|
146
|
+
ingest(String(d));
|
|
147
|
+
});
|
|
93
148
|
child.stderr.on("data", (d) => (stderr += d));
|
|
94
149
|
child.on("error", (err) => {
|
|
95
150
|
settle(() => reject(new Error(`Failed to start claude CLI: ${err.message}. Is Claude Code installed and on PATH?`)));
|
|
@@ -100,21 +155,24 @@ export function runClaude(prompt, opts = {}) {
|
|
|
100
155
|
clearTimeout(timer);
|
|
101
156
|
settled = true;
|
|
102
157
|
if (code !== 0) {
|
|
103
|
-
reject(new Error(`claude exited with code ${code}: ${stderr.trim() ||
|
|
158
|
+
reject(new Error(`claude exited with code ${code}: ${stderr.trim() || tail.slice(-500)}`));
|
|
104
159
|
return;
|
|
105
160
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
reject(new Error(`Could not parse claude output as JSON wrapper: ${stdout.slice(0, 500)}`));
|
|
161
|
+
// The last line need not be newline-terminated, and it is the one that
|
|
162
|
+
// matters: dropping it would throw away a finished review for the want
|
|
163
|
+
// of a trailing byte.
|
|
164
|
+
ingest("\n");
|
|
165
|
+
const wrapper = result;
|
|
166
|
+
if (!wrapper) {
|
|
167
|
+
reject(new Error(`claude produced no result event: ${tail.slice(-500)}`));
|
|
168
|
+
return;
|
|
117
169
|
}
|
|
170
|
+
resolve({
|
|
171
|
+
text: typeof wrapper.result === "string" ? wrapper.result : "",
|
|
172
|
+
costUsd: typeof wrapper.total_cost_usd === "number" ? wrapper.total_cost_usd : null,
|
|
173
|
+
model: wrapper.modelUsage ? Object.keys(wrapper.modelUsage)[0] ?? null : null,
|
|
174
|
+
sessionId: typeof wrapper.session_id === "string" ? wrapper.session_id : null,
|
|
175
|
+
});
|
|
118
176
|
});
|
|
119
177
|
child.stdin.write(prompt);
|
|
120
178
|
child.stdin.end();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a run's own events into a line a human can read while they wait.
|
|
3
|
+
*
|
|
4
|
+
* A review or a chat turn takes minutes, and until now the only honest thing
|
|
5
|
+
* cerber could say about that time was "thinking…". The run already narrates
|
|
6
|
+
* itself — what it is about to read, what it concluded, what it was refused —
|
|
7
|
+
* so this says that instead, in the same plain words the review itself uses:
|
|
8
|
+
* what happened, not the wire format it happened in.
|
|
9
|
+
*
|
|
10
|
+
* Ignoring is the default. An event we don't recognise, and anything that would
|
|
11
|
+
* leak the machinery (the JSON answer, tool results, session bookkeeping),
|
|
12
|
+
* returns null and never reaches the user.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no I/O, no clock.
|
|
15
|
+
*/
|
|
16
|
+
/** How much of any one line survives. Long enough to be useful in a sidebar. */
|
|
17
|
+
const MAX = 180;
|
|
18
|
+
export function describeEvent(event) {
|
|
19
|
+
if (event.type === "assistant") {
|
|
20
|
+
return (event.message?.content ?? []).flatMap((block) => {
|
|
21
|
+
if (block.type === "thinking")
|
|
22
|
+
return ["thinking…"];
|
|
23
|
+
if (block.type === "text") {
|
|
24
|
+
const text = (block.text ?? "").trim();
|
|
25
|
+
// The turn's answer is a JSON object; showing it raw would put the
|
|
26
|
+
// machinery on screen a second before the rendered version lands.
|
|
27
|
+
if (!text || looksLikeJson(text))
|
|
28
|
+
return [];
|
|
29
|
+
return [clip(collapse(text))];
|
|
30
|
+
}
|
|
31
|
+
if (block.type === "tool_use")
|
|
32
|
+
return [describeTool(block.name ?? "", block.input ?? {})];
|
|
33
|
+
return [];
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// Cerber denies most tools on purpose. When the run reaches for one anyway,
|
|
37
|
+
// saying so beats a silent gap in the story.
|
|
38
|
+
if (event.type === "system" && event.subtype === "permission_denied") {
|
|
39
|
+
const tool = typeof event.tool_name === "string" ? event.tool_name : "a tool";
|
|
40
|
+
return [`not allowed to use ${tool}`];
|
|
41
|
+
}
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
function describeTool(name, input) {
|
|
45
|
+
const str = (key) => (typeof input[key] === "string" ? input[key] : "");
|
|
46
|
+
switch (name) {
|
|
47
|
+
case "Read": {
|
|
48
|
+
const file = short(str("file_path"));
|
|
49
|
+
return file ? `reading ${file}` : "reading a file";
|
|
50
|
+
}
|
|
51
|
+
case "Grep": {
|
|
52
|
+
const where = short(str("path"));
|
|
53
|
+
return clip(`searching for ${str("pattern")}${where ? ` in ${where}` : ""}`);
|
|
54
|
+
}
|
|
55
|
+
case "Glob":
|
|
56
|
+
return clip(`looking for files matching ${str("pattern")}`);
|
|
57
|
+
case "Bash": {
|
|
58
|
+
// The description is the model's own words for what it is about to run;
|
|
59
|
+
// the command is what it will actually do. The command is the honest one.
|
|
60
|
+
const command = collapse(str("command"));
|
|
61
|
+
return command ? clip(`running ${command}`) : "running a command";
|
|
62
|
+
}
|
|
63
|
+
case "WebSearch":
|
|
64
|
+
return clip(`searching the web for ${str("query")}`);
|
|
65
|
+
case "WebFetch":
|
|
66
|
+
return clip(`fetching ${str("url")}`);
|
|
67
|
+
case "TodoWrite":
|
|
68
|
+
return "planning the rest of the turn";
|
|
69
|
+
default:
|
|
70
|
+
return name ? `using ${name}` : "using a tool";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Paths in a checkout are absolute and long; the repo-relative tail is the useful part. */
|
|
74
|
+
function short(file) {
|
|
75
|
+
if (!file)
|
|
76
|
+
return "";
|
|
77
|
+
const parts = file.split("/").filter(Boolean);
|
|
78
|
+
return parts.length > 3 ? parts.slice(-3).join("/") : file;
|
|
79
|
+
}
|
|
80
|
+
function looksLikeJson(text) {
|
|
81
|
+
return text.startsWith("{") || text.startsWith("```");
|
|
82
|
+
}
|
|
83
|
+
function collapse(text) {
|
|
84
|
+
return text.replace(/\s+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
function clip(text) {
|
|
87
|
+
return text.length > MAX ? `${text.slice(0, MAX - 1)}…` : text;
|
|
88
|
+
}
|
package/dist/runner/review.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadArtifact, saveArtifact } from "../core/state.js";
|
|
|
7
7
|
import { loadConfig } from "../core/config.js";
|
|
8
8
|
import { decideTrust, membershipQueries, parseTrustRules } from "../core/trust.js";
|
|
9
9
|
import { extractJson, runClaude, unauthenticatedEnv } from "./claude.js";
|
|
10
|
+
import { describeEvent } from "./progress.js";
|
|
10
11
|
import { beginReview, endReview, isReviewRunning } from "./inflight.js";
|
|
11
12
|
import { buildReviewPrompt, buildRetryPrompt } from "./prompt.js";
|
|
12
13
|
/** All an untrusted reviewer needs from a checkout — everything else stays off. */
|
|
@@ -132,6 +133,7 @@ async function runReview(ref, opts) {
|
|
|
132
133
|
// this run just replaced, and resetting to it would restore a dead review.
|
|
133
134
|
chat: existing?.chat ?? [],
|
|
134
135
|
preChat: null,
|
|
136
|
+
pendingChat: null,
|
|
135
137
|
};
|
|
136
138
|
await saveArtifact(artifact);
|
|
137
139
|
const { prompt, truncated } = buildReviewPrompt(pr, diff, { source: source !== null, trusted });
|
|
@@ -231,6 +233,9 @@ async function runAiReview(prompt, opts, source, trusted) {
|
|
|
231
233
|
const empty = await createRunDir();
|
|
232
234
|
const claudeOpts = {
|
|
233
235
|
model: opts.model,
|
|
236
|
+
// Say what the review is doing while it does it, rather than going quiet
|
|
237
|
+
// for minutes. `cerber review` and the daemon log print these.
|
|
238
|
+
onEvent: (event) => opts.onProgress && describeEvent(event).forEach(opts.onProgress),
|
|
234
239
|
cwd: source ?? empty,
|
|
235
240
|
env: unauthenticatedEnv(process.env, empty),
|
|
236
241
|
allowedTools: source ? (trusted ? TRUSTED_TOOLS : READ_TOOLS) : undefined,
|
package/dist/server/daemon.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { isReviewRunning } from "../runner/inflight.js";
|
|
|
19
19
|
import { reviewPr } from "../runner/review.js";
|
|
20
20
|
import { runChatTurn } from "../runner/chat.js";
|
|
21
21
|
import { mergeConcurrentEdits, restoreReview } from "../core/revise.js";
|
|
22
|
+
import { progressWriter } from "./progress.js";
|
|
22
23
|
import { ChatTurnSchema } from "../core/artifact.js";
|
|
23
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
export async function buildApp(opts) {
|
|
@@ -328,6 +329,7 @@ export async function buildApp(opts) {
|
|
|
328
329
|
/** The user said, in so many words, that the run may touch their comments. */
|
|
329
330
|
allowUserComments: z.boolean().optional(),
|
|
330
331
|
});
|
|
332
|
+
const turnInFlight = (a) => a.pendingChat != null && a.pendingChat.error == null;
|
|
331
333
|
app.post("/api/reviews/:key/chat", async (c) => {
|
|
332
334
|
const key = c.req.param("key");
|
|
333
335
|
const artifact = await loadArtifactByKey(key);
|
|
@@ -337,7 +339,7 @@ export async function buildApp(opts) {
|
|
|
337
339
|
// rewrite history that GitHub already has.
|
|
338
340
|
if (artifact.sent)
|
|
339
341
|
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
340
|
-
if (isReviewRunning(artifact.id)) {
|
|
342
|
+
if (isReviewRunning(artifact.id) || turnInFlight(artifact)) {
|
|
341
343
|
return c.json({ error: "a run for this PR is already in flight" }, 409);
|
|
342
344
|
}
|
|
343
345
|
let request;
|
|
@@ -347,21 +349,72 @@ export async function buildApp(opts) {
|
|
|
347
349
|
catch (err) {
|
|
348
350
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
349
351
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
352
|
+
// Record the question before responding, so the cockpit's next poll — and a
|
|
353
|
+
// reload mid-turn — finds a turn being answered rather than a message that
|
|
354
|
+
// went nowhere.
|
|
355
|
+
const pending = await updateArtifactByKey(key, (a) => ({
|
|
356
|
+
...a,
|
|
357
|
+
pendingChat: {
|
|
358
|
+
message: request.message,
|
|
359
|
+
refs: request.refs ?? [],
|
|
360
|
+
startedAt: new Date().toISOString(),
|
|
361
|
+
progress: [],
|
|
362
|
+
error: null,
|
|
363
|
+
},
|
|
364
|
+
}));
|
|
365
|
+
// The turn narrates itself as it reads the code; those lines go onto the
|
|
366
|
+
// artifact so the cockpit's poll can show them instead of a spinner.
|
|
367
|
+
const progress = progressWriter(key);
|
|
368
|
+
// Minutes-long, like a re-review: run it detached and let the cockpit poll.
|
|
369
|
+
// Holding the request open is fine on localhost and fails behind a reverse
|
|
370
|
+
// proxy's read timeout, which tells the user it broke while the turn
|
|
371
|
+
// quietly succeeds — a worse failure than an honest error.
|
|
372
|
+
void runChatTurn(artifact, request.message, {
|
|
373
|
+
refs: request.refs,
|
|
374
|
+
allowUserComments: request.allowUserComments,
|
|
375
|
+
onProgress: (m) => {
|
|
376
|
+
console.log(`[chat ${artifact.id}] ${m}`);
|
|
377
|
+
progress.push(m);
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
.then(async (result) => {
|
|
381
|
+
// Let the narration finish writing first: an append that read the
|
|
382
|
+
// artifact before this point would otherwise land on top of the answer.
|
|
383
|
+
await progress.stop();
|
|
384
|
+
// The cockpit stays live throughout, so fold the result onto whatever
|
|
385
|
+
// is on disk now rather than overwriting it — an edit the user made
|
|
386
|
+
// while waiting must not vanish when the answer lands.
|
|
387
|
+
await updateArtifactByKey(key, (current) => ({
|
|
388
|
+
...mergeConcurrentEdits(artifact, result.artifact, current),
|
|
389
|
+
pendingChat: null,
|
|
390
|
+
}));
|
|
391
|
+
})
|
|
392
|
+
.catch(async (err) => {
|
|
393
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
394
|
+
console.error(`[chat ${artifact.id}] failed: ${message}`);
|
|
395
|
+
await progress.stop();
|
|
396
|
+
// Nobody is holding a response to hand this to any more, so the failure
|
|
397
|
+
// lives on the artifact against the question that caused it — under
|
|
398
|
+
// whatever the turn had managed to do before it died.
|
|
399
|
+
await updateArtifactByKey(key, (a) => ({
|
|
400
|
+
...a,
|
|
401
|
+
pendingChat: a.pendingChat ? { ...a.pendingChat, error: message } : null,
|
|
402
|
+
})).catch(() => { });
|
|
403
|
+
});
|
|
404
|
+
return c.json(pending, 202);
|
|
405
|
+
});
|
|
406
|
+
// Clear a turn that failed. Only that: a turn still being answered would come
|
|
407
|
+
// back on the next write and the cockpit would have lied about dropping it.
|
|
408
|
+
app.delete("/api/reviews/:key/chat/pending", async (c) => {
|
|
409
|
+
const key = c.req.param("key");
|
|
410
|
+
const artifact = await loadArtifactByKey(key);
|
|
411
|
+
if (!artifact)
|
|
412
|
+
return c.json({ error: "not found" }, 404);
|
|
413
|
+
if (turnInFlight(artifact)) {
|
|
414
|
+
return c.json({ error: "that turn is still being answered" }, 409);
|
|
364
415
|
}
|
|
416
|
+
const updated = await updateArtifactByKey(key, (a) => ({ ...a, pendingChat: null }));
|
|
417
|
+
return c.json(updated);
|
|
365
418
|
});
|
|
366
419
|
// Put the review back the way it was before the conversation started. The
|
|
367
420
|
// conversation itself stays — you lose the edits, not the reasoning.
|
|
@@ -372,6 +425,11 @@ export async function buildApp(opts) {
|
|
|
372
425
|
return c.json({ error: "not found" }, 404);
|
|
373
426
|
if (artifact.sent)
|
|
374
427
|
return c.json({ error: `already sent at ${artifact.sent.at}` }, 409);
|
|
428
|
+
// A turn in flight will fold its own result on top of whatever is on disk
|
|
429
|
+
// when it lands, so resetting now would be undone a minute later.
|
|
430
|
+
if (turnInFlight(artifact)) {
|
|
431
|
+
return c.json({ error: "a turn is still being answered — wait for it to land" }, 409);
|
|
432
|
+
}
|
|
375
433
|
if (!artifact.preChat)
|
|
376
434
|
return c.json({ error: "this review has not been chatted about" }, 409);
|
|
377
435
|
const updated = await updateArtifactByKey(key, (a) => a.preChat ? restoreReview(a, a.preChat) : a);
|
|
@@ -443,7 +501,7 @@ export async function buildApp(opts) {
|
|
|
443
501
|
export async function startServer(opts) {
|
|
444
502
|
const cleared = await reconcileRunning();
|
|
445
503
|
if (cleared > 0) {
|
|
446
|
-
console.log(`Cleared ${cleared}
|
|
504
|
+
console.log(`Cleared ${cleared} AI run(s) left in flight by a previous process — start them again from the cockpit.`);
|
|
447
505
|
}
|
|
448
506
|
const app = await buildApp(opts);
|
|
449
507
|
serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { updateArtifactByKey } from "../core/state.js";
|
|
2
|
+
/**
|
|
3
|
+
* Putting a running turn's narration where the cockpit can see it.
|
|
4
|
+
*
|
|
5
|
+
* The turn is detached, so there is no connection to stream down: the artifact
|
|
6
|
+
* is the channel, exactly as it is for the pending question itself. That keeps
|
|
7
|
+
* the story on a reload and behind a proxy, and costs no new transport.
|
|
8
|
+
*
|
|
9
|
+
* Two things it must not do. It must not rewrite the whole artifact — diff and
|
|
10
|
+
* all — once per line, so lines are coalesced into one write every couple of
|
|
11
|
+
* seconds, which is as often as the cockpit polls anyway. And it must not still
|
|
12
|
+
* be writing when the turn lands: an append that read the artifact before the
|
|
13
|
+
* answer was folded in would write back the "still thinking" version on top of
|
|
14
|
+
* it. `stop()` is awaited before the result is saved, and refuses every append
|
|
15
|
+
* after that.
|
|
16
|
+
*/
|
|
17
|
+
/** Enough to see what it has been up to, not so much that the panel scrolls away. */
|
|
18
|
+
const KEEP = 30;
|
|
19
|
+
export function progressWriter(key, opts = {}) {
|
|
20
|
+
const everyMs = opts.everyMs ?? 2_000;
|
|
21
|
+
const keep = opts.keep ?? KEEP;
|
|
22
|
+
const update = opts.update ?? updateArtifactByKey;
|
|
23
|
+
let queued = [];
|
|
24
|
+
let last = null;
|
|
25
|
+
let timer = null;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
// Writes chain onto each other so two flushes can never interleave their
|
|
28
|
+
// read-modify-write, and so stop() has something to await.
|
|
29
|
+
let writes = Promise.resolve();
|
|
30
|
+
const flush = () => {
|
|
31
|
+
timer = null;
|
|
32
|
+
const lines = queued;
|
|
33
|
+
queued = [];
|
|
34
|
+
if (!lines.length)
|
|
35
|
+
return;
|
|
36
|
+
writes = writes.then(() => update(key, (a) =>
|
|
37
|
+
// No pending turn means it already landed (or was dismissed) — there is
|
|
38
|
+
// nothing left for this narration to describe.
|
|
39
|
+
a.pendingChat
|
|
40
|
+
? {
|
|
41
|
+
...a,
|
|
42
|
+
pendingChat: {
|
|
43
|
+
...a.pendingChat,
|
|
44
|
+
progress: [...a.pendingChat.progress, ...lines].slice(-keep),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
: a).catch(() => {
|
|
48
|
+
// A line that never reaches disk is a line the user doesn't see. The
|
|
49
|
+
// turn itself is unaffected, so it is not worth failing over.
|
|
50
|
+
}));
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
push(line) {
|
|
54
|
+
if (stopped || !line || line === last)
|
|
55
|
+
return;
|
|
56
|
+
last = line;
|
|
57
|
+
queued.push(line);
|
|
58
|
+
if (!timer)
|
|
59
|
+
timer = setTimeout(flush, everyMs);
|
|
60
|
+
},
|
|
61
|
+
async stop() {
|
|
62
|
+
stopped = true;
|
|
63
|
+
if (timer)
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
// Flush what's left rather than dropping it: on the failure path the
|
|
66
|
+
// pending turn survives, and the last thing it did before dying is
|
|
67
|
+
// exactly what the user wants to see.
|
|
68
|
+
flush();
|
|
69
|
+
await writes;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fullstackhouse/cerber",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "AI code-review cockpit: Claude reviews your PRs into local artifacts; nothing reaches GitHub until you say so.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:fullstackhouse/cerber",
|