pi-claude-supervisor 0.5.1 → 0.5.2
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/CHANGELOG.md +19 -0
- package/README.cn.md +15 -6
- package/README.md +20 -12
- package/docs/architecture.md +41 -11
- package/docs/automation-hardening-plan.md +211 -0
- package/docs/engineering-plan.md +24 -2
- package/docs/testing.md +59 -19
- package/package.json +1 -1
- package/src/decision-worker.ts +56 -9
- package/src/index.ts +29 -7
- package/src/redaction.ts +2 -1
- package/src/reviewer.ts +69 -16
- package/src/supervisor.ts +238 -37
- package/src/types.ts +6 -2
- package/src/verifier.ts +175 -20
- package/src/worker/process-adapter.ts +245 -47
- package/src/worker/tmux-adapter.ts +42 -3
package/src/decision-worker.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager,
|
|
|
6
6
|
import type { TaskSpec, WorkerEvent } from "./types.ts";
|
|
7
7
|
import { redactSensitive } from "./redaction.ts";
|
|
8
8
|
|
|
9
|
+
const MAX_DECISION_RESPONSE_BYTES = 32 * 1024;
|
|
10
|
+
const MAX_DECISION_FIELD_BYTES = 8 * 1024;
|
|
11
|
+
|
|
9
12
|
export type DecisionAction =
|
|
10
13
|
| { action: "continue" | "redirect" | "answer"; message: string; reason: string; confidence?: number }
|
|
11
14
|
| { action: "allow_permission" | "deny_permission"; requestId: string; toolUseId: string; reason: string; confidence?: number }
|
|
@@ -189,10 +192,37 @@ secrets, deployment, or any uncertainty. Never invent missing information.`;
|
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
async function askDecision(session: AgentSession, event: WorkerEvent, context: DecisionContext, timeoutMs: number): Promise<string> {
|
|
192
|
-
let
|
|
195
|
+
let current = "";
|
|
196
|
+
let finalMessage = "";
|
|
197
|
+
let capturingAssistant = false;
|
|
198
|
+
let currentTooLarge = false;
|
|
199
|
+
let finalTooLarge = false;
|
|
193
200
|
const unsubscribe = session.subscribe((value) => {
|
|
194
|
-
const record = value as unknown as { type?: string; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
195
|
-
|
|
201
|
+
const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
202
|
+
const role = record.message?.role;
|
|
203
|
+
if (record.type === "message_start" && role === "assistant") {
|
|
204
|
+
current = "";
|
|
205
|
+
currentTooLarge = false;
|
|
206
|
+
capturingAssistant = true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
|
|
210
|
+
capturingAssistant = true;
|
|
211
|
+
const delta = record.assistantMessageEvent.delta ?? "";
|
|
212
|
+
if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_DECISION_RESPONSE_BYTES) {
|
|
213
|
+
currentTooLarge = true;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
current += delta;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (record.type === "message_end" && role === "assistant") {
|
|
220
|
+
finalMessage = current || textFromMessage(record.message?.content);
|
|
221
|
+
finalTooLarge = currentTooLarge;
|
|
222
|
+
current = "";
|
|
223
|
+
currentTooLarge = false;
|
|
224
|
+
capturingAssistant = false;
|
|
225
|
+
}
|
|
196
226
|
});
|
|
197
227
|
try {
|
|
198
228
|
try {
|
|
@@ -204,11 +234,21 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
|
|
|
204
234
|
} finally {
|
|
205
235
|
unsubscribe();
|
|
206
236
|
}
|
|
207
|
-
return
|
|
237
|
+
if (finalTooLarge) return "";
|
|
238
|
+
return finalMessage || current;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function textFromMessage(content: unknown): string {
|
|
242
|
+
if (!Array.isArray(content)) return "";
|
|
243
|
+
return content
|
|
244
|
+
.filter((block): block is { type?: unknown; text?: unknown } => Boolean(block && typeof block === "object"))
|
|
245
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
246
|
+
.map((block) => block.text as string)
|
|
247
|
+
.join("");
|
|
208
248
|
}
|
|
209
249
|
|
|
210
250
|
function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
211
|
-
const candidate = text.
|
|
251
|
+
const candidate = text.trim();
|
|
212
252
|
if (!candidate) return { action: "ask_human", reason: "Decision Worker returned no JSON action" };
|
|
213
253
|
try {
|
|
214
254
|
const value = JSON.parse(candidate) as Record<string, unknown>;
|
|
@@ -216,11 +256,13 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
216
256
|
if (typeof action !== "string") throw new Error("missing action");
|
|
217
257
|
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "ask_human", "noop"]);
|
|
218
258
|
if (!allowed.has(action)) throw new Error(`unsupported action: ${action}`);
|
|
219
|
-
const reason = typeof value.reason === "string" && value.reason.trim() ? value.reason : "no reason provided";
|
|
220
|
-
const confidence = typeof value.confidence === "number"
|
|
259
|
+
const reason = typeof value.reason === "string" && value.reason.trim() ? boundedDecisionText(value.reason, "reason") : "no reason provided";
|
|
260
|
+
const confidence = value.confidence === undefined ? undefined : typeof value.confidence === "number" && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1
|
|
261
|
+
? value.confidence
|
|
262
|
+
: (() => { throw new Error("confidence must be a finite number between 0 and 1"); })();
|
|
221
263
|
if (["continue", "redirect", "answer"].includes(action)) {
|
|
222
264
|
if (typeof value.message !== "string" || !value.message.trim()) throw new Error("message required");
|
|
223
|
-
return { action: action as "continue" | "redirect" | "answer", message: value.message, reason, confidence };
|
|
265
|
+
return { action: action as "continue" | "redirect" | "answer", message: boundedDecisionText(value.message, "message"), reason, confidence };
|
|
224
266
|
}
|
|
225
267
|
if (["allow_permission", "deny_permission"].includes(action)) {
|
|
226
268
|
const permission = event.type === "permission_request" ? event.request : undefined;
|
|
@@ -229,12 +271,17 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
229
271
|
if (!requestId || !toolUseId) throw new Error("permission requestId/toolUseId required");
|
|
230
272
|
return { action: action as "allow_permission" | "deny_permission", requestId, toolUseId, reason, confidence };
|
|
231
273
|
}
|
|
232
|
-
return { action: action as "verify" | "retry" | "stop" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? value.question : undefined, confidence };
|
|
274
|
+
return { action: action as "verify" | "retry" | "stop" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
|
|
233
275
|
} catch (error) {
|
|
234
276
|
return { action: "ask_human", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
|
|
235
277
|
}
|
|
236
278
|
}
|
|
237
279
|
|
|
280
|
+
function boundedDecisionText(value: string, field: string): string {
|
|
281
|
+
if (Buffer.byteLength(value, "utf8") > MAX_DECISION_FIELD_BYTES) throw new Error(`${field} exceeds ${MAX_DECISION_FIELD_BYTES} bytes`);
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
|
|
238
285
|
function eventKey(event: WorkerEvent): string {
|
|
239
286
|
if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
|
|
240
287
|
if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { accessSync, chmodSync, constants as fsConstants, lstatSync, mkdirSync } from "node:fs";
|
|
5
6
|
import { readFile, realpath } from "node:fs/promises";
|
|
6
7
|
import { EventLog } from "./events.ts";
|
|
7
8
|
import { redactSensitive } from "./redaction.ts";
|
|
8
9
|
import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
|
|
9
10
|
import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
|
|
10
|
-
import { Supervisor, type DecisionSessionClosedInfo } from "./supervisor.ts";
|
|
11
|
+
import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
|
|
11
12
|
import { evaluateCommand } from "./policy.ts";
|
|
12
13
|
import { HumanWebhookNotifier } from "./notifications.ts";
|
|
13
14
|
import { loadSupervisorEnvironment } from "./config.ts";
|
|
@@ -40,6 +41,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
40
41
|
if (transport === "tmux" && cgroupMode === "required") {
|
|
41
42
|
throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
|
|
42
43
|
}
|
|
44
|
+
if (automation && transport === "process-pipe") {
|
|
45
|
+
throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux; explicit process-pipe is manual-only");
|
|
46
|
+
}
|
|
43
47
|
const adapter = transport === "tmux"
|
|
44
48
|
? new TmuxWorkerAdapter({ stateDir })
|
|
45
49
|
: new ProcessWorkerAdapter({
|
|
@@ -53,9 +57,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
53
57
|
format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
|
|
54
58
|
secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
|
|
55
59
|
});
|
|
60
|
+
const leaseDir = process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases");
|
|
61
|
+
assertRuntimeDirectory(stateDir, "state");
|
|
62
|
+
assertRuntimeDirectory(leaseDir, "lease");
|
|
56
63
|
const events = new EventLog(join(stateDir, "events.jsonl"));
|
|
57
64
|
const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
|
|
58
|
-
const cwdLeaseStore = new CwdLeaseStore(
|
|
65
|
+
const cwdLeaseStore = new CwdLeaseStore(leaseDir);
|
|
59
66
|
const reviewer = automation ? new PiReadOnlyReviewer() : undefined;
|
|
60
67
|
const sessions = new Map<string, Supervisor>();
|
|
61
68
|
const cwdLeases = new Map<string, CwdLeaseHandle>();
|
|
@@ -83,6 +90,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
83
90
|
const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
|
|
84
91
|
if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
|
|
85
92
|
};
|
|
93
|
+
const progress = (ctx: ExtensionContext) => (info: SupervisorProgress) => {
|
|
94
|
+
notify(ctx, `Worker progress: task=${info.taskId} phase=${info.phase} ${info.message}`, info.phase === "human" || info.phase === "failed" ? "warning" : "info");
|
|
95
|
+
};
|
|
86
96
|
const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
|
|
87
97
|
["starting", "running", "waiting", "paused"].includes(session.state));
|
|
88
98
|
const releaseLease = async (taskId: string): Promise<boolean> => {
|
|
@@ -275,6 +285,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
275
285
|
try {
|
|
276
286
|
const handle = await session.start({
|
|
277
287
|
taskId,
|
|
288
|
+
onProgress: progress(ctx),
|
|
278
289
|
task: goal,
|
|
279
290
|
spec,
|
|
280
291
|
cwd: cwdKey,
|
|
@@ -496,6 +507,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
496
507
|
try {
|
|
497
508
|
const handle = await session.start({
|
|
498
509
|
taskId: record.taskId,
|
|
510
|
+
onProgress: progress(ctx),
|
|
499
511
|
// Claude session resume is not supported by this adapter. Start
|
|
500
512
|
// idle so recovery never replays the original task; the operator
|
|
501
513
|
// must explicitly send the next instruction.
|
|
@@ -719,11 +731,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
719
731
|
})();
|
|
720
732
|
return shutdownPromise;
|
|
721
733
|
};
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
process.once("SIGTERM", onSignal);
|
|
726
|
-
process.once("SIGINT", onSignal);
|
|
734
|
+
// Pi owns process signal handling and invokes session_shutdown. Installing a
|
|
735
|
+
// second extension-level process.exit() handler races Pi's terminal restore and
|
|
736
|
+
// other extension shutdown hooks.
|
|
727
737
|
pi.on("session_shutdown", async () => {
|
|
728
738
|
await shutdown();
|
|
729
739
|
});
|
|
@@ -796,6 +806,18 @@ function delay(ms: number): Promise<void> {
|
|
|
796
806
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
797
807
|
}
|
|
798
808
|
|
|
809
|
+
function assertRuntimeDirectory(path: string, label: string): void {
|
|
810
|
+
try {
|
|
811
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
812
|
+
const info = lstatSync(path);
|
|
813
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} directory is not a real directory`);
|
|
814
|
+
chmodSync(path, 0o700);
|
|
815
|
+
accessSync(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
throw new Error(`${label} directory preflight failed for ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
799
821
|
function redactText(value: string): string {
|
|
800
822
|
return String(redactSensitive(value));
|
|
801
823
|
}
|
package/src/redaction.ts
CHANGED
|
@@ -10,8 +10,9 @@ export function redactSensitive(value: unknown, key?: string): unknown {
|
|
|
10
10
|
.replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
|
|
11
11
|
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
|
|
12
12
|
.replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
|
|
13
|
+
.replace(/\b((?:authorization)\s*:\s*(?:[A-Za-z]+\s+)?)[^\s,;)}\]]+/giu, "$1[REDACTED]")
|
|
14
|
+
.replace(/\b((?:x-api-key|api[-_]?key|token|secret|password|credential)\s*[:=]\s*)[^\s,;)}\]]+/giu, "$1[REDACTED]")
|
|
13
15
|
.replace(/\b((?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)))=([^\s]+)/gu, "$1=[REDACTED]")
|
|
14
|
-
.replace(/\b((?:authorization|x-api-key|api-key)\s*:\s*)[^\s]+/giu, "$1[REDACTED]")
|
|
15
16
|
.replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
|
|
16
17
|
}
|
|
17
18
|
if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
|
package/src/reviewer.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ReviewInput {
|
|
|
15
15
|
workerOutput?: string;
|
|
16
16
|
workerResult?: Record<string, unknown>;
|
|
17
17
|
round: number;
|
|
18
|
+
/** Abort a review when the operator stops or shuts down the Supervisor. */
|
|
19
|
+
signal?: AbortSignal;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface TaskReviewer {
|
|
@@ -38,6 +40,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
async review(input: ReviewInput): Promise<ReviewReport> {
|
|
43
|
+
if (input.evidence.complete === false || input.evidence.truncated === true) {
|
|
44
|
+
return invalidReview("repository evidence is incomplete or truncated", input.round, new Date().toISOString());
|
|
45
|
+
}
|
|
41
46
|
const resourceLoader = new DefaultResourceLoader({
|
|
42
47
|
cwd: input.cwd,
|
|
43
48
|
agentDir: getAgentDir(),
|
|
@@ -55,20 +60,40 @@ export class PiReadOnlyReviewer implements TaskReviewer {
|
|
|
55
60
|
thinkingLevel: "low",
|
|
56
61
|
tools: ["read", "grep", "find", "ls"],
|
|
57
62
|
});
|
|
58
|
-
let
|
|
59
|
-
let
|
|
63
|
+
let current = "";
|
|
64
|
+
let finalMessage = "";
|
|
65
|
+
let capturingAssistant = false;
|
|
66
|
+
let currentTooLarge = false;
|
|
67
|
+
let finalTooLarge = false;
|
|
60
68
|
const unsubscribe = session.subscribe((value) => {
|
|
61
|
-
const record = value as unknown as { type?: string; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
70
|
+
const role = record.message?.role;
|
|
71
|
+
if (record.type === "message_start" && role === "assistant") {
|
|
72
|
+
current = "";
|
|
73
|
+
currentTooLarge = false;
|
|
74
|
+
capturingAssistant = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
|
|
78
|
+
capturingAssistant = true;
|
|
79
|
+
const delta = record.assistantMessageEvent.delta ?? "";
|
|
80
|
+
if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_REVIEW_RESPONSE_BYTES) {
|
|
81
|
+
currentTooLarge = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
current += delta;
|
|
66
85
|
return;
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
if (record.type === "message_end" && role === "assistant") {
|
|
88
|
+
finalMessage = current || textFromMessage(record.message?.content);
|
|
89
|
+
finalTooLarge = currentTooLarge;
|
|
90
|
+
current = "";
|
|
91
|
+
currentTooLarge = false;
|
|
92
|
+
capturingAssistant = false;
|
|
93
|
+
}
|
|
69
94
|
});
|
|
70
95
|
try {
|
|
71
|
-
await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer");
|
|
96
|
+
await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer", input.signal);
|
|
72
97
|
} catch (error) {
|
|
73
98
|
await session.abort().catch(() => {});
|
|
74
99
|
throw error;
|
|
@@ -76,9 +101,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
|
|
|
76
101
|
unsubscribe();
|
|
77
102
|
session.dispose();
|
|
78
103
|
}
|
|
79
|
-
return
|
|
104
|
+
return finalTooLarge
|
|
80
105
|
? invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, input.round, new Date().toISOString())
|
|
81
|
-
: parseReview(
|
|
106
|
+
: parseReview(finalMessage || current, input.round);
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -103,10 +128,17 @@ ACCEPTANCE REPORT:
|
|
|
103
128
|
${boundedJson(input.acceptance)}
|
|
104
129
|
|
|
105
130
|
REPOSITORY STATUS:
|
|
106
|
-
${boundText(input.evidence.status, 8_000)}
|
|
131
|
+
${boundText(redactText(input.evidence.status), 8_000)}
|
|
132
|
+
|
|
133
|
+
REPOSITORY DIFF (HEAD-RELATIVE, UNTRUSTED):
|
|
134
|
+
${boundText(redactText(input.evidence.diff), 16_000)}
|
|
107
135
|
|
|
108
|
-
|
|
109
|
-
${boundText(input.evidence.
|
|
136
|
+
UNTRACKED FILE EVIDENCE (UNTRUSTED):
|
|
137
|
+
${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
|
|
138
|
+
|
|
139
|
+
EVIDENCE COMPLETE:
|
|
140
|
+
${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
|
|
141
|
+
If repository evidence is incomplete or truncated, do not return pass; return human and explain which evidence is unavailable.
|
|
110
142
|
|
|
111
143
|
WORKER OUTPUT (UNTRUSTED):
|
|
112
144
|
${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
|
|
@@ -118,6 +150,15 @@ REVIEW ROUND:
|
|
|
118
150
|
${input.round}`;
|
|
119
151
|
}
|
|
120
152
|
|
|
153
|
+
function textFromMessage(content: unknown): string {
|
|
154
|
+
if (!Array.isArray(content)) return "";
|
|
155
|
+
return content
|
|
156
|
+
.filter((block): block is { type?: unknown; text?: unknown } => Boolean(block && typeof block === "object"))
|
|
157
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
158
|
+
.map((block) => block.text as string)
|
|
159
|
+
.join("");
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
export function parseReview(text: string, round: number): ReviewReport {
|
|
122
163
|
const checkedAt = new Date().toISOString();
|
|
123
164
|
if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
|
|
@@ -179,16 +220,28 @@ function redactText(value: string): string {
|
|
|
179
220
|
return String(redactSensitive(value));
|
|
180
221
|
}
|
|
181
222
|
|
|
182
|
-
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
223
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal): Promise<T> {
|
|
183
224
|
let timer: NodeJS.Timeout | undefined;
|
|
225
|
+
let onAbort: (() => void) | undefined;
|
|
184
226
|
const timeout = new Promise<never>((_, reject) => {
|
|
185
227
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
186
228
|
timer.unref();
|
|
187
229
|
});
|
|
230
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
231
|
+
if (!signal) return;
|
|
232
|
+
onAbort = () => {
|
|
233
|
+
const error = new Error(`${label} aborted`);
|
|
234
|
+
error.name = "AbortError";
|
|
235
|
+
reject(error);
|
|
236
|
+
};
|
|
237
|
+
if (signal.aborted) onAbort();
|
|
238
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
239
|
+
});
|
|
188
240
|
try {
|
|
189
|
-
return await Promise.race([promise, timeout]);
|
|
241
|
+
return await Promise.race([promise, timeout, aborted]);
|
|
190
242
|
} finally {
|
|
191
243
|
if (timer) clearTimeout(timer);
|
|
244
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
192
245
|
}
|
|
193
246
|
}
|
|
194
247
|
|