@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,155 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { db } from "@/lib/db";
|
|
3
|
+
import { parsePorts } from "./allocatePorts";
|
|
4
|
+
import type {
|
|
5
|
+
DevServerPortRow,
|
|
6
|
+
Holder,
|
|
7
|
+
Listener,
|
|
8
|
+
PortCheck,
|
|
9
|
+
SessionPortRow,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const inside = (cwd: string, dir: string) =>
|
|
13
|
+
cwd === dir || cwd.startsWith(dir.endsWith("/") ? dir : `${dir}/`);
|
|
14
|
+
|
|
15
|
+
const holdsPort = (s: SessionPortRow, port: number) =>
|
|
16
|
+
s.devServerPort === port || Object.values(s.ports ?? {}).includes(port);
|
|
17
|
+
|
|
18
|
+
const asSession = (
|
|
19
|
+
s: SessionPortRow,
|
|
20
|
+
pid: number | null,
|
|
21
|
+
listening: boolean
|
|
22
|
+
): Holder => ({
|
|
23
|
+
kind: "session",
|
|
24
|
+
sessionId: s.id,
|
|
25
|
+
name: s.name,
|
|
26
|
+
project: s.project,
|
|
27
|
+
pid,
|
|
28
|
+
listening,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Who holds a port: the session whose worktree the listener runs in, the
|
|
33
|
+
* Dev Servers panel's server, a Windows process, or a process outside
|
|
34
|
+
* AgentOS. A listener whose cwd can't be read falls to the session that
|
|
35
|
+
* holds the port, if any. With nothing listening, a session or panel server
|
|
36
|
+
* that holds the port still has it. Null when it's free.
|
|
37
|
+
*/
|
|
38
|
+
export function whoHolds(
|
|
39
|
+
port: number,
|
|
40
|
+
listeners: Listener[],
|
|
41
|
+
sessions: SessionPortRow[],
|
|
42
|
+
devServers: DevServerPortRow[]
|
|
43
|
+
): Holder | null {
|
|
44
|
+
const l = listeners.find((x) => x.port === port);
|
|
45
|
+
const reserving = sessions.find((s) => holdsPort(s, port));
|
|
46
|
+
const panel = devServers.find((d) => d.ports.includes(port));
|
|
47
|
+
if (l) {
|
|
48
|
+
if (l.cwd) {
|
|
49
|
+
const s = sessions.find((x) => x.worktree && inside(l.cwd!, x.worktree));
|
|
50
|
+
if (s) return asSession(s, l.pid, true);
|
|
51
|
+
}
|
|
52
|
+
const byPid = devServers.find((d) => d.pid !== null && d.pid === l.pid);
|
|
53
|
+
if (byPid)
|
|
54
|
+
return { kind: "dev-server", id: byPid.id, name: byPid.name, pid: l.pid };
|
|
55
|
+
if (l.windows) return { kind: "windows", pid: l.pid };
|
|
56
|
+
if (!l.cwd && reserving) return asSession(reserving, l.pid, true);
|
|
57
|
+
return { kind: "outside", pid: l.pid, command: l.command, cwd: l.cwd };
|
|
58
|
+
}
|
|
59
|
+
if (reserving) return asSession(reserving, null, false);
|
|
60
|
+
if (panel)
|
|
61
|
+
return {
|
|
62
|
+
kind: "dev-server",
|
|
63
|
+
id: panel.id,
|
|
64
|
+
name: panel.name,
|
|
65
|
+
pid: panel.pid,
|
|
66
|
+
};
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function judgePort(
|
|
71
|
+
askerId: string,
|
|
72
|
+
name: string | null,
|
|
73
|
+
port: number,
|
|
74
|
+
listeners: Listener[] | null,
|
|
75
|
+
sessions: SessionPortRow[],
|
|
76
|
+
devServers: DevServerPortRow[]
|
|
77
|
+
): PortCheck {
|
|
78
|
+
const holder = whoHolds(port, listeners ?? [], sessions, devServers);
|
|
79
|
+
const mine = holder?.kind === "session" && holder.sessionId === askerId;
|
|
80
|
+
const free =
|
|
81
|
+
holder === null
|
|
82
|
+
? listeners === null
|
|
83
|
+
? null
|
|
84
|
+
: true
|
|
85
|
+
: mine && !holder.listening;
|
|
86
|
+
return { name, port, free, holder, mine };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const real = (p: string) => {
|
|
90
|
+
try {
|
|
91
|
+
return fs.realpathSync(p);
|
|
92
|
+
} catch {
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// This machine's sessions that run in a worktree or hold ports.
|
|
98
|
+
export function localPortRows(): {
|
|
99
|
+
sessions: SessionPortRow[];
|
|
100
|
+
devServers: DevServerPortRow[];
|
|
101
|
+
} {
|
|
102
|
+
const rows = db
|
|
103
|
+
.prepare(
|
|
104
|
+
`SELECT s.id, s.name, p.name AS project, s.worktree_path, s.ports, s.dev_server_port
|
|
105
|
+
FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
|
|
106
|
+
WHERE (s.host_id IS NULL OR s.host_id = 'local')
|
|
107
|
+
AND ((s.worktree_path IS NOT NULL AND s.archived_at IS NULL)
|
|
108
|
+
OR s.ports IS NOT NULL OR s.dev_server_port IS NOT NULL)`
|
|
109
|
+
)
|
|
110
|
+
.all() as {
|
|
111
|
+
id: string;
|
|
112
|
+
name: string;
|
|
113
|
+
project: string | null;
|
|
114
|
+
worktree_path: string | null;
|
|
115
|
+
ports: string | null;
|
|
116
|
+
dev_server_port: number | null;
|
|
117
|
+
}[];
|
|
118
|
+
const servers = db
|
|
119
|
+
.prepare(
|
|
120
|
+
`SELECT id, name, pid, ports FROM dev_servers WHERE status IN ('running', 'starting')`
|
|
121
|
+
)
|
|
122
|
+
.all() as { id: string; name: string; pid: number | null; ports: string }[];
|
|
123
|
+
return {
|
|
124
|
+
sessions: rows.map((r) => ({
|
|
125
|
+
id: r.id,
|
|
126
|
+
name: r.name,
|
|
127
|
+
project: r.project,
|
|
128
|
+
worktree: r.worktree_path ? real(r.worktree_path) : null,
|
|
129
|
+
ports: parsePorts(r.ports),
|
|
130
|
+
devServerPort: r.dev_server_port,
|
|
131
|
+
})),
|
|
132
|
+
devServers: servers.map((d) => {
|
|
133
|
+
let ports: number[] = [];
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(d.ports) as unknown;
|
|
136
|
+
if (Array.isArray(parsed))
|
|
137
|
+
ports = parsed.filter((p): p is number => Number.isInteger(p));
|
|
138
|
+
} catch {}
|
|
139
|
+
return { id: d.id, name: d.name, pid: d.pid, ports };
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function describeHolder(h: Holder): string {
|
|
145
|
+
switch (h.kind) {
|
|
146
|
+
case "session":
|
|
147
|
+
return `session "${h.name}"${h.project ? ` (project ${h.project})` : ""}${h.pid ? `, pid ${h.pid}` : ""}${h.listening ? "" : ", which holds it but isn't listening yet"}`;
|
|
148
|
+
case "dev-server":
|
|
149
|
+
return `the Dev Servers panel's "${h.name}"${h.pid ? `, pid ${h.pid}` : ""}`;
|
|
150
|
+
case "windows":
|
|
151
|
+
return `a Windows process${h.pid ? ` (pid ${h.pid})` : ""}`;
|
|
152
|
+
case "outside":
|
|
153
|
+
return `${h.pid ? `pid ${h.pid}` : "a process"}${h.command ? ` (${h.command})` : ""}${h.cwd ? ` in ${h.cwd}` : ""}, outside AgentOS`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -19,15 +19,17 @@ export function runningBrief(
|
|
|
19
19
|
config: ProjectConfig,
|
|
20
20
|
ports: Record<string, number>,
|
|
21
21
|
database: SessionDatabase | null = null,
|
|
22
|
-
warning: string | null = null
|
|
22
|
+
warning: string | null = null,
|
|
23
|
+
hardcoded: string[] = []
|
|
23
24
|
): string {
|
|
24
25
|
const lines: string[] = [];
|
|
25
26
|
if (warning) lines.push(`- ${warning}`);
|
|
26
27
|
const names = Object.entries(ports);
|
|
27
28
|
if (names.length)
|
|
28
29
|
lines.push(
|
|
29
|
-
`- Your ports, exported in your shell and no other session's: ${names.map(([n, p]) => `${n}=${p}`).join(", ")}. Start servers on these; never stop a process you didn't start (kill by your port: \`lsof -ti tcp:<port> -sTCP:LISTEN | xargs kill\`).`
|
|
30
|
+
`- Your ports, exported in your shell and no other session's: ${names.map(([n, p]) => `${n}=${p}`).join(", ")}. Start servers on these; never stop a process you didn't start (kill by your port: \`lsof -ti tcp:<port> -sTCP:LISTEN | xargs kill\`). If one is taken, never wait for it: \`aos port\` names the holder and gives you a free one.`
|
|
30
31
|
);
|
|
32
|
+
for (const note of hardcoded) lines.push(`- ${note}`);
|
|
31
33
|
if (config.dev)
|
|
32
34
|
lines.push(`- Dev server: \`${withPorts(config.dev, ports)}\``);
|
|
33
35
|
const ready = readyCommand(config, ports);
|
|
@@ -9,7 +9,8 @@ import os from "os";
|
|
|
9
9
|
import { sessionTmpDir } from "../sessions/leftovers";
|
|
10
10
|
import { db } from "../db";
|
|
11
11
|
import { getProject } from "../projects";
|
|
12
|
-
import { sessionPorts } from "../ports";
|
|
12
|
+
import { sessionPorts } from "../ports/allocatePorts";
|
|
13
|
+
import { findHardcodedPorts, hardcodedNote } from "../ports/findHardcodedPorts";
|
|
13
14
|
import {
|
|
14
15
|
loadProjectConfig,
|
|
15
16
|
NONE,
|
|
@@ -71,12 +72,27 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// With no config file the section is only the session's ports, so an agent
|
|
76
|
+
// in a project without agentos.json still knows it has them.
|
|
74
77
|
export function sessionRunningBrief(sessionId: string): string {
|
|
75
78
|
try {
|
|
76
79
|
const { config, source, warning, ports } = sessionConfig(sessionId);
|
|
80
|
+
if (!source && !ports) return "";
|
|
81
|
+
const dir = projectPathOf(sessionId);
|
|
82
|
+
const primary = ports ? Object.values(ports)[0] : undefined;
|
|
83
|
+
const hardcoded =
|
|
84
|
+
dir && primary !== undefined
|
|
85
|
+
? findHardcodedPorts(dir, config.dev, primary).map(hardcodedNote)
|
|
86
|
+
: [];
|
|
77
87
|
return source
|
|
78
|
-
? runningBrief(
|
|
79
|
-
|
|
88
|
+
? runningBrief(
|
|
89
|
+
config,
|
|
90
|
+
ports ?? {},
|
|
91
|
+
sessionDatabase(sessionId),
|
|
92
|
+
warning,
|
|
93
|
+
hardcoded
|
|
94
|
+
)
|
|
95
|
+
: runningBrief({}, ports ?? {}, null, null, hardcoded);
|
|
80
96
|
} catch (error) {
|
|
81
97
|
console.error(`[project-config] brief for ${sessionId}:`, error);
|
|
82
98
|
return "";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The drafted next prompt: approve (one click), edit-and-approve, or dismiss.
|
|
2
|
+
// A task with an analyst pairing uses the analyst's own recommendation, so
|
|
3
|
+
// there is one draft system; a plain task's draft lives on its report row.
|
|
4
|
+
|
|
5
|
+
import { db, type Session } from "../db";
|
|
6
|
+
import { deliver } from "../bus";
|
|
7
|
+
import {
|
|
8
|
+
dismissRecommendation,
|
|
9
|
+
forwardToCoder,
|
|
10
|
+
sendRecommendation,
|
|
11
|
+
} from "../analyst/forward";
|
|
12
|
+
import { statusDetector } from "../status-detector";
|
|
13
|
+
import { claimDraft, getReport, reopenDraft, setDraftText } from "./store";
|
|
14
|
+
|
|
15
|
+
export class DraftError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly status: 400 | 404 | 409 = 409
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const draftItemId = (reportId: number) =>
|
|
25
|
+
`user-report-draft-${reportId}`;
|
|
26
|
+
|
|
27
|
+
export async function approveDraft(
|
|
28
|
+
sessionId: string,
|
|
29
|
+
input: {
|
|
30
|
+
source: "report" | "analyst";
|
|
31
|
+
id: string;
|
|
32
|
+
linkId?: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
}
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (input.source === "analyst") {
|
|
37
|
+
if (!input.linkId) throw new DraftError("Missing pairing", 400);
|
|
38
|
+
await sendRecommendation(input.linkId, input.id, input.text);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const reportId = Number(input.id);
|
|
42
|
+
const report = getReport(reportId);
|
|
43
|
+
if (!report || report.sessionId !== sessionId || !report.draft)
|
|
44
|
+
throw new DraftError("No such draft", 404);
|
|
45
|
+
const text = (input.text ?? report.draft.text).trim();
|
|
46
|
+
if (!text) throw new DraftError("There is no prompt to send", 400);
|
|
47
|
+
if (!claimDraft(reportId, "sent"))
|
|
48
|
+
throw new DraftError(`This draft was already ${report.draft.status}`);
|
|
49
|
+
if (input.text && text !== report.draft.text)
|
|
50
|
+
setDraftText(reportId, text, "sent");
|
|
51
|
+
try {
|
|
52
|
+
const session = db
|
|
53
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
54
|
+
.get(sessionId) as Session | undefined;
|
|
55
|
+
if (!session) throw new DraftError("No such task", 404);
|
|
56
|
+
if (session.view === "chat")
|
|
57
|
+
await forwardToCoder(sessionId, {
|
|
58
|
+
text,
|
|
59
|
+
id: draftItemId(reportId),
|
|
60
|
+
label: "Analyst",
|
|
61
|
+
});
|
|
62
|
+
else {
|
|
63
|
+
await statusDetector.refreshCache();
|
|
64
|
+
const delivery = await deliver(session, text, { name: "Analyst" });
|
|
65
|
+
if (delivery.state === "failed") throw new Error(delivery.why);
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reopenDraft(reportId);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function dismissDraft(
|
|
74
|
+
sessionId: string,
|
|
75
|
+
input: { source: "report" | "analyst"; id: string; linkId?: string }
|
|
76
|
+
): void {
|
|
77
|
+
if (input.source === "analyst") {
|
|
78
|
+
if (!input.linkId) throw new DraftError("Missing pairing", 400);
|
|
79
|
+
dismissRecommendation(input.linkId, input.id);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const report = getReport(Number(input.id));
|
|
83
|
+
if (!report || report.sessionId !== sessionId || !report.draft)
|
|
84
|
+
throw new DraftError("No such draft", 404);
|
|
85
|
+
if (!claimDraft(report.id, "dismissed"))
|
|
86
|
+
throw new DraftError(`This draft was already ${report.draft.status}`);
|
|
87
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The task-end trigger, decoupled from the code that ends tasks: finishing a
|
|
2
|
+
// task calls taskEnded(), and the report generator is plugged in at server
|
|
3
|
+
// start (hooks.ts), so ending a task never reaches for gh or a model on its own.
|
|
4
|
+
|
|
5
|
+
let onEnd: ((sessionId: string) => void) | null = null;
|
|
6
|
+
|
|
7
|
+
export const setTaskEndHook = (fn: ((sessionId: string) => void) | null) => {
|
|
8
|
+
onEnd = fn;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function taskEnded(sessionId: string): void {
|
|
12
|
+
try {
|
|
13
|
+
onEnd?.(sessionId);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error(`Report at the end of ${sessionId}:`, error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The completion estimate. Computed here from evidence, never by a model:
|
|
2
|
+
// the model may only propose which evidence backs an acceptance item, and
|
|
3
|
+
// this decides whether the citation holds.
|
|
4
|
+
|
|
5
|
+
import type { Estimate, EstimateItem, EvidenceRef } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface AcceptanceItem {
|
|
8
|
+
label: string;
|
|
9
|
+
// Verified by the system itself (a loop criterion met with resolved
|
|
10
|
+
// evidence), so no model mapping is needed.
|
|
11
|
+
evidence?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Stages {
|
|
15
|
+
planned: boolean;
|
|
16
|
+
pushed: boolean;
|
|
17
|
+
prOpen: boolean;
|
|
18
|
+
ciGreen: boolean;
|
|
19
|
+
reviewed: boolean;
|
|
20
|
+
merged: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const STAGE_COUNT = 6;
|
|
24
|
+
|
|
25
|
+
// Diff, piece and log evidence is true of the work, not of one head.
|
|
26
|
+
const HEAD_FREE = new Set(["diff", "piece", "log", "accept", "state"]);
|
|
27
|
+
// A diff alone never verifies: a file changed shows nothing works.
|
|
28
|
+
const PROVING = new Set(["test", "ci", "review", "piece", "accept"]);
|
|
29
|
+
|
|
30
|
+
export interface Mapping {
|
|
31
|
+
item: number;
|
|
32
|
+
evidence: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function holds(
|
|
36
|
+
ids: string[],
|
|
37
|
+
evidence: Map<string, EvidenceRef>,
|
|
38
|
+
head: string | null
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!ids.length) return false;
|
|
41
|
+
const cited: EvidenceRef[] = [];
|
|
42
|
+
for (const id of ids) {
|
|
43
|
+
const ev = evidence.get(id);
|
|
44
|
+
if (!ev || ev.status !== "pass") return false;
|
|
45
|
+
if (!HEAD_FREE.has(ev.kind) && head && ev.sha && ev.sha !== head)
|
|
46
|
+
return false;
|
|
47
|
+
cited.push(ev);
|
|
48
|
+
}
|
|
49
|
+
if (!cited.some((e) => PROVING.has(e.kind))) return false;
|
|
50
|
+
const kinds = new Set(cited.map((e) => e.kind));
|
|
51
|
+
for (const e of evidence.values())
|
|
52
|
+
if (
|
|
53
|
+
e.status === "fail" &&
|
|
54
|
+
kinds.has(e.kind) &&
|
|
55
|
+
(HEAD_FREE.has(e.kind) || !head || !e.sha || e.sha === head)
|
|
56
|
+
)
|
|
57
|
+
return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function computeEstimate(input: {
|
|
62
|
+
head: string | null;
|
|
63
|
+
evidence: EvidenceRef[];
|
|
64
|
+
items: AcceptanceItem[];
|
|
65
|
+
mappings: Mapping[];
|
|
66
|
+
stages: Stages;
|
|
67
|
+
}): Estimate {
|
|
68
|
+
const byId = new Map(input.evidence.map((e) => [e.id, e]));
|
|
69
|
+
if (input.items.length) {
|
|
70
|
+
const items: EstimateItem[] = input.items.map((it, i) => {
|
|
71
|
+
const own = it.evidence ?? [];
|
|
72
|
+
if (own.length && holds(own, byId, input.head))
|
|
73
|
+
return { label: it.label, verified: true, evidence: own };
|
|
74
|
+
const mapped = input.mappings
|
|
75
|
+
.filter((m) => m.item === i)
|
|
76
|
+
.flatMap((m) => m.evidence);
|
|
77
|
+
const ids = [...new Set(mapped)];
|
|
78
|
+
const ok = holds(ids, byId, input.head);
|
|
79
|
+
return { label: it.label, verified: ok, evidence: ok ? ids : [] };
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
done: items.filter((i) => i.verified).length,
|
|
83
|
+
total: items.length,
|
|
84
|
+
basis: "acceptance",
|
|
85
|
+
items,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const s = input.stages;
|
|
89
|
+
const list: [string, boolean][] = [
|
|
90
|
+
["Plan approved", s.planned],
|
|
91
|
+
["Code pushed", s.pushed],
|
|
92
|
+
["Pull request open", s.prOpen],
|
|
93
|
+
["CI green on the latest push", s.ciGreen],
|
|
94
|
+
["Review passed", s.reviewed],
|
|
95
|
+
["Merged", s.merged],
|
|
96
|
+
];
|
|
97
|
+
return {
|
|
98
|
+
done: list.filter(([, ok]) => ok).length,
|
|
99
|
+
total: STAGE_COUNT,
|
|
100
|
+
basis: "stages",
|
|
101
|
+
items: list.map(([label, verified]) => ({
|
|
102
|
+
label,
|
|
103
|
+
verified,
|
|
104
|
+
evidence: [],
|
|
105
|
+
})),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Evidence items from what was read. Pure: the collectors in gather.ts do the
|
|
2
|
+
// reading, so these are tested with fixtures.
|
|
3
|
+
|
|
4
|
+
import type { TaskCheck, TaskReviewVerdict } from "../../tasks/loop-types";
|
|
5
|
+
import type { TaskState } from "../../tasks/state";
|
|
6
|
+
import type { DigestSignals } from "../../analyst/types";
|
|
7
|
+
import { latestChecks } from "../../analyst/signals";
|
|
8
|
+
import type { EvidenceRef } from "../types";
|
|
9
|
+
|
|
10
|
+
const EXCERPT = 1500;
|
|
11
|
+
const cap = (s: string, n = EXCERPT) =>
|
|
12
|
+
s.length > n ? `${s.slice(0, n)}…` : s;
|
|
13
|
+
|
|
14
|
+
export function ciEvidence(
|
|
15
|
+
checks: TaskCheck[],
|
|
16
|
+
head: string | null,
|
|
17
|
+
at: string
|
|
18
|
+
): EvidenceRef[] {
|
|
19
|
+
return checks
|
|
20
|
+
.filter((c) => c.state !== "skipped" && c.state !== "neutral")
|
|
21
|
+
.slice(0, 30)
|
|
22
|
+
.map((c) => ({
|
|
23
|
+
id: `ci:${c.name}`,
|
|
24
|
+
kind: "ci",
|
|
25
|
+
label: `CI · ${c.name}`,
|
|
26
|
+
status:
|
|
27
|
+
c.state === "pass"
|
|
28
|
+
? "pass"
|
|
29
|
+
: c.state === "pending"
|
|
30
|
+
? "pending"
|
|
31
|
+
: "fail",
|
|
32
|
+
at: c.completedAt ?? at,
|
|
33
|
+
sha: head ?? undefined,
|
|
34
|
+
url: c.url ?? undefined,
|
|
35
|
+
excerpt: c.log
|
|
36
|
+
? cap(`${c.log.step ?? ""}\n${c.log.tail}`.trim())
|
|
37
|
+
: undefined,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function reviewEvidence(
|
|
42
|
+
review: TaskReviewVerdict | null
|
|
43
|
+
): EvidenceRef[] {
|
|
44
|
+
if (!review) return [];
|
|
45
|
+
const findings = review.findings
|
|
46
|
+
.map(
|
|
47
|
+
(f) =>
|
|
48
|
+
`[${f.severity}] ${f.file ?? ""}${f.line ? `:${f.line}` : ""} ${f.summary}`
|
|
49
|
+
)
|
|
50
|
+
.join("\n");
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
id: `review:${review.sha.slice(0, 7)}`,
|
|
54
|
+
kind: "review",
|
|
55
|
+
label: "Independent review",
|
|
56
|
+
status:
|
|
57
|
+
review.status === "pass"
|
|
58
|
+
? "pass"
|
|
59
|
+
: review.status === "running"
|
|
60
|
+
? "pending"
|
|
61
|
+
: "fail",
|
|
62
|
+
at: review.at,
|
|
63
|
+
sha: review.sha,
|
|
64
|
+
excerpt: cap(`${review.summary}\n${findings}`.trim()),
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The last run of each test, build, typecheck or lint command.
|
|
70
|
+
export function testEvidence(
|
|
71
|
+
signals: DigestSignals,
|
|
72
|
+
sessionId: string,
|
|
73
|
+
head: string | null,
|
|
74
|
+
at: string
|
|
75
|
+
): EvidenceRef[] {
|
|
76
|
+
return latestChecks(signals)
|
|
77
|
+
.slice(0, 12)
|
|
78
|
+
.map((c, i) => ({
|
|
79
|
+
id: `test:${i + 1}`,
|
|
80
|
+
kind: "test",
|
|
81
|
+
label: c.command.slice(0, 120),
|
|
82
|
+
status: c.passed ? "pass" : "fail",
|
|
83
|
+
at,
|
|
84
|
+
sha: head ?? undefined,
|
|
85
|
+
href: `/?session=${sessionId}`,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function diffEvidence(
|
|
90
|
+
numstat: string,
|
|
91
|
+
sessionId: string,
|
|
92
|
+
at: string
|
|
93
|
+
): EvidenceRef[] {
|
|
94
|
+
const out: EvidenceRef[] = [];
|
|
95
|
+
for (const line of numstat.split("\n")) {
|
|
96
|
+
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
out.push({
|
|
99
|
+
id: `diff:${m[3]}`,
|
|
100
|
+
kind: "diff",
|
|
101
|
+
label: `${m[3]} (+${m[1] === "-" ? 0 : m[1]} −${m[2] === "-" ? 0 : m[2]})`,
|
|
102
|
+
status: "info",
|
|
103
|
+
at,
|
|
104
|
+
href: `/?session=${sessionId}`,
|
|
105
|
+
});
|
|
106
|
+
if (out.length >= 40) break;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function stateEvidence(
|
|
112
|
+
state: TaskState,
|
|
113
|
+
blocked: string | null,
|
|
114
|
+
at: string
|
|
115
|
+
): EvidenceRef {
|
|
116
|
+
return {
|
|
117
|
+
id: "state:task",
|
|
118
|
+
kind: "state",
|
|
119
|
+
label: `Task state: ${state}`,
|
|
120
|
+
status: state === "checks-failing" || state === "blocked" ? "fail" : "info",
|
|
121
|
+
at,
|
|
122
|
+
excerpt: blocked ? cap(blocked) : undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function askEvidence(
|
|
127
|
+
asks: { id: number; title: string; detail: string; created_at: string }[]
|
|
128
|
+
): EvidenceRef[] {
|
|
129
|
+
return asks.slice(0, 5).map((a) => ({
|
|
130
|
+
id: `ask:${a.id}`,
|
|
131
|
+
kind: "ask",
|
|
132
|
+
label: `Waiting on you: ${a.title}`,
|
|
133
|
+
status: "pending",
|
|
134
|
+
at: a.created_at,
|
|
135
|
+
excerpt: cap(a.detail),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function acceptEvidence(
|
|
140
|
+
items: { label: string; met: boolean; note?: string }[],
|
|
141
|
+
at: string
|
|
142
|
+
): EvidenceRef[] {
|
|
143
|
+
return items.map((it, i) => ({
|
|
144
|
+
id: `accept:${i + 1}`,
|
|
145
|
+
kind: "accept",
|
|
146
|
+
label: it.label,
|
|
147
|
+
status: it.met ? "pass" : "pending",
|
|
148
|
+
at,
|
|
149
|
+
excerpt: it.note ? cap(it.note) : undefined,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function logEvidence(log: {
|
|
154
|
+
sessionId: string;
|
|
155
|
+
mergeCommit: string | null;
|
|
156
|
+
prNumber: number | null;
|
|
157
|
+
durationMs: number | null;
|
|
158
|
+
costUsd: number;
|
|
159
|
+
createdAt: string;
|
|
160
|
+
}): EvidenceRef {
|
|
161
|
+
const mins =
|
|
162
|
+
log.durationMs !== null
|
|
163
|
+
? Math.max(1, Math.round(log.durationMs / 60000))
|
|
164
|
+
: null;
|
|
165
|
+
return {
|
|
166
|
+
id: "log:task",
|
|
167
|
+
kind: "log",
|
|
168
|
+
label: [
|
|
169
|
+
log.prNumber ? `Merged as pull request ${log.prNumber}` : "Task log",
|
|
170
|
+
mins ? `in ${mins} min` : "",
|
|
171
|
+
]
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join(" "),
|
|
174
|
+
status: "pass",
|
|
175
|
+
at: log.createdAt,
|
|
176
|
+
excerpt: log.mergeCommit ? `Merge commit ${log.mergeCommit}` : undefined,
|
|
177
|
+
href: `/?session=${log.sessionId}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// FNV-1a over ids and statuses: times and excerpts don't change it.
|
|
182
|
+
export function evidenceHash(evidence: EvidenceRef[], head: string | null) {
|
|
183
|
+
let h = 2166136261;
|
|
184
|
+
const s = `${head ?? ""}|${evidence
|
|
185
|
+
.map((e) => `${e.id}:${e.status}`)
|
|
186
|
+
.sort()
|
|
187
|
+
.join(",")}`;
|
|
188
|
+
for (let i = 0; i < s.length; i++) {
|
|
189
|
+
h ^= s.charCodeAt(i);
|
|
190
|
+
h = Math.imul(h, 16777619);
|
|
191
|
+
}
|
|
192
|
+
return (h >>> 0).toString(16);
|
|
193
|
+
}
|