otacon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/cli/client.js +188 -0
- package/dist/cli/client.js.map +1 -0
- package/dist/cli/commands/answer.js +63 -0
- package/dist/cli/commands/answer.js.map +1 -0
- package/dist/cli/commands/ask.js +117 -0
- package/dist/cli/commands/ask.js.map +1 -0
- package/dist/cli/commands/clean.js +48 -0
- package/dist/cli/commands/clean.js.map +1 -0
- package/dist/cli/commands/doctor.js +86 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/expose.js +104 -0
- package/dist/cli/commands/expose.js.map +1 -0
- package/dist/cli/commands/implement-done.js +53 -0
- package/dist/cli/commands/implement-done.js.map +1 -0
- package/dist/cli/commands/install.js +113 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/open.js +37 -0
- package/dist/cli/commands/open.js.map +1 -0
- package/dist/cli/commands/progress.js +45 -0
- package/dist/cli/commands/progress.js.map +1 -0
- package/dist/cli/commands/start.js +66 -0
- package/dist/cli/commands/start.js.map +1 -0
- package/dist/cli/commands/status.js +44 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/submit.js +64 -0
- package/dist/cli/commands/submit.js.map +1 -0
- package/dist/cli/commands/wait.js +66 -0
- package/dist/cli/commands/wait.js.map +1 -0
- package/dist/cli/install/assets.js +285 -0
- package/dist/cli/install/assets.js.map +1 -0
- package/dist/cli/install/locations.js +92 -0
- package/dist/cli/install/locations.js.map +1 -0
- package/dist/cli/install/tailscale.js +39 -0
- package/dist/cli/install/tailscale.js.map +1 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/output.js +39 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/session.js +77 -0
- package/dist/cli/session.js.map +1 -0
- package/dist/daemon/activity.js +56 -0
- package/dist/daemon/activity.js.map +1 -0
- package/dist/daemon/anchor.js +143 -0
- package/dist/daemon/anchor.js.map +1 -0
- package/dist/daemon/app.js +1081 -0
- package/dist/daemon/app.js.map +1 -0
- package/dist/daemon/approve.js +71 -0
- package/dist/daemon/approve.js.map +1 -0
- package/dist/daemon/desktop-notify.js +69 -0
- package/dist/daemon/desktop-notify.js.map +1 -0
- package/dist/daemon/diff.js +187 -0
- package/dist/daemon/diff.js.map +1 -0
- package/dist/daemon/linter/index.js +19 -0
- package/dist/daemon/linter/index.js.map +1 -0
- package/dist/daemon/linter/parse.js +350 -0
- package/dist/daemon/linter/parse.js.map +1 -0
- package/dist/daemon/linter/rules.js +359 -0
- package/dist/daemon/linter/rules.js.map +1 -0
- package/dist/daemon/main.js +48 -0
- package/dist/daemon/main.js.map +1 -0
- package/dist/daemon/notify.js +23 -0
- package/dist/daemon/notify.js.map +1 -0
- package/dist/daemon/presence.js +37 -0
- package/dist/daemon/presence.js.map +1 -0
- package/dist/daemon/queue.js +160 -0
- package/dist/daemon/queue.js.map +1 -0
- package/dist/daemon/store.js +393 -0
- package/dist/daemon/store.js.map +1 -0
- package/dist/daemon/threads.js +153 -0
- package/dist/daemon/threads.js.map +1 -0
- package/dist/daemon/transcript.js +89 -0
- package/dist/daemon/transcript.js.map +1 -0
- package/dist/daemon/ui.js +175 -0
- package/dist/daemon/ui.js.map +1 -0
- package/dist/shared/config.js +93 -0
- package/dist/shared/config.js.map +1 -0
- package/dist/shared/gwt.js +69 -0
- package/dist/shared/gwt.js.map +1 -0
- package/dist/shared/paths.js +67 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/question-spec.js +44 -0
- package/dist/shared/question-spec.js.map +1 -0
- package/dist/shared/types.js +35 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/shared/version.js +5 -0
- package/dist/shared/version.js.map +1 -0
- package/dist/ui/assets/arc-HhPfdCPZ.js +1 -0
- package/dist/ui/assets/architecture-7EHR7CIX-BPLblcyi.js +1 -0
- package/dist/ui/assets/architectureDiagram-3BPJPVTR-D2PIxGOb.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-GPEHLZMM-DQ3Dn17h.js +132 -0
- package/dist/ui/assets/c4Diagram-AAUBKEIU-DxITrQgS.js +10 -0
- package/dist/ui/assets/channel-ipcU8ZNI.js +1 -0
- package/dist/ui/assets/chunk-2J33WTMH-Du1JoPx5.js +1 -0
- package/dist/ui/assets/chunk-3OPIFGDE-Dn7x2Yqf.js +62 -0
- package/dist/ui/assets/chunk-4BX2VUAB-DVnrE-4n.js +1 -0
- package/dist/ui/assets/chunk-55IACEB6-BAhFAimA.js +1 -0
- package/dist/ui/assets/chunk-5ZQYHXKU-0hEZptem.js +2 -0
- package/dist/ui/assets/chunk-727SXJPM-C1FN_cI3.js +206 -0
- package/dist/ui/assets/chunk-AQP2D5EJ-A656OBd4.js +231 -0
- package/dist/ui/assets/chunk-BSJP7CBP-D8oMbjm8.js +1 -0
- package/dist/ui/assets/chunk-CSCIHK7Q-DjIL8GLi.js +122 -0
- package/dist/ui/assets/chunk-FMBD7UC4-Otblfqvz.js +15 -0
- package/dist/ui/assets/chunk-KSCS5N6A-BOjTvm3H.js +10 -0
- package/dist/ui/assets/chunk-L5ZTLDWV-CaTLaw6L.js +1 -0
- package/dist/ui/assets/chunk-LZXEDZCA-Dq5p7qrD.js +2 -0
- package/dist/ui/assets/chunk-ND2GUHAM-jZ_NNnWi.js +1 -0
- package/dist/ui/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
- package/dist/ui/assets/chunk-NZK2D7GU-U_7l_sCh.js +1 -0
- package/dist/ui/assets/chunk-O5CBEL6O-MewqqNB7.js +70 -0
- package/dist/ui/assets/chunk-QZHKN3VN-DzGPH44B.js +1 -0
- package/dist/ui/assets/chunk-WU5MYG2G-DyEIVjoo.js +1 -0
- package/dist/ui/assets/chunk-XPW4576I-D5ArxNEF.js +32 -0
- package/dist/ui/assets/classDiagram-4FO5ZUOK-Byg2Hl9D.js +1 -0
- package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-Byg2Hl9D.js +1 -0
- package/dist/ui/assets/cose-bilkent-S5V4N54A-PFXzf7WV.js +1 -0
- package/dist/ui/assets/cytoscape.esm-h6BdjjI9.js +321 -0
- package/dist/ui/assets/dagre-BM42HDAG-xrCfjZuZ.js +4 -0
- package/dist/ui/assets/dagre-Bx709z4p.js +1 -0
- package/dist/ui/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/dist/ui/assets/diagram-2AECGRRQ-BFf-cyKY.js +43 -0
- package/dist/ui/assets/diagram-5GNKFQAL-kNPV4NfV.js +10 -0
- package/dist/ui/assets/diagram-KO2AKTUF-ByC1IUwG.js +3 -0
- package/dist/ui/assets/diagram-LMA3HP47-DZIJMPK0.js +24 -0
- package/dist/ui/assets/diagram-OG6HWLK6-CSDED9A-.js +24 -0
- package/dist/ui/assets/dist-YwjsDswi.js +1 -0
- package/dist/ui/assets/erDiagram-TEJ5UH35-yuzvjE6J.js +85 -0
- package/dist/ui/assets/eventmodeling-FCH6USID-CZR4eNG-.js +1 -0
- package/dist/ui/assets/flowDiagram-I6XJVG4X-ApPtVyYM.js +162 -0
- package/dist/ui/assets/ganttDiagram-6RSMTGT7-BeMLXtAr.js +292 -0
- package/dist/ui/assets/gitGraph-WXDBUCRP-JmTTBa7j.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-PVQCEYII-Cjjnjs71.js +106 -0
- package/dist/ui/assets/graphlib-B8gBHxth.js +1 -0
- package/dist/ui/assets/index-BFQVRcSI.js +11 -0
- package/dist/ui/assets/index-Bj_kTrwP.css +1 -0
- package/dist/ui/assets/info-J43DQDTF-8vZ3gome.js +1 -0
- package/dist/ui/assets/infoDiagram-5YYISTIA-CnMk1cA-.js +2 -0
- package/dist/ui/assets/init-D6jRqBbL.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-YF4QCWOH-Bl8z6huD.js +70 -0
- package/dist/ui/assets/journeyDiagram-JHISSGLW-DYIVfMpS.js +139 -0
- package/dist/ui/assets/kanban-definition-UN3LZRKU-BnR0ZzOz.js +89 -0
- package/dist/ui/assets/katex-Vhh-h91d.js +257 -0
- package/dist/ui/assets/line-DcBdQit6.js +1 -0
- package/dist/ui/assets/linear-HKjRHFAO.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-DkYXrPlA.js +4 -0
- package/dist/ui/assets/mermaid.core-BmkfCI3b.js +9 -0
- package/dist/ui/assets/mindmap-definition-RKZ34NQL-sIAd4nDi.js +96 -0
- package/dist/ui/assets/ordinal-hYBb2elL.js +1 -0
- package/dist/ui/assets/otacon-DPXGiaVj.svg +11 -0
- package/dist/ui/assets/packet-YPE3B663-BxbxcfXN.js +1 -0
- package/dist/ui/assets/path-BWPyau1x.js +1 -0
- package/dist/ui/assets/pie-LRSECV5Y-BJxazjNs.js +1 -0
- package/dist/ui/assets/pieDiagram-4H26LBE5-BiOhc9GR.js +30 -0
- package/dist/ui/assets/plan-view-CH6NzUDb.js +74 -0
- package/dist/ui/assets/purify.es-CDvCXckx.js +3 -0
- package/dist/ui/assets/quadrantDiagram-W4KKPZXB-CVyHbWgo.js +7 -0
- package/dist/ui/assets/radar-GUYGQ44K-D9ohbnbV.js +1 -0
- package/dist/ui/assets/requirementDiagram-4Y6WPE33-Ba24_hqc.js +84 -0
- package/dist/ui/assets/rough.esm-CSKSodPl.js +1 -0
- package/dist/ui/assets/sankeyDiagram-5OEKKPKP-CxD4wiPL.js +40 -0
- package/dist/ui/assets/sequenceDiagram-3UESZ5HK-7qA7lD61.js +162 -0
- package/dist/ui/assets/src-IM8AE8MK.js +1 -0
- package/dist/ui/assets/stateDiagram-AJRCARHV-DNElRCuH.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-D6qTYpY3.js +1 -0
- package/dist/ui/assets/timeline-definition-PNZ67QCA-ChYC4Grd.js +120 -0
- package/dist/ui/assets/treeView-BLDUP644-Il0KnMi_.js +1 -0
- package/dist/ui/assets/treemap-LRROVOQU-CIiKcdRo.js +1 -0
- package/dist/ui/assets/vennDiagram-CIIHVFJN-Ulhkum9i.js +34 -0
- package/dist/ui/assets/wardley-L42UT6IY-BNd4ljz7.js +1 -0
- package/dist/ui/assets/wardleyDiagram-YWT4CUSO-BicXxh84.js +78 -0
- package/dist/ui/assets/xychartDiagram-2RQKCTM6-Duf-m_th.js +7 -0
- package/dist/ui/index.html +20 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
// otacond's HTTP surface (DESIGN.md §6 "HTTP API sketch"), as a Hono app
|
|
2
|
+
// factory so tests drive it via app.request() with no socket.
|
|
3
|
+
//
|
|
4
|
+
// Long-poll delivery honors at-least-once (DECISIONS.md "SessionQueue API"):
|
|
5
|
+
// an event is acked with queue.flush(event) only once the response bytes are
|
|
6
|
+
// out. Under @hono/node-server the env carries the Node ServerResponse
|
|
7
|
+
// (`outgoing`), whose "close" event fires exactly once per response —
|
|
8
|
+
// writableFinished there distinguishes delivered (ack) from client-aborted
|
|
9
|
+
// (requeue). Accessing c.req.raw.signal materializes node-server's lazy
|
|
10
|
+
// AbortController, so a client that drops a *parked* poll aborts the signal
|
|
11
|
+
// and the waiter is canceled without consuming anything.
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import { isAbsolute, join } from "node:path";
|
|
14
|
+
import { loadConfig } from "../shared/config.js";
|
|
15
|
+
import { otaconPort } from "../shared/paths.js";
|
|
16
|
+
import { parseQuestionSpec } from "../shared/question-spec.js";
|
|
17
|
+
import { TERMINAL_STATUSES } from "../shared/types.js";
|
|
18
|
+
import { VERSION } from "../shared/version.js";
|
|
19
|
+
import { appendActivity, latestNote, readActivity } from "./activity.js";
|
|
20
|
+
import { composeArtifact, localDate, pickArtifactRelPath } from "./approve.js";
|
|
21
|
+
import { createDesktopNotifier } from "./desktop-notify.js";
|
|
22
|
+
import { diffPlans } from "./diff.js";
|
|
23
|
+
import { lint } from "./linter/index.js";
|
|
24
|
+
import { Notifier } from "./notify.js";
|
|
25
|
+
import { Presence } from "./presence.js";
|
|
26
|
+
import { SessionQueue } from "./queue.js";
|
|
27
|
+
import { writeFileAtomic } from "./store.js";
|
|
28
|
+
import { answerQuestion, appendThreads, applyRevisionToThreads, commentThreadStates, readThreads, } from "./threads.js";
|
|
29
|
+
import { answerEntry, appendEntries, appendEntry, readTranscript } from "./transcript.js";
|
|
30
|
+
import { registerUiRoutes } from "./ui.js";
|
|
31
|
+
/** Hard ceiling on ?wait= (seconds); agents ask for 540 under their 600s Bash cap. */
|
|
32
|
+
const MAX_WAIT_SECONDS = 600;
|
|
33
|
+
const badRequest = (c, message) => c.json({ error: { code: "E_BAD_REQUEST", message } }, 400);
|
|
34
|
+
const notFound = (c, message) => c.json({ error: { code: "E_NOT_FOUND", message } }, 404);
|
|
35
|
+
const timeoutEvent = (c) => c.json({ event: "timeout" });
|
|
36
|
+
// A session in a terminal state is over (DESIGN.md §6, §12 status machine):
|
|
37
|
+
// every state-mutating verb refuses — the CLI's pointer rules guard its side,
|
|
38
|
+
// but curl/UI/--session calls must hit the same wall. Each route checks *after*
|
|
39
|
+
// its body await (see sessionEnded in createApp): a pre-await snapshot goes
|
|
40
|
+
// stale when a concurrent approve lands while the bytes stream in. (Note
|
|
41
|
+
// `implementing` is non-terminal — it deliberately re-opens the mutating verbs
|
|
42
|
+
// while the agent builds the approved plan, so it does NOT trip this wall.)
|
|
43
|
+
const sessionOver = (c, id) => c.json({ error: { code: "E_SESSION_OVER", message: `session ${id} is over (terminal)` } }, 409);
|
|
44
|
+
async function readJsonBody(c) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = await c.req.json();
|
|
47
|
+
return typeof parsed === "object" && parsed !== null
|
|
48
|
+
? parsed
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** null/undefined = whole-plan; otherwise require {section} and keep known keys only. */
|
|
56
|
+
function parseAnchor(raw) {
|
|
57
|
+
if (raw === null || raw === undefined)
|
|
58
|
+
return null;
|
|
59
|
+
if (typeof raw !== "object")
|
|
60
|
+
return undefined;
|
|
61
|
+
const { section, exact, prefix, suffix } = raw;
|
|
62
|
+
if (typeof section !== "string" || section === "")
|
|
63
|
+
return undefined;
|
|
64
|
+
const anchor = { section };
|
|
65
|
+
if (typeof exact === "string")
|
|
66
|
+
anchor.exact = exact;
|
|
67
|
+
if (typeof prefix === "string")
|
|
68
|
+
anchor.prefix = prefix;
|
|
69
|
+
if (typeof suffix === "string")
|
|
70
|
+
anchor.suffix = suffix;
|
|
71
|
+
return anchor;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Validate the submit body's `resolutions` (DESIGN.md §6): an object with
|
|
75
|
+
* only `changelog` (string) and `threads` (string → string). Strict — an
|
|
76
|
+
* unknown key is a typo that would silently drop resolutions, so it refuses.
|
|
77
|
+
* undefined/null = none provided ({}); any other bad shape = undefined.
|
|
78
|
+
*/
|
|
79
|
+
function parseResolutions(raw) {
|
|
80
|
+
if (raw === undefined || raw === null)
|
|
81
|
+
return {};
|
|
82
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
83
|
+
return undefined;
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
86
|
+
if (key === "changelog" && typeof value === "string") {
|
|
87
|
+
out.changelog = value;
|
|
88
|
+
}
|
|
89
|
+
else if (key === "threads" && typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
90
|
+
// Null-prototype: JSON.parse can hand us an own "__proto__" key, and
|
|
91
|
+
// assigning it onto a plain object silently drops it — the strict shape
|
|
92
|
+
// must surface it to L5 (E_UNKNOWN_THREAD) instead.
|
|
93
|
+
const threads = Object.create(null);
|
|
94
|
+
for (const [id, reply] of Object.entries(value)) {
|
|
95
|
+
if (typeof reply !== "string")
|
|
96
|
+
return undefined;
|
|
97
|
+
threads[id] = reply;
|
|
98
|
+
}
|
|
99
|
+
out.threads = threads;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** Build a transcript entry from a validated spec and its minted q<n> id. */
|
|
108
|
+
function entryFromSpec(id, spec, askedAt) {
|
|
109
|
+
// `spec` is already normalized (no absent/false keys), so spreading it is the
|
|
110
|
+
// whole asked shape — keep this in lockstep with QuestionSpec, not field-wise.
|
|
111
|
+
return { id, ...spec, askedAt };
|
|
112
|
+
}
|
|
113
|
+
/** True when the Origin header names this daemon itself (the M2 web UI). */
|
|
114
|
+
function sameOrigin(origin, host) {
|
|
115
|
+
try {
|
|
116
|
+
return host !== undefined && new URL(origin).host === host;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false; // opaque origins ("null") and garbage are foreign
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function createApp(options) {
|
|
123
|
+
const { store } = options;
|
|
124
|
+
// One queue instance per session for the daemon's lifetime — every request
|
|
125
|
+
// must park on / enqueue into the same in-memory waiter list, and the
|
|
126
|
+
// SessionQueue constructor re-reads disk (it would resurrect unacked
|
|
127
|
+
// in-flight events if constructed per request).
|
|
128
|
+
const queues = new Map();
|
|
129
|
+
const queueFor = (id) => {
|
|
130
|
+
let queue = queues.get(id);
|
|
131
|
+
if (!queue) {
|
|
132
|
+
queue = new SessionQueue(store.eventsPath(id));
|
|
133
|
+
queues.set(id, queue);
|
|
134
|
+
}
|
|
135
|
+
return queue;
|
|
136
|
+
};
|
|
137
|
+
const sessionFor = (c) => store.getSession(c.req.param("id") ?? "");
|
|
138
|
+
// Mutating routes call this after their last await: reading the request
|
|
139
|
+
// body yields, and a concurrent approve can flip the session mid-read — a
|
|
140
|
+
// status captured before the await would let the stale handler mutate (or
|
|
141
|
+
// re-approve) an ended session. Everything from this re-check to the state
|
|
142
|
+
// writes is synchronous, so the answer cannot rot again. Gates on the
|
|
143
|
+
// terminal set (TERMINAL_STATUSES), so an `implementing` session — which
|
|
144
|
+
// re-opens the mutating verbs — sails through.
|
|
145
|
+
const sessionEnded = (id) => {
|
|
146
|
+
const status = store.getSession(id)?.status;
|
|
147
|
+
return status !== undefined && TERMINAL_STATUSES.includes(status);
|
|
148
|
+
};
|
|
149
|
+
// Agent presence (DESIGN.md §6): ephemeral, in-memory liveness only — the
|
|
150
|
+
// epoch-ms of each session's last agent contact. Every mutating verb and each
|
|
151
|
+
// `wait` park bumps it; the summary exposes it (plus `parked`) and the UI
|
|
152
|
+
// derives live/offline from its recency, so the daemon needs no timer. A
|
|
153
|
+
// restart starts empty (offline until the next call), which is correct.
|
|
154
|
+
const lastContact = new Map();
|
|
155
|
+
const bumpContact = (id) => {
|
|
156
|
+
lastContact.set(id, Date.now());
|
|
157
|
+
};
|
|
158
|
+
// UI pub/sub (DECISIONS.md "UI live updates"): every state mutation below
|
|
159
|
+
// publishes, and the SSE routes in ui.ts fan the events out to browsers.
|
|
160
|
+
const notifier = new Notifier();
|
|
161
|
+
const summarize = (session) => {
|
|
162
|
+
const state = store.readState(session.id);
|
|
163
|
+
return {
|
|
164
|
+
...session,
|
|
165
|
+
revision: state.revision,
|
|
166
|
+
lastReviewedRevision: state.lastReviewedRevision,
|
|
167
|
+
pendingEvents: queueFor(session.id).size,
|
|
168
|
+
openQuestions: readTranscript(store.transcriptPath(session.id)).filter((e) => e.answer === undefined).length,
|
|
169
|
+
latestActivity: latestNote(store.activityPath(session.id)),
|
|
170
|
+
lastContactAt: lastContact.get(session.id),
|
|
171
|
+
parked: queueFor(session.id).waiterCount > 0,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
const publishSession = (session) => notifier.publish({ type: "session", session: session.id, data: { session: summarize(session) } });
|
|
175
|
+
const publishQueue = (id, pending) => notifier.publish({ type: "queue", session: id, data: { session: id, pending } });
|
|
176
|
+
const publishThread = (id, thread) => notifier.publish({ type: "thread", session: id, data: { session: id, thread } });
|
|
177
|
+
const publishGrill = (id, entry) => notifier.publish({ type: "grill", session: id, data: { session: id, entry } });
|
|
178
|
+
// Desktop attention banners (DESIGN.md §6). Presence tracks which sessions
|
|
179
|
+
// have a *visible* review open; the notify sink fires the native macOS banner
|
|
180
|
+
// (a no-op off darwin). Both are injectable for tests.
|
|
181
|
+
const presence = options.presence ?? new Presence();
|
|
182
|
+
const notify = options.notify ?? createDesktopNotifier();
|
|
183
|
+
/**
|
|
184
|
+
* Fire a desktop banner for an attention moment unless the user is already
|
|
185
|
+
* watching this session's review (presence) or has disabled them (config,
|
|
186
|
+
* loaded fresh per session.repo so a repo override applies). The whole thing
|
|
187
|
+
* is wrapped: a spawn or config error must never break the submit/ask response
|
|
188
|
+
* — it is swallowed to stderr (DESIGN.md §13: zero-API-spend untouched; this
|
|
189
|
+
* is a local OS call).
|
|
190
|
+
*/
|
|
191
|
+
const maybeNotify = (session, moment) => {
|
|
192
|
+
try {
|
|
193
|
+
if (!loadConfig(session.repo).notifications.desktop)
|
|
194
|
+
return;
|
|
195
|
+
if (presence.isWatched(session.id))
|
|
196
|
+
return;
|
|
197
|
+
const message = moment.kind === "revision"
|
|
198
|
+
? `Revision r${moment.revision} ready for review`
|
|
199
|
+
: moment.kind === "questions"
|
|
200
|
+
? `${moment.count} questions need your answer`
|
|
201
|
+
: moment.text.length > 80
|
|
202
|
+
? `${moment.text.slice(0, 79)}…`
|
|
203
|
+
: moment.text;
|
|
204
|
+
notify({
|
|
205
|
+
title: session.title,
|
|
206
|
+
message,
|
|
207
|
+
url: `http://127.0.0.1:${otaconPort()}/s/${session.id}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
process.stderr.write(`otacond: desktop notification failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
/** Respond with the event; ack only after the bytes are out (see header comment). */
|
|
215
|
+
function respondEvent(c, queue, event) {
|
|
216
|
+
const session = event.payload.session;
|
|
217
|
+
const outgoing = c.env?.outgoing;
|
|
218
|
+
if (!outgoing) {
|
|
219
|
+
queue.flush(event); // app.request() test path: no socket to wait on
|
|
220
|
+
publishQueue(session, queue.size);
|
|
221
|
+
}
|
|
222
|
+
else if (outgoing.destroyed || outgoing.closed) {
|
|
223
|
+
// Client vanished before we built the response. The closed check matters:
|
|
224
|
+
// once "close" has fired, a listener added below would never run and the
|
|
225
|
+
// event would sit unacked-unrequeued in the in-flight list until restart.
|
|
226
|
+
queue.requeue(event);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
outgoing.once("close", () => {
|
|
230
|
+
if (outgoing.writableFinished)
|
|
231
|
+
queue.flush(event);
|
|
232
|
+
else
|
|
233
|
+
queue.requeue(event);
|
|
234
|
+
publishQueue(session, queue.size);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return c.json(event.payload);
|
|
238
|
+
}
|
|
239
|
+
const app = new Hono();
|
|
240
|
+
// Loopback binding doesn't stop a malicious webpage from firing fetch() at
|
|
241
|
+
// 127.0.0.1 (no-cors requests are delivered even though the response is
|
|
242
|
+
// opaque). Browsers always attach Origin to cross-origin POSTs; the CLI and
|
|
243
|
+
// curl send none, and the M2 web UI is same-origin — so a foreign Origin on
|
|
244
|
+
// a state-changing call is refused (DECISIONS.md "Foreign-Origin requests").
|
|
245
|
+
app.use("/api/*", async (c, next) => {
|
|
246
|
+
const origin = c.req.header("origin");
|
|
247
|
+
if (c.req.method !== "GET" && origin !== undefined && !sameOrigin(origin, c.req.header("host"))) {
|
|
248
|
+
return c.json({ error: { code: "E_FORBIDDEN", message: "cross-origin requests are refused" } }, 403);
|
|
249
|
+
}
|
|
250
|
+
await next();
|
|
251
|
+
});
|
|
252
|
+
app.onError((error, c) => c.json({ error: { code: "E_INTERNAL", message: error.message } }, 500));
|
|
253
|
+
app.notFound((c) => c.json({ error: { code: "E_NOT_FOUND", message: `no route: ${c.req.method} ${c.req.path}` } }, 404));
|
|
254
|
+
app.get("/api/health", (c) => c.json({ app: "otacond", version: VERSION, pid: process.pid }));
|
|
255
|
+
app.post("/api/shutdown", (c) => {
|
|
256
|
+
// Fire the hook only once the response is out (or the client is already
|
|
257
|
+
// gone): main.ts exits the process in it, and a timing guess would race
|
|
258
|
+
// the response flush.
|
|
259
|
+
const outgoing = c.env?.outgoing;
|
|
260
|
+
if (outgoing && !outgoing.destroyed && !outgoing.closed) {
|
|
261
|
+
outgoing.once("close", () => options.onShutdown?.());
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
options.onShutdown?.();
|
|
265
|
+
}
|
|
266
|
+
return c.json({ ok: true });
|
|
267
|
+
});
|
|
268
|
+
app.get("/api/sessions", (c) => c.json({ sessions: store.listSessions() }));
|
|
269
|
+
app.post("/api/sessions", async (c) => {
|
|
270
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
271
|
+
const { title, repo, branch, quick } = body;
|
|
272
|
+
if (typeof title !== "string" || title.trim() === "") {
|
|
273
|
+
return badRequest(c, "title must be a non-empty string");
|
|
274
|
+
}
|
|
275
|
+
if (typeof repo !== "string" || !isAbsolute(repo)) {
|
|
276
|
+
return badRequest(c, "repo must be an absolute path");
|
|
277
|
+
}
|
|
278
|
+
if (branch !== undefined && typeof branch !== "string") {
|
|
279
|
+
return badRequest(c, "branch must be a string");
|
|
280
|
+
}
|
|
281
|
+
if (quick !== undefined && typeof quick !== "boolean") {
|
|
282
|
+
return badRequest(c, "quick must be a boolean");
|
|
283
|
+
}
|
|
284
|
+
const session = store.createSession({ title, repo, branch, quick });
|
|
285
|
+
publishSession(session);
|
|
286
|
+
return c.json(session, 201);
|
|
287
|
+
});
|
|
288
|
+
app.get("/api/sessions/:id", (c) => {
|
|
289
|
+
const session = sessionFor(c);
|
|
290
|
+
if (!session)
|
|
291
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
292
|
+
return c.json(summarize(session));
|
|
293
|
+
});
|
|
294
|
+
// DELETE removes a session from the registry, status-branched on whether it
|
|
295
|
+
// has committed value (DESIGN.md §6, §12). **Terminal** (approved, plus
|
|
296
|
+
// implemented/implement_failed once a build finishes): its plan + transcript
|
|
297
|
+
// are committed under docs/plans/, so the working dir is *archived* to
|
|
298
|
+
// .otacon/archive/ (recoverable) — `otacon clean` and the UI's delete of an
|
|
299
|
+
// over session both take this path. Gated on TERMINAL_STATUSES so this split
|
|
300
|
+
// agrees with the UI's `over` (which passes `approved={isOver(status)}` to the
|
|
301
|
+
// confirm sheet) — otherwise an `implemented` delete would promise archival
|
|
302
|
+
// and silently hard-delete. **Non-terminal** (draft/in_review/revising, and a
|
|
303
|
+
// live `implementing` build): the working dir is *hard-removed* (permanent),
|
|
304
|
+
// and any parked agent is woken with a terminal `deleted` event so its `wait`
|
|
305
|
+
// loop stops cleanly. Both publish the same terminal `removed` frame; the
|
|
306
|
+
// response carries `archivedTo` (the archive path, or null for a hard-delete).
|
|
307
|
+
app.delete("/api/sessions/:id", (c) => {
|
|
308
|
+
const session = sessionFor(c);
|
|
309
|
+
if (!session)
|
|
310
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
311
|
+
const queue = queueFor(session.id);
|
|
312
|
+
const pendingEvents = queue.size;
|
|
313
|
+
let archivedTo = null;
|
|
314
|
+
if (TERMINAL_STATUSES.includes(session.status)) {
|
|
315
|
+
// Deregister first — it can throw (registry flush), and an early queue
|
|
316
|
+
// eviction would orphan in-flight ack tracking for a session that is in
|
|
317
|
+
// fact still registered. Close the evicted instance before the move so a
|
|
318
|
+
// late in-flight ack cannot recreate .otacon/<id>/ next to the archive.
|
|
319
|
+
store.deleteSession(session.id);
|
|
320
|
+
queue.close();
|
|
321
|
+
queues.delete(session.id);
|
|
322
|
+
archivedTo = store.archiveSessionDir(session.repo, session.id);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
// Wake any parked agent BEFORE deregistering so its respondEvent still
|
|
326
|
+
// resolves against a registered session; closeWith sets the queue closed
|
|
327
|
+
// first, so the hard-remove below can't be recreated by a late ack. Then
|
|
328
|
+
// deregister and permanently drop the working dir (no committed value).
|
|
329
|
+
queue.closeWith({ event: "deleted", session: session.id });
|
|
330
|
+
queues.delete(session.id);
|
|
331
|
+
store.deleteSession(session.id);
|
|
332
|
+
store.removeSessionDir(session.repo, session.id);
|
|
333
|
+
}
|
|
334
|
+
// Terminal frame: the index and switcher drop the session live, and an
|
|
335
|
+
// open review tab flips to its closed state instead of error-limbo.
|
|
336
|
+
notifier.publish({ type: "removed", session: session.id, data: { session: session.id } });
|
|
337
|
+
return c.json({ ok: true, session: session.id, repo: session.repo, pendingEvents, archivedTo });
|
|
338
|
+
});
|
|
339
|
+
app.get("/api/sessions/:id/events", (c) => {
|
|
340
|
+
const session = sessionFor(c);
|
|
341
|
+
if (!session)
|
|
342
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
343
|
+
const queue = queueFor(session.id);
|
|
344
|
+
// Any events call is the agent on the line; bump presence before deciding
|
|
345
|
+
// whether to park (covers the fast-path and wait=0 drains too).
|
|
346
|
+
bumpContact(session.id);
|
|
347
|
+
const raw = Number(c.req.query("wait") ?? "0");
|
|
348
|
+
const waitSeconds = Number.isFinite(raw)
|
|
349
|
+
? Math.min(Math.max(raw, 0), MAX_WAIT_SECONDS)
|
|
350
|
+
: 0;
|
|
351
|
+
// Fast path and parking are one synchronous block: no enqueue can slip
|
|
352
|
+
// between this take() and park() (DECISIONS.md "SessionQueue: synchronous").
|
|
353
|
+
const immediate = queue.take();
|
|
354
|
+
if (immediate)
|
|
355
|
+
return respondEvent(c, queue, immediate);
|
|
356
|
+
if (waitSeconds === 0)
|
|
357
|
+
return timeoutEvent(c);
|
|
358
|
+
const signal = c.req.raw.signal; // materializes node-server's AbortController
|
|
359
|
+
// node-server only aborts that controller if it existed when the socket's
|
|
360
|
+
// "close" fired. A client that vanished before this handler ran would
|
|
361
|
+
// otherwise park a zombie waiter for the full wait window.
|
|
362
|
+
const outgoing = c.env?.outgoing;
|
|
363
|
+
if (signal.aborted || outgoing?.destroyed || outgoing?.closed)
|
|
364
|
+
return timeoutEvent(c);
|
|
365
|
+
return new Promise((resolve) => {
|
|
366
|
+
let settled = false;
|
|
367
|
+
let timer;
|
|
368
|
+
let handle;
|
|
369
|
+
const settle = (response) => {
|
|
370
|
+
if (settled)
|
|
371
|
+
return;
|
|
372
|
+
settled = true;
|
|
373
|
+
if (timer !== undefined)
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
handle?.cancel();
|
|
376
|
+
signal.removeEventListener("abort", onAbort);
|
|
377
|
+
// Leaving the park flips `parked` (the waiter is gone): broadcast a
|
|
378
|
+
// fresh summary so a dropped agent's dot can fall to offline instead
|
|
379
|
+
// of the last frame's parked=true sticking forever. Re-read the
|
|
380
|
+
// registry — the session may have flipped (approve) or vanished
|
|
381
|
+
// (clean) during the park.
|
|
382
|
+
const current = store.getSession(session.id);
|
|
383
|
+
if (current)
|
|
384
|
+
publishSession(current);
|
|
385
|
+
resolve(response);
|
|
386
|
+
};
|
|
387
|
+
// Aborted while parked: cancel the waiter; queued events stay queued.
|
|
388
|
+
// (Aborted after wake-up is the respondEvent requeue path instead.)
|
|
389
|
+
const onAbort = () => settle(timeoutEvent(c));
|
|
390
|
+
handle = queue.park((event) => settle(respondEvent(c, queue, event)));
|
|
391
|
+
if (!settled) {
|
|
392
|
+
// Genuinely parked (the queue was empty at take()): broadcast
|
|
393
|
+
// parked=true + the refreshed lastContactAt so the live dot reaches the
|
|
394
|
+
// UI within one park slice (DESIGN.md §6).
|
|
395
|
+
publishSession(session);
|
|
396
|
+
timer = setTimeout(() => settle(timeoutEvent(c)), waitSeconds * 1000);
|
|
397
|
+
signal.addEventListener("abort", onAbort);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
app.post("/api/sessions/:id/submit", async (c) => {
|
|
402
|
+
const session = sessionFor(c);
|
|
403
|
+
if (!session)
|
|
404
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
405
|
+
// Raw markdown body, or {"plan": "...", "resolutions": {...}} JSON — the
|
|
406
|
+
// CLI sends resolutions.json's content along (DESIGN.md §6). The raw path
|
|
407
|
+
// carries no resolutions, so L5 still rejects it when threads are open.
|
|
408
|
+
let content = await c.req.text();
|
|
409
|
+
if (sessionEnded(session.id))
|
|
410
|
+
return sessionOver(c, session.id);
|
|
411
|
+
bumpContact(session.id);
|
|
412
|
+
let resolutions = {};
|
|
413
|
+
if (c.req.header("content-type")?.includes("json")) {
|
|
414
|
+
let body;
|
|
415
|
+
try {
|
|
416
|
+
body = JSON.parse(content);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
body = undefined;
|
|
420
|
+
}
|
|
421
|
+
const plan = body?.plan;
|
|
422
|
+
if (typeof plan !== "string")
|
|
423
|
+
return badRequest(c, "JSON body must carry a string plan");
|
|
424
|
+
const parsed = parseResolutions(body.resolutions);
|
|
425
|
+
if (!parsed) {
|
|
426
|
+
return badRequest(c, 'resolutions must be {"changelog"?: string, "threads"?: {"t<n>": "reply"}}');
|
|
427
|
+
}
|
|
428
|
+
content = plan;
|
|
429
|
+
resolutions = parsed;
|
|
430
|
+
}
|
|
431
|
+
if (content.trim() === "")
|
|
432
|
+
return badRequest(c, "request body must be the plan markdown");
|
|
433
|
+
const state = store.readState(session.id);
|
|
434
|
+
const replies = resolutions.threads ?? {};
|
|
435
|
+
const result = lint(content, loadConfig(session.repo), {
|
|
436
|
+
session: session.id,
|
|
437
|
+
expectedRevision: state.revision + 1,
|
|
438
|
+
expectedStatus: "in_review",
|
|
439
|
+
// L3/L5 context is composed here: rules stay pure, the daemon does the I/O.
|
|
440
|
+
grill: {
|
|
441
|
+
quick: session.quick,
|
|
442
|
+
knownQuestions: readTranscript(store.transcriptPath(session.id)).map((e) => e.id),
|
|
443
|
+
},
|
|
444
|
+
resolutions: {
|
|
445
|
+
revision: state.revision + 1,
|
|
446
|
+
commentThreads: commentThreadStates(store.threadsPath(session.id)),
|
|
447
|
+
replies,
|
|
448
|
+
changelog: resolutions.changelog,
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
if (!result.ok) {
|
|
452
|
+
return c.json({ ok: false, errors: result.errors, warnings: result.warnings }, 422);
|
|
453
|
+
}
|
|
454
|
+
const changelog = (resolutions.changelog ?? "").trim() === "" ? null : resolutions.changelog;
|
|
455
|
+
const revision = store.saveRevision(session.id, content, result.warnings, changelog ?? undefined);
|
|
456
|
+
// The accepted revision settles its threads: resolutions land on their
|
|
457
|
+
// threads, every anchor is re-located in the new text, lost ones orphan
|
|
458
|
+
// (DESIGN.md §4, §9). SSE upserts keep the rail live.
|
|
459
|
+
const changedThreads = applyRevisionToThreads(store.threadsPath(session.id), {
|
|
460
|
+
plan: content,
|
|
461
|
+
replies,
|
|
462
|
+
revision,
|
|
463
|
+
});
|
|
464
|
+
const updated = store.updateSession(session.id, { status: "in_review" });
|
|
465
|
+
publishSession(updated);
|
|
466
|
+
notifier.publish({
|
|
467
|
+
type: "revision",
|
|
468
|
+
session: session.id,
|
|
469
|
+
data: { session: session.id, revision, changelog },
|
|
470
|
+
});
|
|
471
|
+
for (const thread of changedThreads)
|
|
472
|
+
publishThread(session.id, thread);
|
|
473
|
+
// The ball is back in the user's court: a fresh revision awaits review.
|
|
474
|
+
maybeNotify(session, { kind: "revision", revision });
|
|
475
|
+
return c.json({
|
|
476
|
+
ok: true,
|
|
477
|
+
session: session.id,
|
|
478
|
+
revision,
|
|
479
|
+
status: "in_review",
|
|
480
|
+
warnings: result.warnings,
|
|
481
|
+
resolved: Object.keys(replies),
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
// The user's side of re-review bookkeeping (DESIGN.md §9 layer 3): the UI's
|
|
485
|
+
// "mark reviewed" / banner-dismiss POSTs here; comment flushes mark it
|
|
486
|
+
// implicitly. Monotonic — see Store.markReviewed.
|
|
487
|
+
app.post("/api/sessions/:id/reviewed", async (c) => {
|
|
488
|
+
const session = sessionFor(c);
|
|
489
|
+
if (!session)
|
|
490
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
491
|
+
const state = store.readState(session.id);
|
|
492
|
+
if (state.revision === 0) {
|
|
493
|
+
return badRequest(c, "session has no revisions to mark reviewed");
|
|
494
|
+
}
|
|
495
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
496
|
+
const revision = body.revision ?? state.revision;
|
|
497
|
+
if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 1 || revision > state.revision) {
|
|
498
|
+
return badRequest(c, `revision must be an integer 1..${state.revision}`);
|
|
499
|
+
}
|
|
500
|
+
const lastReviewedRevision = store.markReviewed(session.id, revision);
|
|
501
|
+
publishSession(session); // summary re-reads state, so the frame carries it
|
|
502
|
+
return c.json({ ok: true, session: session.id, lastReviewedRevision });
|
|
503
|
+
});
|
|
504
|
+
// The review screen reports its visibility here (DESIGN.md §6): {visible:true}
|
|
505
|
+
// when shown + on a heartbeat, {visible:false} on blur/unload. The daemon
|
|
506
|
+
// suppresses a desktop banner only while a review is visible — a hidden or
|
|
507
|
+
// backgrounded tab (its SSE stream still open) does NOT suppress. No status
|
|
508
|
+
// change, so it stays callable on an approved session (a closing tab still
|
|
509
|
+
// pings); presence is ephemeral, not persisted.
|
|
510
|
+
app.post("/api/sessions/:id/presence", async (c) => {
|
|
511
|
+
const session = sessionFor(c);
|
|
512
|
+
if (!session)
|
|
513
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
514
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
515
|
+
if (typeof body.visible !== "boolean") {
|
|
516
|
+
return badRequest(c, "visible must be a boolean");
|
|
517
|
+
}
|
|
518
|
+
if (body.visible)
|
|
519
|
+
presence.markVisible(session.id);
|
|
520
|
+
else
|
|
521
|
+
presence.markHidden(session.id);
|
|
522
|
+
return c.json({ ok: true, session: session.id, visible: body.visible });
|
|
523
|
+
});
|
|
524
|
+
// Structural diff between two stored revisions (DESIGN.md §6, §9 layer 3).
|
|
525
|
+
// Defaults: to = latest, from = last-reviewed (?from= selects any other
|
|
526
|
+
// baseline; 0 = the empty plan, so a never-reviewed session shows all-new).
|
|
527
|
+
app.get("/api/sessions/:id/diff", (c) => {
|
|
528
|
+
const session = sessionFor(c);
|
|
529
|
+
if (!session)
|
|
530
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
531
|
+
const state = store.readState(session.id);
|
|
532
|
+
if (state.revision === 0) {
|
|
533
|
+
return notFound(c, `session ${session.id} has no revisions to diff`);
|
|
534
|
+
}
|
|
535
|
+
const parseRev = (raw, fallback) => {
|
|
536
|
+
if (raw === undefined || raw === "")
|
|
537
|
+
return fallback;
|
|
538
|
+
const n = Number(raw);
|
|
539
|
+
return Number.isInteger(n) ? n : undefined;
|
|
540
|
+
};
|
|
541
|
+
const to = parseRev(c.req.query("to"), state.revision);
|
|
542
|
+
const from = parseRev(c.req.query("from"), state.lastReviewedRevision);
|
|
543
|
+
if (to === undefined || to < 1 || to > state.revision) {
|
|
544
|
+
return badRequest(c, `to must be a revision number 1..${state.revision}`);
|
|
545
|
+
}
|
|
546
|
+
if (from === undefined || from < 0 || from > state.revision) {
|
|
547
|
+
return badRequest(c, `from must be a revision number 0..${state.revision} (0 = empty plan)`);
|
|
548
|
+
}
|
|
549
|
+
const payload = {
|
|
550
|
+
session: session.id,
|
|
551
|
+
from,
|
|
552
|
+
to,
|
|
553
|
+
sections: diffPlans(from === 0 ? "" : store.readRevision(session.id, from), store.readRevision(session.id, to)),
|
|
554
|
+
};
|
|
555
|
+
return c.json(payload);
|
|
556
|
+
});
|
|
557
|
+
app.post("/api/sessions/:id/comments", async (c) => {
|
|
558
|
+
const session = sessionFor(c);
|
|
559
|
+
if (!session)
|
|
560
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
561
|
+
const queue = queueFor(session.id); // before any state write: can throw on a corrupt file
|
|
562
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
563
|
+
if (sessionEnded(session.id))
|
|
564
|
+
return sessionOver(c, session.id);
|
|
565
|
+
bumpContact(session.id);
|
|
566
|
+
const rawItems = body.items;
|
|
567
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
568
|
+
return badRequest(c, "items must be a non-empty array");
|
|
569
|
+
}
|
|
570
|
+
const drafts = [];
|
|
571
|
+
for (const raw of rawItems) {
|
|
572
|
+
const anchor = parseAnchor(raw?.anchor);
|
|
573
|
+
if (typeof raw?.body !== "string" || raw.body.trim() === "" || anchor === undefined) {
|
|
574
|
+
return badRequest(c, "each item needs a non-empty body and a valid anchor (or null)");
|
|
575
|
+
}
|
|
576
|
+
drafts.push({ anchor, body: raw.body });
|
|
577
|
+
}
|
|
578
|
+
// Ids are minted only after the whole batch validates, in one counter
|
|
579
|
+
// write — a rejected batch burns neither ids nor disk writes.
|
|
580
|
+
const counters = store.bumpCounters(session.id, {
|
|
581
|
+
thread: drafts.length,
|
|
582
|
+
batch: 1,
|
|
583
|
+
eventSeq: 1,
|
|
584
|
+
});
|
|
585
|
+
const firstThread = counters.thread - drafts.length;
|
|
586
|
+
const items = drafts.map((draft, i) => ({
|
|
587
|
+
thread: `t${firstThread + i + 1}`,
|
|
588
|
+
...draft,
|
|
589
|
+
}));
|
|
590
|
+
const batch = `b${counters.batch}`;
|
|
591
|
+
// Each item becomes a persistent thread (DESIGN.md §9) — the rail's
|
|
592
|
+
// source of truth; the queued event is only the agent's wake-up copy.
|
|
593
|
+
const createdAt = new Date().toISOString();
|
|
594
|
+
const threads = items.map((item) => ({
|
|
595
|
+
id: item.thread,
|
|
596
|
+
kind: "comment",
|
|
597
|
+
batch,
|
|
598
|
+
anchor: item.anchor,
|
|
599
|
+
body: item.body,
|
|
600
|
+
createdAt,
|
|
601
|
+
}));
|
|
602
|
+
appendThreads(store.threadsPath(session.id), threads);
|
|
603
|
+
// Flushing a batch is the implicit "I reviewed this revision" signal
|
|
604
|
+
// (DESIGN.md §9 layer 3) — the diff baseline moves with it.
|
|
605
|
+
store.markReviewed(session.id, store.readState(session.id).revision);
|
|
606
|
+
// Comments are revision requests (DECISIONS.md "Status transitions"); flip
|
|
607
|
+
// status before the enqueue wakes a parked agent.
|
|
608
|
+
const updated = store.updateSession(session.id, { status: "revising" });
|
|
609
|
+
const payload = { event: "comments", session: session.id, batch, items };
|
|
610
|
+
queue.enqueue(payload, counters.eventSeq);
|
|
611
|
+
publishSession(updated); // after the enqueue, so the summary carries the fresh pending count
|
|
612
|
+
for (const thread of threads)
|
|
613
|
+
publishThread(session.id, thread);
|
|
614
|
+
return c.json({ ok: true, batch, threads: items.map((i) => i.thread), seq: counters.eventSeq }, 202);
|
|
615
|
+
});
|
|
616
|
+
app.post("/api/sessions/:id/questions", async (c) => {
|
|
617
|
+
const session = sessionFor(c);
|
|
618
|
+
if (!session)
|
|
619
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
620
|
+
const queue = queueFor(session.id); // before any state write: can throw on a corrupt file
|
|
621
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
622
|
+
if (sessionEnded(session.id))
|
|
623
|
+
return sessionOver(c, session.id);
|
|
624
|
+
bumpContact(session.id);
|
|
625
|
+
if (typeof body.body !== "string" || body.body.trim() === "") {
|
|
626
|
+
return badRequest(c, "question needs a non-empty body");
|
|
627
|
+
}
|
|
628
|
+
// A follow-up (DESIGN.md §9) names the question it continues with `replyTo`
|
|
629
|
+
// and inherits that conversation's anchor — so a client anchor is ignored on
|
|
630
|
+
// a follow-up; a root question parses its own anchor (or null = whole-plan).
|
|
631
|
+
let anchor;
|
|
632
|
+
let replyTo;
|
|
633
|
+
let inheritOrphan = false;
|
|
634
|
+
const replyToRaw = body.replyTo;
|
|
635
|
+
if (replyToRaw === undefined) {
|
|
636
|
+
const parsed = parseAnchor(body.anchor);
|
|
637
|
+
if (parsed === undefined) {
|
|
638
|
+
return badRequest(c, "question needs a valid anchor (or null)");
|
|
639
|
+
}
|
|
640
|
+
anchor = parsed;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
if (typeof replyToRaw !== "string" || replyToRaw === "") {
|
|
644
|
+
return badRequest(c, "replyTo must name a question thread id (q<n>)");
|
|
645
|
+
}
|
|
646
|
+
const existing = readThreads(store.threadsPath(session.id));
|
|
647
|
+
const parent = existing.find((t) => t.id === replyToRaw && t.kind === "question");
|
|
648
|
+
if (!parent) {
|
|
649
|
+
return c.json({
|
|
650
|
+
error: {
|
|
651
|
+
code: "E_UNKNOWN_QUESTION",
|
|
652
|
+
message: `session ${session.id} has no question ${replyToRaw}`,
|
|
653
|
+
},
|
|
654
|
+
}, 404);
|
|
655
|
+
}
|
|
656
|
+
// Resolve the root so a whole chain shares one key — "follow up on a
|
|
657
|
+
// follow-up" collapses to the same root, whose anchor (and orphan state)
|
|
658
|
+
// the new turn inherits and travels with.
|
|
659
|
+
const rootId = parent.replyTo ?? parent.id;
|
|
660
|
+
const root = existing.find((t) => t.id === rootId && t.kind === "question");
|
|
661
|
+
const source = root ?? parent;
|
|
662
|
+
replyTo = rootId;
|
|
663
|
+
anchor = source.anchor;
|
|
664
|
+
inheritOrphan = source.anchorState === "orphaned";
|
|
665
|
+
}
|
|
666
|
+
const counters = store.bumpCounters(session.id, { question: 1, eventSeq: 1 });
|
|
667
|
+
const id = `q${counters.question}`;
|
|
668
|
+
const thread = {
|
|
669
|
+
id,
|
|
670
|
+
kind: "question",
|
|
671
|
+
anchor,
|
|
672
|
+
...(inheritOrphan ? { anchorState: "orphaned" } : {}),
|
|
673
|
+
body: body.body,
|
|
674
|
+
createdAt: new Date().toISOString(),
|
|
675
|
+
...(replyTo !== undefined ? { replyTo } : {}),
|
|
676
|
+
};
|
|
677
|
+
appendThreads(store.threadsPath(session.id), [thread]);
|
|
678
|
+
// Questions leave the plan — and the status — untouched (DESIGN.md §9).
|
|
679
|
+
const payload = {
|
|
680
|
+
event: "question",
|
|
681
|
+
session: session.id,
|
|
682
|
+
id,
|
|
683
|
+
anchor,
|
|
684
|
+
body: body.body,
|
|
685
|
+
...(replyTo !== undefined ? { replyTo } : {}),
|
|
686
|
+
};
|
|
687
|
+
queue.enqueue(payload, counters.eventSeq);
|
|
688
|
+
publishQueue(session.id, queue.size);
|
|
689
|
+
publishThread(session.id, thread);
|
|
690
|
+
return c.json({ ok: true, id, seq: counters.eventSeq }, 202);
|
|
691
|
+
});
|
|
692
|
+
// The agent's side of a user question (otacon answer, DESIGN.md §6, §9):
|
|
693
|
+
// the answer lands on the thread — the plan and the status stay untouched —
|
|
694
|
+
// and the UI's "answering…" placeholder resolves over SSE.
|
|
695
|
+
app.post("/api/sessions/:id/questions/:qid/answer", async (c) => {
|
|
696
|
+
const session = sessionFor(c);
|
|
697
|
+
if (!session)
|
|
698
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
699
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
700
|
+
if (sessionEnded(session.id))
|
|
701
|
+
return sessionOver(c, session.id);
|
|
702
|
+
bumpContact(session.id);
|
|
703
|
+
if (typeof body.body !== "string" || body.body.trim() === "") {
|
|
704
|
+
return badRequest(c, "answer needs a non-empty body");
|
|
705
|
+
}
|
|
706
|
+
const qid = c.req.param("qid") ?? "";
|
|
707
|
+
const thread = answerQuestion(store.threadsPath(session.id), qid, body.body);
|
|
708
|
+
if (!thread) {
|
|
709
|
+
return c.json({
|
|
710
|
+
error: {
|
|
711
|
+
code: "E_UNKNOWN_QUESTION",
|
|
712
|
+
message: `session ${session.id} has no question ${qid}`,
|
|
713
|
+
},
|
|
714
|
+
}, 404);
|
|
715
|
+
}
|
|
716
|
+
publishThread(session.id, thread);
|
|
717
|
+
return c.json({
|
|
718
|
+
ok: true,
|
|
719
|
+
session: session.id,
|
|
720
|
+
question: qid,
|
|
721
|
+
answeredAt: thread.answer.answeredAt,
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
app.get("/api/sessions/:id/threads", (c) => {
|
|
725
|
+
const session = sessionFor(c);
|
|
726
|
+
if (!session)
|
|
727
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
728
|
+
return c.json({ session: session.id, threads: readThreads(store.threadsPath(session.id)) });
|
|
729
|
+
});
|
|
730
|
+
// The agent's grill question (otacon ask, DESIGN.md §6, §8): persisted in
|
|
731
|
+
// the transcript and pushed to the UI as a card; no agent event is queued —
|
|
732
|
+
// the asker goes straight back to `otacon wait` for the answer. Accepts a
|
|
733
|
+
// single question body or a batch (`{questions:[…]}`) of independent
|
|
734
|
+
// questions — independent siblings the agent posts in one call (§8); they
|
|
735
|
+
// render as ordinary cards, each answered instantly.
|
|
736
|
+
app.post("/api/sessions/:id/ask", async (c) => {
|
|
737
|
+
const session = sessionFor(c);
|
|
738
|
+
if (!session)
|
|
739
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
740
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
741
|
+
if (sessionEnded(session.id))
|
|
742
|
+
return sessionOver(c, session.id);
|
|
743
|
+
bumpContact(session.id);
|
|
744
|
+
// Batch: validate every member first, then mint all ids in one counter
|
|
745
|
+
// bump and append them in one write — a malformed member fails the whole
|
|
746
|
+
// batch, so the queue never holds a partial set (DECISIONS.md).
|
|
747
|
+
if (body.questions !== undefined) {
|
|
748
|
+
const raw = body.questions;
|
|
749
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
750
|
+
return badRequest(c, "questions must be a non-empty array of question objects");
|
|
751
|
+
}
|
|
752
|
+
const specs = [];
|
|
753
|
+
for (let i = 0; i < raw.length; i++) {
|
|
754
|
+
const spec = parseQuestionSpec(raw[i]);
|
|
755
|
+
if (typeof spec === "string")
|
|
756
|
+
return badRequest(c, `questions[${i}] ${spec}`);
|
|
757
|
+
specs.push(spec);
|
|
758
|
+
}
|
|
759
|
+
const counters = store.bumpCounters(session.id, { question: specs.length });
|
|
760
|
+
const first = counters.question - specs.length;
|
|
761
|
+
const askedAt = new Date().toISOString();
|
|
762
|
+
const entries = specs.map((spec, i) => entryFromSpec(`q${first + i + 1}`, spec, askedAt));
|
|
763
|
+
appendEntries(store.transcriptPath(session.id), entries);
|
|
764
|
+
for (const entry of entries)
|
|
765
|
+
publishGrill(session.id, entry);
|
|
766
|
+
publishSession(store.getSession(session.id) ?? session);
|
|
767
|
+
// A batch coalesces to one banner — N questions need answering (DESIGN.md §6).
|
|
768
|
+
maybeNotify(session, entries.length === 1
|
|
769
|
+
? { kind: "question", text: specs[0].question }
|
|
770
|
+
: { kind: "questions", count: entries.length });
|
|
771
|
+
return c.json({ ok: true, session: session.id, ids: entries.map((e) => e.id) }, 201);
|
|
772
|
+
}
|
|
773
|
+
const spec = parseQuestionSpec(body);
|
|
774
|
+
if (typeof spec === "string")
|
|
775
|
+
return badRequest(c, spec);
|
|
776
|
+
const counters = store.bumpCounters(session.id, { question: 1 });
|
|
777
|
+
const entry = entryFromSpec(`q${counters.question}`, spec, new Date().toISOString());
|
|
778
|
+
appendEntry(store.transcriptPath(session.id), entry);
|
|
779
|
+
publishGrill(session.id, entry);
|
|
780
|
+
// The summary's openQuestions just moved: the index's "questions pending"
|
|
781
|
+
// chip rides session frames, so every transcript change publishes one.
|
|
782
|
+
publishSession(store.getSession(session.id) ?? session);
|
|
783
|
+
maybeNotify(session, { kind: "question", text: spec.question });
|
|
784
|
+
return c.json({ ok: true, session: session.id, id: entry.id }, 201);
|
|
785
|
+
});
|
|
786
|
+
// The user's side of a grill question (DESIGN.md §6, §8): the answer lands
|
|
787
|
+
// on the transcript entry and an `answer` event wakes the parked agent.
|
|
788
|
+
app.post("/api/sessions/:id/answers", async (c) => {
|
|
789
|
+
const session = sessionFor(c);
|
|
790
|
+
if (!session)
|
|
791
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
792
|
+
const queue = queueFor(session.id); // before any state write: can throw on a corrupt file
|
|
793
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
794
|
+
if (sessionEnded(session.id))
|
|
795
|
+
return sessionOver(c, session.id);
|
|
796
|
+
const { question, choice, choices, text } = body;
|
|
797
|
+
if (typeof question !== "string" || question === "") {
|
|
798
|
+
return badRequest(c, "question must name a transcript question id (q<n>)");
|
|
799
|
+
}
|
|
800
|
+
const asked = readTranscript(store.transcriptPath(session.id)).find((e) => e.id === question);
|
|
801
|
+
if (!asked) {
|
|
802
|
+
return c.json({
|
|
803
|
+
error: {
|
|
804
|
+
code: "E_UNKNOWN_QUESTION",
|
|
805
|
+
message: `session ${session.id} has no grill question ${question}`,
|
|
806
|
+
},
|
|
807
|
+
}, 404);
|
|
808
|
+
}
|
|
809
|
+
if (text !== undefined && typeof text !== "string") {
|
|
810
|
+
return badRequest(c, "text must be a string");
|
|
811
|
+
}
|
|
812
|
+
// The answer must fit the question's shape: chips for option questions
|
|
813
|
+
// (one chip, or 1+ under --multi), free text for optionless ones. A
|
|
814
|
+
// non-empty custom answer with no chip is valid on option questions too
|
|
815
|
+
// (native-AskUserQuestion "Other" parity, DESIGN.md §8) — and text may
|
|
816
|
+
// still ride a chosen chip as a note.
|
|
817
|
+
const customText = typeof text === "string" && text.trim() !== "";
|
|
818
|
+
const noChips = choice === undefined && choices === undefined;
|
|
819
|
+
if (noChips) {
|
|
820
|
+
// "Other" parity (DESIGN.md §8): a non-empty custom answer with no chip
|
|
821
|
+
// is valid on ANY question shape — the one branch-independent rule, so it
|
|
822
|
+
// lives here, not re-stated per shape. Only the hint names the shape.
|
|
823
|
+
if (!customText) {
|
|
824
|
+
const need = asked.options === undefined
|
|
825
|
+
? "a non-empty text answer"
|
|
826
|
+
: asked.multi === true
|
|
827
|
+
? "chosen choices or a non-empty custom answer"
|
|
828
|
+
: "a single choice from its options or a non-empty custom answer";
|
|
829
|
+
return badRequest(c, `${question} needs ${need}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
else if (asked.options === undefined) {
|
|
833
|
+
return badRequest(c, `${question} has no options — answer with text only`);
|
|
834
|
+
}
|
|
835
|
+
else if (asked.multi === true) {
|
|
836
|
+
const ok = choice === undefined &&
|
|
837
|
+
Array.isArray(choices) &&
|
|
838
|
+
choices.length > 0 &&
|
|
839
|
+
choices.every((x) => typeof x === "string" && asked.options.includes(x)) &&
|
|
840
|
+
new Set(choices).size === choices.length;
|
|
841
|
+
if (!ok) {
|
|
842
|
+
return badRequest(c, `${question} is multi-choice — pass distinct choices from its options`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else if (choices !== undefined || typeof choice !== "string" || !asked.options.includes(choice)) {
|
|
846
|
+
// Single-choice with a chip: exactly one valid `choice`, never `choices`.
|
|
847
|
+
return badRequest(c, `${question} needs a single choice from its options`);
|
|
848
|
+
}
|
|
849
|
+
const answer = {
|
|
850
|
+
...(typeof choice === "string" ? { choice } : {}),
|
|
851
|
+
...(Array.isArray(choices) ? { choices: choices } : {}),
|
|
852
|
+
...(customText ? { text: text } : {}),
|
|
853
|
+
answeredAt: new Date().toISOString(),
|
|
854
|
+
};
|
|
855
|
+
// Re-answering overwrites (at-least-once: a duplicate POST is legitimate);
|
|
856
|
+
// the agent sees a second answer event with the same question id.
|
|
857
|
+
const updated = answerEntry(store.transcriptPath(session.id), question, answer);
|
|
858
|
+
const payload = {
|
|
859
|
+
event: "answer",
|
|
860
|
+
session: session.id,
|
|
861
|
+
question,
|
|
862
|
+
...(answer.choice !== undefined ? { choice: answer.choice } : {}),
|
|
863
|
+
...(answer.choices !== undefined ? { choices: answer.choices } : {}),
|
|
864
|
+
...(answer.text !== undefined ? { text: answer.text } : {}),
|
|
865
|
+
};
|
|
866
|
+
queue.enqueue(payload, store.bumpCounter(session.id, "eventSeq"));
|
|
867
|
+
publishQueue(session.id, queue.size);
|
|
868
|
+
if (updated)
|
|
869
|
+
publishGrill(session.id, updated);
|
|
870
|
+
// openQuestions dropped (or held, on a re-answer) — keep the chip honest.
|
|
871
|
+
publishSession(store.getSession(session.id) ?? session);
|
|
872
|
+
return c.json({ ok: true, session: session.id, question }, 202);
|
|
873
|
+
});
|
|
874
|
+
app.get("/api/sessions/:id/transcript", (c) => {
|
|
875
|
+
const session = sessionFor(c);
|
|
876
|
+
if (!session)
|
|
877
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
878
|
+
return c.json({
|
|
879
|
+
session: session.id,
|
|
880
|
+
transcript: readTranscript(store.transcriptPath(session.id)),
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
// The agent's narration (otacon progress, DESIGN.md §6, §8): a non-blocking
|
|
884
|
+
// progress note appended to the capped activity feed and pushed to the UI as
|
|
885
|
+
// an `activity` frame (the per-session log) plus a `session` frame (the
|
|
886
|
+
// chip's latestActivity). No agent event is queued — like `ask`, this is
|
|
887
|
+
// UI-only telemetry, never a wake-up. The note is trimmed to the configured
|
|
888
|
+
// max so long narration never fails or bloats payloads.
|
|
889
|
+
app.post("/api/sessions/:id/progress", async (c) => {
|
|
890
|
+
const session = sessionFor(c);
|
|
891
|
+
if (!session)
|
|
892
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
893
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
894
|
+
if (sessionEnded(session.id))
|
|
895
|
+
return sessionOver(c, session.id);
|
|
896
|
+
const raw = body.note;
|
|
897
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
898
|
+
return badRequest(c, "note must be a non-empty string");
|
|
899
|
+
}
|
|
900
|
+
const { activity } = loadConfig(session.repo);
|
|
901
|
+
const trimmed = raw.trim();
|
|
902
|
+
const text = trimmed.length > activity.noteMaxChars
|
|
903
|
+
? `${trimmed.slice(0, Math.max(1, activity.noteMaxChars - 1)).trimEnd()}…`
|
|
904
|
+
: trimmed;
|
|
905
|
+
const note = appendActivity(store.activityPath(session.id), text, activity.cap, new Date().toISOString());
|
|
906
|
+
bumpContact(session.id);
|
|
907
|
+
notifier.publish({ type: "activity", session: session.id, data: { session: session.id, note } });
|
|
908
|
+
publishSession(session); // latestActivity for the chip; fresh contact for the dot
|
|
909
|
+
return c.json({ ok: true, session: session.id, note: text });
|
|
910
|
+
});
|
|
911
|
+
// Approve ends the planning session (DESIGN.md §6 step 6, §12): the daemon
|
|
912
|
+
// writes docs/plans/YYYY-MM-DD-<slug>.md (final revision, status: approved,
|
|
913
|
+
// grill transcript appended) and queues the `approved` event for the parked
|
|
914
|
+
// agent to commit the file. Plain Approve flips the session `approved` —
|
|
915
|
+
// terminal, every mutating verb then refuses. **Approve & Implement**
|
|
916
|
+
// ({implement:true}) instead flips it to the non-terminal `implementing` and
|
|
917
|
+
// sets `implement:true` on the event: the agent commits the plan exactly as
|
|
918
|
+
// plain Approve, then proceeds to build it (DESIGN.md §12). Unresolved
|
|
919
|
+
// threads refuse 409 unless {force}.
|
|
920
|
+
app.post("/api/sessions/:id/approve", async (c) => {
|
|
921
|
+
const session = sessionFor(c);
|
|
922
|
+
if (!session)
|
|
923
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
924
|
+
const queue = queueFor(session.id); // before any state write: can throw on a corrupt file
|
|
925
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
926
|
+
// Doubles as the double-approve guard: two concurrent approves both
|
|
927
|
+
// snapshot in_review, but the loser re-checks here after its body await
|
|
928
|
+
// and refuses instead of writing a second (-2 suffixed) artifact.
|
|
929
|
+
if (sessionEnded(session.id))
|
|
930
|
+
return sessionOver(c, session.id);
|
|
931
|
+
// `implementing` is non-terminal, so it slips past sessionEnded — but a
|
|
932
|
+
// build is already under way, and re-approving would re-write the artifact
|
|
933
|
+
// and re-queue the wake-up. Refuse it explicitly (the second tap on an
|
|
934
|
+
// Approve & Implement, or a stray approve while the agent builds).
|
|
935
|
+
if (store.getSession(session.id)?.status === "implementing") {
|
|
936
|
+
return c.json({
|
|
937
|
+
error: {
|
|
938
|
+
code: "E_ALREADY_IMPLEMENTING",
|
|
939
|
+
message: `session ${session.id} is already implementing`,
|
|
940
|
+
},
|
|
941
|
+
}, 409);
|
|
942
|
+
}
|
|
943
|
+
if (body.force !== undefined && typeof body.force !== "boolean") {
|
|
944
|
+
return badRequest(c, "force must be a boolean");
|
|
945
|
+
}
|
|
946
|
+
if (body.implement !== undefined && typeof body.implement !== "boolean") {
|
|
947
|
+
return badRequest(c, "implement must be a boolean");
|
|
948
|
+
}
|
|
949
|
+
const implement = body.implement === true;
|
|
950
|
+
const state = store.readState(session.id);
|
|
951
|
+
if (state.revision === 0) {
|
|
952
|
+
return c.json({
|
|
953
|
+
error: {
|
|
954
|
+
code: "E_NO_REVISION",
|
|
955
|
+
message: `session ${session.id} has no revisions to approve`,
|
|
956
|
+
},
|
|
957
|
+
}, 409);
|
|
958
|
+
}
|
|
959
|
+
// Unresolved = comment threads with no resolution + user questions with no
|
|
960
|
+
// answer — the same open items the rail shows. The 409 carries the count;
|
|
961
|
+
// the UI warns and retries with {force:true} on confirm.
|
|
962
|
+
const unresolved = readThreads(store.threadsPath(session.id)).filter((t) => t.kind === "comment" ? t.resolution === undefined : t.answer === undefined).length;
|
|
963
|
+
if (unresolved > 0 && body.force !== true) {
|
|
964
|
+
return c.json({
|
|
965
|
+
error: {
|
|
966
|
+
code: "E_UNRESOLVED_THREADS",
|
|
967
|
+
message: `session has ${unresolved} unresolved thread(s); approve with {"force":true} to override`,
|
|
968
|
+
},
|
|
969
|
+
unresolved,
|
|
970
|
+
}, 409);
|
|
971
|
+
}
|
|
972
|
+
const artifact = composeArtifact(store.readRevision(session.id, state.revision), {
|
|
973
|
+
revision: state.revision,
|
|
974
|
+
entries: readTranscript(store.transcriptPath(session.id)),
|
|
975
|
+
});
|
|
976
|
+
const relPath = pickArtifactRelPath(session.repo, session.title, localDate());
|
|
977
|
+
// Artifact on disk first, then the status flip (the registry is the commit
|
|
978
|
+
// point — same ordering argument as createSession), then the wake-up. The
|
|
979
|
+
// artifact + `path` are identical on both branches: the agent commits the
|
|
980
|
+
// plan the same way; `implement` only tells it whether to keep building.
|
|
981
|
+
writeFileAtomic(join(session.repo, relPath), artifact);
|
|
982
|
+
const updated = store.updateSession(session.id, {
|
|
983
|
+
status: implement ? "implementing" : "approved",
|
|
984
|
+
});
|
|
985
|
+
const payload = implement
|
|
986
|
+
? { event: "approved", session: session.id, path: relPath, implement: true }
|
|
987
|
+
: { event: "approved", session: session.id, path: relPath };
|
|
988
|
+
queue.enqueue(payload, store.bumpCounter(session.id, "eventSeq"));
|
|
989
|
+
publishSession(updated); // after the enqueue, so the summary carries the fresh pending count
|
|
990
|
+
return c.json({
|
|
991
|
+
ok: true,
|
|
992
|
+
session: session.id,
|
|
993
|
+
revision: state.revision,
|
|
994
|
+
path: relPath,
|
|
995
|
+
unresolved,
|
|
996
|
+
implement,
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
// Approve & Implement's outcome report (DESIGN.md §6, §12): once the agent has
|
|
1000
|
+
// built the approved plan it reports here. `failed:true` flips the session
|
|
1001
|
+
// `implement_failed`, otherwise `implemented` (both terminal). A `pr` URL is
|
|
1002
|
+
// persisted on the registry session so the home card can surface the link.
|
|
1003
|
+
// The session must currently be `implementing` — that check runs FIRST, so a
|
|
1004
|
+
// double-report (the second sees a terminal state) and a stray call on a
|
|
1005
|
+
// never-implementing session both get a clear E_NOT_IMPLEMENTING instead of
|
|
1006
|
+
// the generic terminal wall.
|
|
1007
|
+
app.post("/api/sessions/:id/implement-done", async (c) => {
|
|
1008
|
+
const session = sessionFor(c);
|
|
1009
|
+
if (!session)
|
|
1010
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
1011
|
+
const body = (await readJsonBody(c)) ?? {};
|
|
1012
|
+
bumpContact(session.id);
|
|
1013
|
+
if (store.getSession(session.id)?.status !== "implementing") {
|
|
1014
|
+
return c.json({
|
|
1015
|
+
error: {
|
|
1016
|
+
code: "E_NOT_IMPLEMENTING",
|
|
1017
|
+
message: `session ${session.id} is not implementing`,
|
|
1018
|
+
},
|
|
1019
|
+
}, 409);
|
|
1020
|
+
}
|
|
1021
|
+
const { pr, failed } = body;
|
|
1022
|
+
if (pr !== undefined && (typeof pr !== "string" || pr.trim() === "")) {
|
|
1023
|
+
return badRequest(c, "pr must be a non-empty string");
|
|
1024
|
+
}
|
|
1025
|
+
if (failed !== undefined && typeof failed !== "boolean") {
|
|
1026
|
+
return badRequest(c, "failed must be a boolean");
|
|
1027
|
+
}
|
|
1028
|
+
const status = failed === true ? "implement_failed" : "implemented";
|
|
1029
|
+
const updated = store.updateSession(session.id, {
|
|
1030
|
+
status,
|
|
1031
|
+
...(typeof pr === "string" ? { prUrl: pr } : {}),
|
|
1032
|
+
});
|
|
1033
|
+
publishSession(updated); // the chip flips + the PR link appears live
|
|
1034
|
+
return c.json({ ok: true, session: updated, status, prUrl: updated.prUrl });
|
|
1035
|
+
});
|
|
1036
|
+
app.get("/api/sessions/:id/revisions/:n", (c) => {
|
|
1037
|
+
const session = sessionFor(c);
|
|
1038
|
+
if (!session)
|
|
1039
|
+
return notFound(c, `unknown session: ${c.req.param("id")}`);
|
|
1040
|
+
const n = Number(c.req.param("n"));
|
|
1041
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1042
|
+
return badRequest(c, "revision must be a positive integer");
|
|
1043
|
+
}
|
|
1044
|
+
if (n > store.readState(session.id).revision) {
|
|
1045
|
+
return notFound(c, `session ${session.id} has no revision ${n}`);
|
|
1046
|
+
}
|
|
1047
|
+
// Default is the raw markdown (byte-identical read-back; the CLI/curl
|
|
1048
|
+
// path). The web UI asks for JSON to get the lint warnings the revision
|
|
1049
|
+
// was accepted with alongside it (DESIGN.md §6).
|
|
1050
|
+
if (c.req.header("accept")?.toLowerCase().includes("application/json")) {
|
|
1051
|
+
const payload = {
|
|
1052
|
+
session: session.id,
|
|
1053
|
+
revision: n,
|
|
1054
|
+
markdown: store.readRevision(session.id, n),
|
|
1055
|
+
warnings: store.readRevisionWarnings(session.id, n),
|
|
1056
|
+
changelog: store.readRevisionChangelog(session.id, n),
|
|
1057
|
+
};
|
|
1058
|
+
return c.json(payload);
|
|
1059
|
+
}
|
|
1060
|
+
return c.text(store.readRevision(session.id, n), 200, {
|
|
1061
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
// The SPA (GET /, GET /s/:id, /assets/*) and its SSE feeds (GET /api/stream,
|
|
1065
|
+
// GET /api/sessions/:id/stream) — see ui.ts.
|
|
1066
|
+
registerUiRoutes(app, {
|
|
1067
|
+
notifier,
|
|
1068
|
+
listSummaries: () => store.listSessions().map(summarize),
|
|
1069
|
+
getSummary: (id) => {
|
|
1070
|
+
const session = store.getSession(id);
|
|
1071
|
+
return session ? summarize(session) : undefined;
|
|
1072
|
+
},
|
|
1073
|
+
getThreads: (id) => readThreads(store.threadsPath(id)),
|
|
1074
|
+
getTranscript: (id) => readTranscript(store.transcriptPath(id)),
|
|
1075
|
+
getActivity: (id) => readActivity(store.activityPath(id)),
|
|
1076
|
+
uiDir: options.uiDir,
|
|
1077
|
+
heartbeatMs: options.sseHeartbeatMs,
|
|
1078
|
+
});
|
|
1079
|
+
return app;
|
|
1080
|
+
}
|
|
1081
|
+
//# sourceMappingURL=app.js.map
|