@saadnvd1/agent-os 0.3.723 → 0.3.731
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 +1 -0
- package/app/api/bus/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +8 -1
- package/components/Chat/TurnCopy.tsx +84 -0
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/exports/queries.ts +11 -0
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/groupDrop.ts +36 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/lib/windows-ports.ts +36 -18
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { agentosHome } from "../agentos-home";
|
|
5
|
+
import { git } from "../git-exec";
|
|
6
|
+
import { adviseHeavy } from "../load/advise";
|
|
7
|
+
import { checkEnv } from "../orchestrator/claude-cli";
|
|
8
|
+
import { compactCommand, signatureOf } from "./compactEvidence";
|
|
9
|
+
import type { CommandResult, EvidenceSummary } from "./types";
|
|
10
|
+
|
|
11
|
+
export const EVIDENCE_TIMEOUT_MS = 10 * 60_000;
|
|
12
|
+
const OUTPUT_KEEP = 2_000_000;
|
|
13
|
+
|
|
14
|
+
export interface EvidenceCommands {
|
|
15
|
+
typecheck: string | null;
|
|
16
|
+
test: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TYPECHECK = /\btypecheck\b|\btsc\b/;
|
|
20
|
+
const PLACEHOLDER = /<[^<>]*>/;
|
|
21
|
+
const TEST_FILE =
|
|
22
|
+
/\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]+\.py$|_test\.(?:go|py|rb)$/;
|
|
23
|
+
|
|
24
|
+
// The yardstick is the BASE branch's agentos.json (or .dispatch.json): the
|
|
25
|
+
// coder can't make a check pass by editing the command that runs it.
|
|
26
|
+
export async function commandsFromBase(
|
|
27
|
+
repo: string,
|
|
28
|
+
base: string
|
|
29
|
+
): Promise<EvidenceCommands> {
|
|
30
|
+
for (const file of ["agentos.json", ".dispatch.json"]) {
|
|
31
|
+
let text: string;
|
|
32
|
+
try {
|
|
33
|
+
text = await git(repo, ["show", `${base}:${file}`]);
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const test = (JSON.parse(text) as { test?: unknown }).test;
|
|
39
|
+
if (typeof test !== "string" || !test.trim()) continue;
|
|
40
|
+
const parts = test
|
|
41
|
+
.split("&&")
|
|
42
|
+
.map((s) => s.trim())
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
const type = parts.filter((p) => TYPECHECK.test(p));
|
|
45
|
+
const rest = parts.filter((p) => !TYPECHECK.test(p));
|
|
46
|
+
return {
|
|
47
|
+
typecheck: type.length ? type.join(" && ") : null,
|
|
48
|
+
test: rest.length ? rest.join(" && ") : null,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { typecheck: null, test: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The test files in the diff, and the ones beside changed source files.
|
|
58
|
+
export async function testFilesFor(
|
|
59
|
+
dir: string,
|
|
60
|
+
base: string,
|
|
61
|
+
sha: string
|
|
62
|
+
): Promise<string[]> {
|
|
63
|
+
const out = await git(dir, [
|
|
64
|
+
"diff",
|
|
65
|
+
"--name-only",
|
|
66
|
+
"--no-renames",
|
|
67
|
+
`${base}...${sha}`,
|
|
68
|
+
]).catch(() => "");
|
|
69
|
+
const changed = out
|
|
70
|
+
.split("\n")
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
const found = new Set<string>();
|
|
74
|
+
for (const f of changed) {
|
|
75
|
+
if (TEST_FILE.test(f)) {
|
|
76
|
+
if (fs.existsSync(path.join(dir, f))) found.add(f);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const ext = path.extname(f);
|
|
80
|
+
const stem = f.slice(0, f.length - ext.length);
|
|
81
|
+
for (const suffix of [".test", ".spec"])
|
|
82
|
+
for (const e of [ext, ".ts", ".tsx"]) {
|
|
83
|
+
const candidate = `${stem}${suffix}${e}`;
|
|
84
|
+
if (fs.existsSync(path.join(dir, candidate))) found.add(candidate);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...found].sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// "<the test files you touched>" and its like become the files; with none the
|
|
91
|
+
// command is skipped. A command with no placeholder is the whole suite.
|
|
92
|
+
export function fillPlaceholder(
|
|
93
|
+
command: string,
|
|
94
|
+
files: string[]
|
|
95
|
+
): { command: string | null; why?: string } {
|
|
96
|
+
if (!PLACEHOLDER.test(command)) return { command };
|
|
97
|
+
if (!files.length)
|
|
98
|
+
return { command: null, why: "no test files in the diff to run" };
|
|
99
|
+
return {
|
|
100
|
+
command: command.replace(
|
|
101
|
+
PLACEHOLDER,
|
|
102
|
+
files.map((f) => JSON.stringify(f)).join(" ")
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RunOutput {
|
|
108
|
+
exitCode: number | null;
|
|
109
|
+
output: string;
|
|
110
|
+
durationMs: number;
|
|
111
|
+
}
|
|
112
|
+
export type Runner = (
|
|
113
|
+
command: string,
|
|
114
|
+
cwd: string,
|
|
115
|
+
timeoutMs: number
|
|
116
|
+
) => Promise<RunOutput>;
|
|
117
|
+
|
|
118
|
+
export const runShell: Runner = (command, cwd, timeoutMs) =>
|
|
119
|
+
new Promise((resolve) => {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
const child = spawn(command, {
|
|
122
|
+
cwd,
|
|
123
|
+
shell: true,
|
|
124
|
+
env: { ...checkEnv(), CI: "1", FORCE_COLOR: "0" },
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
detached: true,
|
|
127
|
+
});
|
|
128
|
+
if (child.pid)
|
|
129
|
+
try {
|
|
130
|
+
adviseHeavy({
|
|
131
|
+
key: `pid:${child.pid}`,
|
|
132
|
+
sessionId: null,
|
|
133
|
+
label: "loop checks",
|
|
134
|
+
pid: child.pid,
|
|
135
|
+
});
|
|
136
|
+
} catch {}
|
|
137
|
+
let output = "";
|
|
138
|
+
const take = (d: Buffer) => {
|
|
139
|
+
output += d;
|
|
140
|
+
if (output.length > OUTPUT_KEEP) output = output.slice(-OUTPUT_KEEP);
|
|
141
|
+
};
|
|
142
|
+
child.stdout.on("data", take);
|
|
143
|
+
child.stderr.on("data", take);
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
147
|
+
} catch {}
|
|
148
|
+
output += `\n[timed out after ${Math.round(timeoutMs / 1000)}s]`;
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
child.on("close", (code) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve({ exitCode: code, output, durationMs: Date.now() - started });
|
|
153
|
+
});
|
|
154
|
+
child.on("error", (e) => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
resolve({
|
|
157
|
+
exitCode: 127,
|
|
158
|
+
output: String(e),
|
|
159
|
+
durationMs: Date.now() - started,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// One evidence run at a time on the machine, so loops don't stack suites.
|
|
165
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
166
|
+
const alone = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
167
|
+
const next = chain.then(fn, fn);
|
|
168
|
+
chain = next.catch(() => {});
|
|
169
|
+
return next;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const loopLogPath = (taskId: string, n: number) =>
|
|
173
|
+
agentosHome("loops", taskId, `${n}.log`);
|
|
174
|
+
|
|
175
|
+
export interface Gathered {
|
|
176
|
+
summary: EvidenceSummary;
|
|
177
|
+
signature: string | null;
|
|
178
|
+
logPath: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Typecheck, then the tests, in the task's worktree. Output goes to a log
|
|
182
|
+
// file; what comes back is compact.
|
|
183
|
+
export async function gatherEvidence(input: {
|
|
184
|
+
taskId: string;
|
|
185
|
+
n: number;
|
|
186
|
+
dir: string;
|
|
187
|
+
commands: EvidenceCommands;
|
|
188
|
+
testFiles: string[];
|
|
189
|
+
run?: Runner;
|
|
190
|
+
timeoutMs?: number;
|
|
191
|
+
}): Promise<Gathered> {
|
|
192
|
+
const run = input.run ?? runShell;
|
|
193
|
+
const results: CommandResult[] = [];
|
|
194
|
+
const outputs = new Map<string, string>();
|
|
195
|
+
const log: string[] = [];
|
|
196
|
+
const todo: { name: CommandResult["name"]; raw: string | null }[] = [
|
|
197
|
+
{ name: "typecheck", raw: input.commands.typecheck },
|
|
198
|
+
{ name: "test", raw: input.commands.test },
|
|
199
|
+
];
|
|
200
|
+
await alone(async () => {
|
|
201
|
+
for (const { name, raw } of todo) {
|
|
202
|
+
if (!raw) continue;
|
|
203
|
+
const filled = fillPlaceholder(raw, input.testFiles);
|
|
204
|
+
if (!filled.command) {
|
|
205
|
+
results.push({
|
|
206
|
+
name,
|
|
207
|
+
command: raw,
|
|
208
|
+
exitCode: null,
|
|
209
|
+
durationMs: 0,
|
|
210
|
+
passed: [],
|
|
211
|
+
failed: [],
|
|
212
|
+
errors: [],
|
|
213
|
+
excerpt: "",
|
|
214
|
+
skipped: filled.why,
|
|
215
|
+
});
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const r = await run(
|
|
219
|
+
filled.command,
|
|
220
|
+
input.dir,
|
|
221
|
+
input.timeoutMs ?? EVIDENCE_TIMEOUT_MS
|
|
222
|
+
);
|
|
223
|
+
log.push(`$ ${filled.command}\n${r.output}\n[exit ${r.exitCode}]\n`);
|
|
224
|
+
outputs.set(name, r.output);
|
|
225
|
+
results.push(
|
|
226
|
+
compactCommand({
|
|
227
|
+
name,
|
|
228
|
+
command: filled.command,
|
|
229
|
+
exitCode: r.exitCode,
|
|
230
|
+
durationMs: r.durationMs,
|
|
231
|
+
output: r.output,
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const ran = results.filter((r) => !r.skipped);
|
|
237
|
+
const green = ran.length > 0 && ran.every((r) => r.exitCode === 0);
|
|
238
|
+
const note = !ran.length
|
|
239
|
+
? "no check could run: " +
|
|
240
|
+
(results
|
|
241
|
+
.map((r) => r.skipped)
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.join("; ") || "no test command on the base branch")
|
|
244
|
+
: results.some((r) => r.skipped)
|
|
245
|
+
? results
|
|
246
|
+
.filter((r) => r.skipped)
|
|
247
|
+
.map((r) => `${r.name} skipped: ${r.skipped}`)
|
|
248
|
+
.join("; ")
|
|
249
|
+
: undefined;
|
|
250
|
+
let logPath: string | null = null;
|
|
251
|
+
if (log.length) {
|
|
252
|
+
try {
|
|
253
|
+
logPath = loopLogPath(input.taskId, input.n);
|
|
254
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
255
|
+
fs.writeFileSync(logPath, log.join("\n"));
|
|
256
|
+
} catch {
|
|
257
|
+
logPath = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
summary: { green, commands: results, ci: [], ...(note ? { note } : {}) },
|
|
262
|
+
signature: signatureOf(results, outputs),
|
|
263
|
+
logPath,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Stop } from "./decideStop";
|
|
2
|
+
import type { LoopRow } from "./types";
|
|
3
|
+
|
|
4
|
+
// Where other features plug into the loop without the loop importing them
|
|
5
|
+
// (the plain-English health reports and operating modes, BR02-19, set these
|
|
6
|
+
// once they land; until then they are empty).
|
|
7
|
+
export interface LoopHooks {
|
|
8
|
+
// A cycle's checks and analysis are stored: ask for a report, fire and
|
|
9
|
+
// forget. The summary is the analyst's own words, not evidence.
|
|
10
|
+
onCycleSettled?: (input: {
|
|
11
|
+
taskId: string;
|
|
12
|
+
cycleId: number;
|
|
13
|
+
analystSummary: string | null;
|
|
14
|
+
}) => void;
|
|
15
|
+
// The loop began or ended on a task: the operating mode follows.
|
|
16
|
+
onStart?: (loop: LoopRow) => void;
|
|
17
|
+
onStop?: (
|
|
18
|
+
loop: LoopRow,
|
|
19
|
+
stop: Stop,
|
|
20
|
+
pendingInstruction: string | null
|
|
21
|
+
) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let hooks: LoopHooks = {};
|
|
25
|
+
|
|
26
|
+
export const setLoopHooks = (next: LoopHooks) => {
|
|
27
|
+
hooks = next;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// A hook never breaks the loop.
|
|
31
|
+
export function callHook<K extends keyof LoopHooks>(
|
|
32
|
+
name: K,
|
|
33
|
+
...args: Parameters<NonNullable<LoopHooks[K]>>
|
|
34
|
+
): void {
|
|
35
|
+
try {
|
|
36
|
+
(hooks[name] as ((...a: unknown[]) => void) | undefined)?.(...args);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(`Loop hook ${name}:`, error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./controls";
|
|
2
|
+
export { tickLoops } from "./tickLoops";
|
|
3
|
+
export { setLoopHooks, type LoopHooks } from "./hooks";
|
|
4
|
+
export { draftCriteria } from "./draftCriteria";
|
|
5
|
+
export { liveLoopFor, latestLoopFor } from "./store";
|
|
6
|
+
export type * from "./types";
|
|
7
|
+
export { loopSummary, loopDigestNote } from "./summary";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { refuse } from "../tasks/contract/authorize";
|
|
4
|
+
import { requireApprover } from "../security/approver";
|
|
5
|
+
import { db, type Session } from "../db";
|
|
6
|
+
import { LoopError } from "./types";
|
|
7
|
+
|
|
8
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
9
|
+
|
|
10
|
+
export const criterionSchema = z.object({
|
|
11
|
+
text: z.string().max(2000),
|
|
12
|
+
source: z.enum(["card", "brief", "plan", "user"]).optional(),
|
|
13
|
+
evidenceKind: z
|
|
14
|
+
.enum(["test", "diff", "typecheck", "ci"])
|
|
15
|
+
.nullable()
|
|
16
|
+
.optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const limitsSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
maxCycles: z.number(),
|
|
22
|
+
maxCostUsd: z.number(),
|
|
23
|
+
maxMinutes: z.number(),
|
|
24
|
+
graceSeconds: z.number(),
|
|
25
|
+
stallCycles: z.number(),
|
|
26
|
+
repeatCycles: z.number(),
|
|
27
|
+
})
|
|
28
|
+
.partial();
|
|
29
|
+
|
|
30
|
+
// A task's loop route: the same device rule as the task's other routes, a
|
|
31
|
+
// zod-checked body, and the loop's own errors as their status codes.
|
|
32
|
+
export async function loopRoute<B = undefined>(
|
|
33
|
+
request: NextRequest,
|
|
34
|
+
{ params }: Ctx,
|
|
35
|
+
opts: {
|
|
36
|
+
schema?: z.ZodType<B>;
|
|
37
|
+
// Raises what the loop may spend or skips a check: a device that can't
|
|
38
|
+
// answer asks may not.
|
|
39
|
+
approver?: (body: B) => boolean;
|
|
40
|
+
},
|
|
41
|
+
run: (
|
|
42
|
+
task: Session,
|
|
43
|
+
body: B,
|
|
44
|
+
device: string | null
|
|
45
|
+
) => Promise<object> | object
|
|
46
|
+
): Promise<NextResponse> {
|
|
47
|
+
const id = (await params).id;
|
|
48
|
+
const refused = refuse(request, id, "open");
|
|
49
|
+
if (refused) return refused;
|
|
50
|
+
const task = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
|
|
51
|
+
Session | undefined;
|
|
52
|
+
if (!task)
|
|
53
|
+
return NextResponse.json({ error: "No such task" }, { status: 404 });
|
|
54
|
+
let body = undefined as B;
|
|
55
|
+
if (opts.schema) {
|
|
56
|
+
const parsed = opts.schema.safeParse(
|
|
57
|
+
await request.json().catch(() => ({}))
|
|
58
|
+
);
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ error: parsed.error.issues[0]?.message ?? "Bad request" },
|
|
62
|
+
{ status: 400 }
|
|
63
|
+
);
|
|
64
|
+
body = parsed.data;
|
|
65
|
+
}
|
|
66
|
+
let device: string | null = null;
|
|
67
|
+
if (opts.approver?.(body)) {
|
|
68
|
+
const gate = requireApprover(request);
|
|
69
|
+
if (!gate.ok) return gate.response;
|
|
70
|
+
device = gate.approver.deviceId;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return NextResponse.json(await run(task, body, device));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof LoopError)
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: error.message },
|
|
78
|
+
{ status: error.status }
|
|
79
|
+
);
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
82
|
+
}
|
|
83
|
+
}
|