@dyzsasd/dev-loop 0.22.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/README.md +55 -0
- package/dist/agentops.js +551 -0
- package/dist/channel.js +226 -0
- package/dist/channelstore.js +269 -0
- package/dist/cli-tickets.js +131 -0
- package/dist/cli.js +77 -0
- package/dist/daemon-lifecycle.js +372 -0
- package/dist/daemon.js +805 -0
- package/dist/daemonviews.js +691 -0
- package/dist/db.js +385 -0
- package/dist/docstore.js +110 -0
- package/dist/doctor.js +230 -0
- package/dist/init-service.js +206 -0
- package/dist/labelstore.js +34 -0
- package/dist/linear.js +60 -0
- package/dist/mcp-merge.js +145 -0
- package/dist/mirrorstore.js +128 -0
- package/dist/release-version.js +39 -0
- package/dist/resolve-project.js +82 -0
- package/dist/seed.js +76 -0
- package/dist/server.js +134 -0
- package/dist/shim.js +146 -0
- package/dist/ticketwrite.js +147 -0
- package/dist/tooldefs.js +147 -0
- package/dist/topicstore.js +174 -0
- package/package.json +91 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
// dev-loop hub daemon — a persistent localhost HTTP read surface over the hub SoR (DL-1).
|
|
2
|
+
//
|
|
3
|
+
// READ-ONLY by construction: it opens the SAME node:sqlite DB the MCP server uses, sets
|
|
4
|
+
// `PRAGMA query_only=ON` (a structural guarantee it can never write the system of record),
|
|
5
|
+
// serves ONLY GET endpoints (any other method → 405), and never mutates tickets/docs/events.
|
|
6
|
+
// Binds 127.0.0.1 ONLY (§16) — never 0.0.0.0, no external exposure.
|
|
7
|
+
//
|
|
8
|
+
// The agents are UNCHANGED: they keep coordinating through the MCP server (`server.ts`); this is
|
|
9
|
+
// an additive human-facing read surface, NOT a new coordinator (strategyDoc Decisions log,
|
|
10
|
+
// 2026-06-23). DL-2 added a server-rendered web UI at `/` (board + ticket detail) and moved the
|
|
11
|
+
// JSON API index to `/api`; the `/api/*` JSON endpoints are unchanged. Write paths (roadmap edit)
|
|
12
|
+
// build on this later (DL-3).
|
|
13
|
+
//
|
|
14
|
+
// Zero native deps, zero build step (Node ≥23.6 type-stripping + built-in node:http/node:sqlite),
|
|
15
|
+
// reusing the existing `db.ts` schema with NO schema fork (hub doctrine).
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
|
+
import { openDb, actorExists, logEvent } from "./db.js";
|
|
21
|
+
import { findProject } from "./seed.js";
|
|
22
|
+
import { loadProjectsConfig } from "./resolve-project.js";
|
|
23
|
+
import { resolveDoc, docSave, docPublish, statusForDocErr } from "./docstore.js";
|
|
24
|
+
import { createTicket, addComment, moveTicket, assignTicket } from "./ticketwrite.js";
|
|
25
|
+
import { agentOp, AGENT_WRITE_OPS, isAgentOp } from "./agentops.js"; // DL-43: the daemon agent op-API's 5-op core (mirrors server.ts)
|
|
26
|
+
import { getEnabledChannel, resolveCreds, resolveNotifyWebhook, scrubErr, cleanLine, sendVia, CHANNEL_DRYRUN, CHANNEL_SEND_CAP } from "./channel.js";
|
|
27
|
+
// DL-74: the HTML view layer (every page renderer + esc/toTicket/eventData) lives in daemonviews.ts; the
|
|
28
|
+
// per-project process-lifecycle subsystem lives in daemon-lifecycle.ts. This file keeps HTTP routing
|
|
29
|
+
// (createDaemon), the write-route handlers, the background timers, and the CLI dispatch + foreground boot.
|
|
30
|
+
import { page, esc, toTicket, boardPage, ticketPage, roadmapPage, activityPage, reportsIndexPage, reportsRoot, reportPage, eventData } from "./daemonviews.js";
|
|
31
|
+
import { daemonLifecycle, LIFECYCLE_SUBS } from "./daemon-lifecycle.js";
|
|
32
|
+
// DL-83: does THIS project's resolved config make the hub roadmap doc its north-star, or is a repo-file
|
|
33
|
+
// strategyDoc the north-star? Returns the strategyDoc PATH when NO agent reads the hub roadmap doc
|
|
34
|
+
// (hub.docs:false/absent AND no director config AND a string strategyDoc) → the /roadmap divergence banner;
|
|
35
|
+
// else undefined (the hub roadmap IS the north-star — hub.docs:true or a director chairs it — or the config
|
|
36
|
+
// is unknown) → no banner. Pure + derived from config ONLY (never request input, §17), so it is unit-testable.
|
|
37
|
+
export function roadmapDivergenceDoc(proj) {
|
|
38
|
+
if (!proj)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (proj.hub?.docs === true)
|
|
41
|
+
return undefined; // a first-class hub doc IS the north-star
|
|
42
|
+
if (proj.director != null)
|
|
43
|
+
return undefined; // a Director drafts/chairs the hub roadmap → north-star
|
|
44
|
+
return typeof proj.strategyDoc === "string" ? proj.strategyDoc : undefined;
|
|
45
|
+
}
|
|
46
|
+
function json(res, status, body) {
|
|
47
|
+
const s = JSON.stringify(body);
|
|
48
|
+
res.writeHead(status, {
|
|
49
|
+
"content-type": "application/json; charset=utf-8",
|
|
50
|
+
"content-length": Buffer.byteLength(s),
|
|
51
|
+
"cache-control": "no-store",
|
|
52
|
+
});
|
|
53
|
+
res.end(s);
|
|
54
|
+
}
|
|
55
|
+
function htmlOut(res, status, body) {
|
|
56
|
+
res.writeHead(status, {
|
|
57
|
+
"content-type": "text/html; charset=utf-8",
|
|
58
|
+
"content-length": Buffer.byteLength(body),
|
|
59
|
+
"cache-control": "no-store",
|
|
60
|
+
});
|
|
61
|
+
res.end(body);
|
|
62
|
+
}
|
|
63
|
+
// DL-41: a REAL `/api/health` liveness check — NOT a static {ok:true}. Proves the SoR is reachable (a
|
|
64
|
+
// trivial read) AND writable (acquire+release the RESERVED write lock without mutating), so a
|
|
65
|
+
// bound-but-wedged daemon (port open, but DB gone/corrupt/readonly/disk-full/closed) reads as NOT
|
|
66
|
+
// healthy and the lifecycle's `up`/`status` (which probe this endpoint) recover it instead of no-op'ing
|
|
67
|
+
// onto a dead process. A read-only daemon (no writeDb) verifies reachability only — it has no write
|
|
68
|
+
// surface to probe. §16-safe: no mutation persists (BEGIN IMMEDIATE → ROLLBACK), errors are scrubbed.
|
|
69
|
+
function healthLiveness(db, writeDb) {
|
|
70
|
+
try {
|
|
71
|
+
db.prepare("SELECT 1").get(); // read liveness: the connection + DB file are reachable & not corrupt
|
|
72
|
+
if (writeDb) {
|
|
73
|
+
// BEGIN IMMEDIATE takes the reserved write lock; ROLLBACK releases it — nothing persists. A
|
|
74
|
+
// SQLITE_BUSY means another writer holds it ⇒ the SoR IS writable (just momentarily contended) ⇒
|
|
75
|
+
// healthy; only a non-busy error (readonly fs / corrupt / disk-full / closed handle) is a real wedge.
|
|
76
|
+
try {
|
|
77
|
+
writeDb.exec("BEGIN IMMEDIATE; ROLLBACK;");
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
try {
|
|
81
|
+
writeDb.exec("ROLLBACK");
|
|
82
|
+
}
|
|
83
|
+
catch { /* no open txn to undo */ }
|
|
84
|
+
if (!/busy|locked/i.test(String(e?.message ?? e)))
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return { ok: false, error: scrubErr(String(e?.message ?? e)) };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Defensively decode a single URL path segment. A malformed / incomplete percent-escape
|
|
95
|
+
// (e.g. "%", "%ZZ", an incomplete UTF-8 sequence "%E0%A4") makes decodeURIComponent throw a
|
|
96
|
+
// URIError — that is a CLIENT error, so callers surface 400 (matching the daemon's existing
|
|
97
|
+
// "bad request url" → 400 contract) instead of letting it fall through to the generic 500 catch
|
|
98
|
+
// (DL-7). Returns null when the segment cannot be decoded.
|
|
99
|
+
function decodeSeg(seg) {
|
|
100
|
+
try {
|
|
101
|
+
return decodeURIComponent(seg);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Read an application/x-www-form-urlencoded body (the roadmap edit/publish forms), bounded so a runaway
|
|
108
|
+
// upload can't exhaust memory. Localhost-only, but defensive anyway. Two correctness points: accumulate
|
|
109
|
+
// Buffers and decode ONCE at the end (a per-chunk `buf.toString()` mangles a multibyte char split across
|
|
110
|
+
// a TCP read boundary), and ALWAYS settle the Promise — on over-limit (reject + destroy), normal end,
|
|
111
|
+
// error, OR a premature 'close' (a destroyed/aborted socket emits 'close' but neither 'end' nor 'error',
|
|
112
|
+
// which would otherwise dangle the awaiting handler forever).
|
|
113
|
+
const MAX_BODY = 1_000_000; // 1 MB of body bytes — a roadmap doc is text; orders of magnitude above any real edit
|
|
114
|
+
// Bounded read of the full request body as bytes, settling EXACTLY ONCE on every terminal event (over-limit
|
|
115
|
+
// reject+destroy / normal end / error / premature 'close' — a destroyed socket emits 'close' but neither
|
|
116
|
+
// 'end' nor 'error', which would otherwise dangle the awaiting handler forever). The decode is the caller's
|
|
117
|
+
// — one read loop shared by parseFormBody (urlencoded forms) and parseJsonBody (the DL-43 op-API).
|
|
118
|
+
function readBodyBytes(req) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const chunks = [];
|
|
121
|
+
let len = 0, settled = false;
|
|
122
|
+
const settle = (fn) => { if (!settled) {
|
|
123
|
+
settled = true;
|
|
124
|
+
fn();
|
|
125
|
+
} };
|
|
126
|
+
req.on("data", (c) => {
|
|
127
|
+
len += c.length;
|
|
128
|
+
if (len > MAX_BODY) {
|
|
129
|
+
settle(() => reject(new Error("request body too large")));
|
|
130
|
+
req.destroy();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
chunks.push(c);
|
|
134
|
+
});
|
|
135
|
+
req.on("end", () => settle(() => resolve(Buffer.concat(chunks)))); // decode ONCE at the end (a per-chunk toString mangles a multibyte char split across a TCP read)
|
|
136
|
+
req.on("error", (e) => settle(() => reject(e)));
|
|
137
|
+
req.on("close", () => settle(() => reject(new Error("request closed before it completed"))));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const parseFormBody = (req) => readBodyBytes(req).then((b) => new URLSearchParams(b.toString("utf8")));
|
|
141
|
+
function redirect(res, location) {
|
|
142
|
+
res.writeHead(303, { location, "content-length": 0 }); // 303 See Other — POST→GET (Post/Redirect/Get)
|
|
143
|
+
res.end();
|
|
144
|
+
}
|
|
145
|
+
// POST /roadmap/save | /roadmap/publish — the ONLY write routes. Both hard-target the kind:"roadmap"
|
|
146
|
+
// document through docstore (DB-doc-only; no filesystem path ⇒ §17 firewall). save → a DRAFT via the
|
|
147
|
+
// CAS (a stale baseVersion is surfaced as a CONFLICT, never last-write-wins); publish → operator-gated.
|
|
148
|
+
// `statusForDocErr` (the docstore-error → HTTP-status map) now lives in docstore.ts so this roadmap path
|
|
149
|
+
// and the DL-43/DL-62 agent op-API can't drift on it.
|
|
150
|
+
// DL-19: CSRF + DNS-rebinding guard for the write routes. The daemon is http localhost-only, so the
|
|
151
|
+
// ONLY legitimate origin is the host the operator's own browser connected to. Refuse:
|
|
152
|
+
// (a) a Host that isn't 127.0.0.1/localhost — a DNS-rebound name resolving to 127.0.0.1 reaches the
|
|
153
|
+
// bind, and the loopback bind alone never validates Host (the rebinding bypass), and
|
|
154
|
+
// (b) a cross-origin Origin/Referer — a urlencoded form is a CORS "simple request" (no preflight),
|
|
155
|
+
// so a page the operator visits can auto-submit to these routes as the operator (textbook CSRF).
|
|
156
|
+
// An ABSENT Origin AND Referer is allowed: a browser CSRF auto-submit always carries Origin, so absence
|
|
157
|
+
// means a non-browser client (curl / the operator's own tooling / tests) — not the CSRF vector, and it
|
|
158
|
+
// must keep working. Origin is preferred over Referer when present.
|
|
159
|
+
// INVARIANT: this literal Host allowlist is sufficient ONLY because the server binds the v4 loopback
|
|
160
|
+
// (127.0.0.1) ONLY — see the `HOST = "127.0.0.1"` bind below. If that bind ever widens (0.0.0.0, ::1,
|
|
161
|
+
// a LAN address), this guard must widen with it (resolve/validate accordingly), or it silently weakens.
|
|
162
|
+
const LOCAL_HOST = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
163
|
+
function writeOriginOk(req) {
|
|
164
|
+
const host = req.headers.host;
|
|
165
|
+
if (!host || !LOCAL_HOST.test(host))
|
|
166
|
+
return false; // (a) foreign/rebound Host → refuse before any write
|
|
167
|
+
const allowed = `http://${host}`; // the daemon is http localhost-only (the served page's origin)
|
|
168
|
+
const origin = req.headers.origin;
|
|
169
|
+
if (origin !== undefined)
|
|
170
|
+
return origin === allowed; // (b) Origin present → must be same-origin
|
|
171
|
+
const referer = req.headers.referer;
|
|
172
|
+
if (referer !== undefined) {
|
|
173
|
+
try {
|
|
174
|
+
return new URL(referer).origin === allowed;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return true; // no Origin/Referer → non-browser client (allowed)
|
|
181
|
+
}
|
|
182
|
+
async function handleRoadmapWrite(action, req, res, db, writeDb, projectId, projectKey, actor, roadmapRepoFileStrategy) {
|
|
183
|
+
let form;
|
|
184
|
+
// If the body was rejected (too large / aborted), the socket may already be destroyed — only respond
|
|
185
|
+
// when the response is still writable, so we never throw write-after-destroy into the outer catch.
|
|
186
|
+
try {
|
|
187
|
+
form = await parseFormBody(req);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
if (!res.headersSent && !res.destroyed)
|
|
191
|
+
json(res, 400, { error: e.message });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Resolve the roadmap doc's slug SERVER-SIDE (never from the form) so the write target can't be redirected.
|
|
195
|
+
const slug = resolveDoc(writeDb, projectId, undefined, "roadmap")?.slug ?? "roadmap";
|
|
196
|
+
// DL-14: on a rejected re-render, preserve the user's submitted body in the textarea (so a CAS
|
|
197
|
+
// conflict / validation error doesn't discard a substantial edit). roadmapPage recomputes the hidden
|
|
198
|
+
// `baseVersion` from the current latest, so an immediate re-submit targets the right base.
|
|
199
|
+
const rerender = (msg, submittedBody) => htmlOut(res, statusForDocErr(msg), page(`roadmap · ${projectKey}`, projectKey, roadmapPage(db, projectId, { writable: true, canPublish: actor === "operator", notice: { kind: "error", msg }, submittedBody, roadmapRepoFileStrategy })));
|
|
200
|
+
if (action === "save") {
|
|
201
|
+
const baseVersion = Number(form.get("baseVersion"));
|
|
202
|
+
if (!Number.isInteger(baseVersion) || baseVersion < 0)
|
|
203
|
+
return json(res, 400, { error: "baseVersion must be a non-negative integer" });
|
|
204
|
+
const r = docSave(writeDb, projectId, actor, { slug, kind: "roadmap", body: form.get("body") ?? "", baseVersion, summary: form.get("summary") ?? undefined });
|
|
205
|
+
return r.ok ? redirect(res, "/roadmap") : rerender(r.error, form.get("body") ?? ""); // 409 CONFLICT (stale base) — surfaced, and the typed edit is preserved (DL-14)
|
|
206
|
+
}
|
|
207
|
+
const version = Number(form.get("version"));
|
|
208
|
+
if (!Number.isInteger(version) || version <= 0)
|
|
209
|
+
return json(res, 400, { error: "version must be a positive integer" });
|
|
210
|
+
const r = docPublish(writeDb, projectId, actor, { kind: "roadmap", version });
|
|
211
|
+
return r.ok ? redirect(res, "/roadmap") : rerender(r.error); // non-operator → 403; missing version → 404
|
|
212
|
+
}
|
|
213
|
+
// ─── DL-29: opt-in human web-write surface (design §11 subsystem D) ──────────────────────────────
|
|
214
|
+
// POST /ticket (create) · /ticket/:id/comment · /ticket/:id/move · /ticket/:id/assign. Present ONLY when
|
|
215
|
+
// a write connection + actor exist (canWrite) AND settings_json.humanWrite.enabled is true. Read FRESH per
|
|
216
|
+
// request so the operator can flip the flag without a daemon restart. Absent/false ⇒ these POSTs are NOT
|
|
217
|
+
// matched and fall through to the read-only 405 (byte-identical to today). The same localhost CSRF /
|
|
218
|
+
// DNS-rebinding guard as /roadmap/* (writeOriginOk) runs BEFORE any write.
|
|
219
|
+
function humanWriteEnabled(db, projectId) {
|
|
220
|
+
try {
|
|
221
|
+
const row = db.prepare("SELECT settings_json FROM projects WHERE id=?").get(projectId);
|
|
222
|
+
return JSON.parse(row?.settings_json ?? "{}")?.humanWrite?.enabled === true;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function isTicketWriteRoute(seg) {
|
|
229
|
+
return (seg.length === 1 && seg[0] === "ticket")
|
|
230
|
+
|| (seg.length === 3 && seg[0] === "ticket" && (seg[2] === "comment" || seg[2] === "move" || seg[2] === "assign"));
|
|
231
|
+
}
|
|
232
|
+
async function handleTicketWrite(seg, req, res, db, writeDb, projectId, projectKey, actor) {
|
|
233
|
+
let form;
|
|
234
|
+
try {
|
|
235
|
+
form = await parseFormBody(req);
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
if (!res.headersSent && !res.destroyed)
|
|
239
|
+
json(res, 400, { error: e.message });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (seg.length === 1) { // POST /ticket — create, then PRG to the new ticket
|
|
243
|
+
const r = createTicket(writeDb, projectId, actor, { title: form.get("title") ?? "", description: form.get("description") ?? undefined, type: form.get("type") ?? undefined });
|
|
244
|
+
if (r.ok)
|
|
245
|
+
return redirect(res, `/ticket/${encodeURIComponent(r.id)}`);
|
|
246
|
+
// DL-86: a rejected create re-renders the BOARD as HTML with the error inline + the typed title preserved
|
|
247
|
+
// (mirrors the /roadmap/save rerender), instead of dead-ending the operator on a raw-JSON {error} page.
|
|
248
|
+
return htmlOut(res, r.status, page(`${projectKey} · board`, projectKey, boardPage(db, projectId, projectKey, {}, true, undefined, { notice: { kind: "error", msg: r.error }, submittedTitle: form.get("title") ?? "" })));
|
|
249
|
+
}
|
|
250
|
+
const id = decodeSeg(seg[1]);
|
|
251
|
+
if (id === null)
|
|
252
|
+
return json(res, 400, { error: "malformed percent-escape in path" });
|
|
253
|
+
const verb = seg[2];
|
|
254
|
+
const r = verb === "comment" ? addComment(writeDb, projectId, actor, id, form.get("body") ?? "")
|
|
255
|
+
: verb === "move" ? moveTicket(writeDb, projectId, actor, id, form.get("state") ?? "")
|
|
256
|
+
: assignTicket(writeDb, projectId, actor, id, form.get("assignee") ?? "");
|
|
257
|
+
if (r.ok)
|
|
258
|
+
return redirect(res, `/ticket/${encodeURIComponent(id)}`);
|
|
259
|
+
// DL-86: a rejected move/assign/comment re-renders the TICKET PAGE as HTML with the error inline (+ the typed
|
|
260
|
+
// comment preserved on a rejected comment), instead of a raw-JSON dead-end. If the ticket is gone (ticketPage
|
|
261
|
+
// null) fall back to the JSON error — there is no page to re-render.
|
|
262
|
+
const inner = ticketPage(db, projectId, id, true, { notice: { kind: "error", msg: r.error }, submittedComment: verb === "comment" ? (form.get("body") ?? "") : undefined });
|
|
263
|
+
if (!inner)
|
|
264
|
+
return json(res, r.status, { error: r.error });
|
|
265
|
+
return htmlOut(res, r.status, page(`${id} · ${projectKey}`, projectKey, inner));
|
|
266
|
+
}
|
|
267
|
+
// ─── DL-43: opt-in daemon agent op-API (/api/op/*) — the MCP↔daemon unification foundation (P1) ───────────
|
|
268
|
+
// A DORMANT, default-OFF loopback surface serving the 5 core ticket ops (agentops.ts, mirroring server.ts
|
|
269
|
+
// 1:1) so a later increment's thin stdio MCP shim (P2) can proxy to the daemon instead of opening hub.db
|
|
270
|
+
// directly. Gated on settings_json.hub.transport==="daemon" (read FRESH per request, the DL-29 humanWrite
|
|
271
|
+
// pattern): unset/≠"daemon" ⇒ the /api/op/* mount is dormant → 404 and every read/roadmap surface is
|
|
272
|
+
// byte-for-byte unchanged. server.ts (the stdio transport) is 100% untouched. handleAgentOp owns the full
|
|
273
|
+
// endpoint pipeline: writeOriginOk (DL-19 CSRF/DNS-rebind wall) → the X-Devloop-Actor header → the G1
|
|
274
|
+
// phantom-actor guard → (writes only) the dry-run mode gate → dispatch. Read ops use the query_only `db`;
|
|
275
|
+
// write ops use the writable `writeDb` (the same connection the human-write routes write through).
|
|
276
|
+
function agentApiEnabled(db, projectId) {
|
|
277
|
+
try {
|
|
278
|
+
const row = db.prepare("SELECT settings_json FROM projects WHERE id=?").get(projectId);
|
|
279
|
+
return JSON.parse(row?.settings_json ?? "{}")?.hub?.transport === "daemon";
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false;
|
|
283
|
+
} // malformed config ⇒ dormant (fail-closed: a write surface never opens on bad config)
|
|
284
|
+
}
|
|
285
|
+
// The project's mode (live|dry-run), read fresh per request so an operator flip takes effect without a
|
|
286
|
+
// restart. Honoring it server-side (design Decision #4) gates the op-API WRITE ops under dry-run — a
|
|
287
|
+
// defense-in-depth atop the agent-side mode authority (§12/§18: the hub row is advisory). A malformed /
|
|
288
|
+
// missing value reads as "live" (fail-OPEN to the working default — never silently wedge a live write path).
|
|
289
|
+
function projectMode(db, projectId) {
|
|
290
|
+
try {
|
|
291
|
+
const row = db.prepare("SELECT mode FROM projects WHERE id=?").get(projectId);
|
|
292
|
+
return row?.mode ?? "live";
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return "live";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Read the op-API's JSON args via the shared bounded reader (readBodyBytes). An empty body ⇒ {} (a no-arg op
|
|
299
|
+
// like list_issues). A non-object JSON value (array/number/null) ⇒ {} — the ops read named fields, so a
|
|
300
|
+
// non-object is "no args", never thrown; only un-parseable JSON rejects (→ the caller's 400).
|
|
301
|
+
function parseJsonBody(req) {
|
|
302
|
+
return readBodyBytes(req).then((b) => {
|
|
303
|
+
const raw = b.toString("utf8").trim();
|
|
304
|
+
if (!raw)
|
|
305
|
+
return {};
|
|
306
|
+
let v;
|
|
307
|
+
try {
|
|
308
|
+
v = JSON.parse(raw);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new Error("invalid JSON body");
|
|
312
|
+
}
|
|
313
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// Handle POST /api/op/<op>. Identity rides X-Devloop-Actor (cooperative single-host attribution, §18 — NOT
|
|
317
|
+
// anti-spoof; the real human boundary stays the operator-publish gate). Pipeline order is load-bearing: the
|
|
318
|
+
// CSRF/Host wall runs BEFORE the actor/body are read, and a write is mode-gated before any mutation.
|
|
319
|
+
async function handleAgentOp(op, req, res, db, writeDb, projectId, projectKey) {
|
|
320
|
+
if (!isAgentOp(op))
|
|
321
|
+
return json(res, 404, { error: `unknown op '${op}'` });
|
|
322
|
+
// (1) CSRF / DNS-rebinding wall FIRST — uniform over every op. A non-browser agent client (the shim, curl,
|
|
323
|
+
// tests) sends no Origin ⇒ allowed; a browser cross-origin / foreign-Host POST is refused before anything.
|
|
324
|
+
if (!writeOriginOk(req))
|
|
325
|
+
return json(res, 403, { error: "op refused: cross-origin or non-localhost Host (CSRF / DNS-rebinding guard)" });
|
|
326
|
+
// (2) actor from the header, validated against `actors` (the G1 phantom-actor guard — every write/comment
|
|
327
|
+
// must be attributable, exactly like the stdio server's DEVLOOP_ACTOR start guard).
|
|
328
|
+
const actor = req.headers["x-devloop-actor"]?.trim();
|
|
329
|
+
if (!actor)
|
|
330
|
+
return json(res, 400, { error: "missing X-Devloop-Actor header (the caller's actor)" });
|
|
331
|
+
if (!actorExists(writeDb, actor))
|
|
332
|
+
return json(res, 400, { error: `unknown actor '${actor}'` });
|
|
333
|
+
const isWrite = AGENT_WRITE_OPS.has(op);
|
|
334
|
+
// (3) honor `mode` server-side (design Decision #4): a WRITE op in a dry-run project is refused (defense-in-
|
|
335
|
+
// depth atop agent-side mode authority). Live ⇒ byte-identical to the stdio path (reads are never gated).
|
|
336
|
+
if (isWrite && projectMode(db, projectId) === "dry-run")
|
|
337
|
+
return json(res, 403, { error: `project '${projectKey}' is in dry-run mode — the op-API refuses writes (mode honored server-side; §12/§18)` });
|
|
338
|
+
// (4) parse the JSON args (bounded). A rejected body may have destroyed the socket — guard the response.
|
|
339
|
+
let args;
|
|
340
|
+
try {
|
|
341
|
+
args = await parseJsonBody(req);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
if (!res.headersSent && !res.destroyed)
|
|
345
|
+
json(res, 400, { error: e.message });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// (5) dispatch — writes through writeDb (atomic txn + attributed event in ticketwrite), reads through the
|
|
349
|
+
// query_only db. agentOp mirrors server.ts; an op-level validation/not-found maps to its HTTP status.
|
|
350
|
+
// AWAIT: agentOp returns OpResult|Promise<OpResult> — the DL-67 channel.send/poll ops are async (network/
|
|
351
|
+
// dryrun build); the sync ops resolve immediately, so awaiting them is a no-op (back-compat).
|
|
352
|
+
const r = await agentOp(op, isWrite ? writeDb : db, projectId, projectKey, actor, args);
|
|
353
|
+
return json(res, r.status, r.body);
|
|
354
|
+
}
|
|
355
|
+
// Build the HTTP server over an already-opened, project-resolved db. Exported so tests (and a later
|
|
356
|
+
// in-process embed) can start it without the CLI bootstrap below. GET routes issue ONLY SELECTs; the
|
|
357
|
+
// optional DL-3 /roadmap/* POST routes write the roadmap doc through the separate `writeDb` connection.
|
|
358
|
+
export function createDaemon({ db, projectId, projectKey, writeDb, actor, roadmapRepoFileStrategy }) {
|
|
359
|
+
const canWrite = !!writeDb && !!actor;
|
|
360
|
+
return createServer(async (req, res) => {
|
|
361
|
+
const method = req.method ?? "GET";
|
|
362
|
+
let url;
|
|
363
|
+
try {
|
|
364
|
+
url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return json(res, 400, { error: "bad request url" });
|
|
368
|
+
}
|
|
369
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
370
|
+
const seg = path.split("/").filter(Boolean); // [] for "/"
|
|
371
|
+
try {
|
|
372
|
+
// ── DL-3 write surface: the ONLY non-GET routes. They hard-target the kind:"roadmap" doc through
|
|
373
|
+
// docstore (DB-doc-only — no filesystem path ⇒ §17 firewall). Present ONLY when a write
|
|
374
|
+
// connection + actor were supplied; otherwise the daemon stays GET-only (DL-1/DL-2 behavior).
|
|
375
|
+
if (method === "POST" && canWrite && (path === "/roadmap/save" || path === "/roadmap/publish")) {
|
|
376
|
+
// DL-19: refuse a cross-origin (CSRF) or foreign-Host (DNS-rebinding) write BEFORE any docSave/
|
|
377
|
+
// docPublish — the guard runs ahead of handleRoadmapWrite, so a refused request never mutates.
|
|
378
|
+
if (!writeOriginOk(req))
|
|
379
|
+
return json(res, 403, { error: "write refused: cross-origin or non-localhost Host (CSRF / DNS-rebinding guard)" });
|
|
380
|
+
await handleRoadmapWrite(path === "/roadmap/save" ? "save" : "publish", req, res, db, writeDb, projectId, projectKey, actor, roadmapRepoFileStrategy);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// DL-29: opt-in human ticket-write routes — present ONLY when canWrite AND humanWrite.enabled. When
|
|
384
|
+
// disabled (or absent), these POSTs are NOT matched and fall through to the 405 below (byte-identical
|
|
385
|
+
// read-only). Origin/Host guard runs BEFORE any write, exactly like /roadmap/*.
|
|
386
|
+
if (method === "POST" && canWrite && humanWriteEnabled(db, projectId) && isTicketWriteRoute(seg)) {
|
|
387
|
+
if (!writeOriginOk(req))
|
|
388
|
+
return json(res, 403, { error: "write refused: cross-origin or non-localhost Host (CSRF / DNS-rebinding guard)" });
|
|
389
|
+
await handleTicketWrite(seg, req, res, db, writeDb, projectId, projectKey, actor);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// DL-43: opt-in agent op-API — POST /api/op/<op>, active ONLY when canWrite AND the project opted in
|
|
393
|
+
// (settings_json.hub.transport==="daemon", read FRESH). The WHOLE /api/op/* path is owned here so a
|
|
394
|
+
// DORMANT mount (flag off, or a read-only daemon, or a non-POST/garbled op path) 404s — an absent
|
|
395
|
+
// mount, not the generic non-GET 405 — leaving every existing surface byte-for-byte unchanged.
|
|
396
|
+
if (seg[0] === "api" && seg[1] === "op") {
|
|
397
|
+
if (method === "POST" && canWrite && seg.length === 3 && agentApiEnabled(db, projectId)) {
|
|
398
|
+
await handleAgentOp(seg[2], req, res, db, writeDb, projectId, projectKey);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
return json(res, 404, { error: `not found: ${path}` });
|
|
402
|
+
}
|
|
403
|
+
// READ-ONLY for everything else: any other non-GET is refused — the read surface never mutates (DL-1 AC).
|
|
404
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
405
|
+
return json(res, 405, { error: "read-only daemon: only GET is allowed" });
|
|
406
|
+
}
|
|
407
|
+
// GET / — the web UI board (DL-2): server-rendered HTML, read-only, columns by state. DL-20:
|
|
408
|
+
// optional server-side filter/search via the query string (state/type/label/assignee + free-text q).
|
|
409
|
+
if (path === "/") {
|
|
410
|
+
const sp = url.searchParams;
|
|
411
|
+
const filters = { state: sp.get("state") ?? undefined, type: sp.get("type") ?? undefined, label: sp.get("label") ?? undefined, assignee: sp.get("assignee") ?? undefined, q: sp.get("q") ?? undefined };
|
|
412
|
+
// DL-31: validate ?group to the single known view ("assignee" → swimlanes); anything else ⇒ default board.
|
|
413
|
+
const group = sp.get("group") === "assignee" ? "assignee" : undefined;
|
|
414
|
+
return htmlOut(res, 200, page(`${projectKey} · board`, projectKey, boardPage(db, projectId, projectKey, filters, canWrite && humanWriteEnabled(db, projectId), group)));
|
|
415
|
+
}
|
|
416
|
+
// GET /roadmap — the roadmap doc view + edit form (+ operator-only publish) (DL-3).
|
|
417
|
+
if (path === "/roadmap") {
|
|
418
|
+
return htmlOut(res, 200, page(`roadmap · ${projectKey}`, projectKey, roadmapPage(db, projectId, { writable: canWrite, canPublish: canWrite && actor === "operator", roadmapRepoFileStrategy })));
|
|
419
|
+
}
|
|
420
|
+
// GET /activity — read-only activity & throughput over the events ledger (DL-17). Pure SELECTs
|
|
421
|
+
// through the query_only db; Date.now() injected so activityPage stays pure/testable.
|
|
422
|
+
if (path === "/activity") {
|
|
423
|
+
return htmlOut(res, 200, page(`activity · ${projectKey}`, projectKey, activityPage(db, projectId, projectKey, Date.now())));
|
|
424
|
+
}
|
|
425
|
+
// GET /reports — the agent reports index (DL-10, read-only filesystem view; empty state if absent).
|
|
426
|
+
if (path === "/reports") {
|
|
427
|
+
return htmlOut(res, 200, page(`reports · ${projectKey}`, projectKey, reportsIndexPage(reportsRoot(projectKey))));
|
|
428
|
+
}
|
|
429
|
+
// GET /reports/<agent>/<level>/<date> — one report, read-only (path-validated → 400 traversal, 404 absent).
|
|
430
|
+
if (seg[0] === "reports" && seg.length === 4) {
|
|
431
|
+
const agent = decodeSeg(seg[1]), level = decodeSeg(seg[2]), date = decodeSeg(seg[3]);
|
|
432
|
+
if (agent === null || level === null || date === null)
|
|
433
|
+
return json(res, 400, { error: "malformed percent-escape in path" });
|
|
434
|
+
const r = reportPage(reportsRoot(projectKey), agent, level, date);
|
|
435
|
+
if (r === "badpath")
|
|
436
|
+
return json(res, 400, { error: "invalid report path" });
|
|
437
|
+
if (r === null)
|
|
438
|
+
return htmlOut(res, 404, page("Not found", projectKey, `<a class="back" href="/reports">← reports</a><p class="empty">No report ${esc(agent)}/${esc(level)}/${esc(date)}.</p>`));
|
|
439
|
+
return htmlOut(res, 200, page(`${date} · ${agent} · ${projectKey}`, projectKey, r.html));
|
|
440
|
+
}
|
|
441
|
+
// GET /ticket/:id — the web UI detail view (DL-2): full description + comments.
|
|
442
|
+
if (seg[0] === "ticket" && seg.length === 2) {
|
|
443
|
+
const id = decodeSeg(seg[1]);
|
|
444
|
+
if (id === null)
|
|
445
|
+
return json(res, 400, { error: "malformed percent-escape in path" });
|
|
446
|
+
const inner = ticketPage(db, projectId, id, canWrite && humanWriteEnabled(db, projectId));
|
|
447
|
+
if (!inner)
|
|
448
|
+
return htmlOut(res, 404, page("Not found", projectKey, `<a class="back" href="/">← board</a><p class="empty">No ticket ${esc(id)} in ${esc(projectKey)}.</p>`));
|
|
449
|
+
return htmlOut(res, 200, page(`${id} · ${projectKey}`, projectKey, inner));
|
|
450
|
+
}
|
|
451
|
+
// GET /api — JSON API index (was GET / before DL-2 added the web UI at the root).
|
|
452
|
+
if (path === "/api") {
|
|
453
|
+
return json(res, 200, {
|
|
454
|
+
name: "dev-loop-hub daemon", project: projectKey, readOnly: true,
|
|
455
|
+
ui: "/", endpoints: ["/api/health", "/api/tickets", "/api/tickets/:id", "/api/docs", "/api/docs/:kind"],
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// GET /api/health — a REAL DB-writable liveness check (DL-41), not a static 200: a bound-but-wedged
|
|
459
|
+
// daemon (SoR unreadable/unwritable) returns 503 ok:false so the lifecycle `up`/`status` recover it.
|
|
460
|
+
if (path === "/api/health") {
|
|
461
|
+
const h = healthLiveness(db, writeDb);
|
|
462
|
+
return json(res, h.ok ? 200 : 503, h.ok ? { ok: true, project: projectKey } : { ok: false, project: projectKey, error: h.error });
|
|
463
|
+
}
|
|
464
|
+
// GET /api/tickets — board, project-scoped (§2), filter by state/type/label/assignee (+ optional limit).
|
|
465
|
+
if (path === "/api/tickets") {
|
|
466
|
+
let out = db.prepare("SELECT * FROM tickets WHERE project_id=? ORDER BY updated_at DESC").all(projectId).map(toTicket);
|
|
467
|
+
const state = url.searchParams.get("state");
|
|
468
|
+
if (state)
|
|
469
|
+
out = out.filter((t) => t.state === state);
|
|
470
|
+
const type = url.searchParams.get("type");
|
|
471
|
+
if (type)
|
|
472
|
+
out = out.filter((t) => t.type === type);
|
|
473
|
+
const label = url.searchParams.get("label");
|
|
474
|
+
if (label)
|
|
475
|
+
out = out.filter((t) => t.labels.includes(label));
|
|
476
|
+
// DL-31: honor ?assignee (was silently ignored → board/API parity; the GET / board already filters it).
|
|
477
|
+
const assignee = url.searchParams.get("assignee");
|
|
478
|
+
if (assignee)
|
|
479
|
+
out = out.filter((t) => t.assignee === assignee);
|
|
480
|
+
const limit = Number(url.searchParams.get("limit"));
|
|
481
|
+
if (Number.isFinite(limit) && limit > 0)
|
|
482
|
+
out = out.slice(0, limit);
|
|
483
|
+
return json(res, 200, out);
|
|
484
|
+
}
|
|
485
|
+
// GET /api/tickets/:id — one ticket with its comments.
|
|
486
|
+
if (seg[0] === "api" && seg[1] === "tickets" && seg.length === 3) {
|
|
487
|
+
const id = decodeSeg(seg[2]);
|
|
488
|
+
if (id === null)
|
|
489
|
+
return json(res, 400, { error: "malformed percent-escape in path" });
|
|
490
|
+
const r = db.prepare("SELECT * FROM tickets WHERE id=? AND project_id=?").get(id, projectId);
|
|
491
|
+
if (!r)
|
|
492
|
+
return json(res, 404, { error: `no such ticket ${id} in ${projectKey}` });
|
|
493
|
+
const comments = db.prepare("SELECT id,author,body,created_at FROM comments WHERE ticket_id=? ORDER BY created_at").all(id);
|
|
494
|
+
return json(res, 200, { ...toTicket(r), comments });
|
|
495
|
+
}
|
|
496
|
+
// GET /api/docs — list this project's documents (no bodies).
|
|
497
|
+
if (path === "/api/docs") {
|
|
498
|
+
return json(res, 200, db.prepare("SELECT kind,slug,title,status,current_version,updated_at FROM documents WHERE project_id=? ORDER BY kind").all(projectId));
|
|
499
|
+
}
|
|
500
|
+
// GET /api/docs/:kind — the current roadmap/strategy doc (published version, else latest draft).
|
|
501
|
+
if (seg[0] === "api" && seg[1] === "docs" && seg.length === 3) {
|
|
502
|
+
const key = decodeSeg(seg[2]);
|
|
503
|
+
if (key === null)
|
|
504
|
+
return json(res, 400, { error: "malformed percent-escape in path" });
|
|
505
|
+
const d = (db.prepare("SELECT * FROM documents WHERE project_id=? AND kind=?").get(projectId, key)
|
|
506
|
+
?? db.prepare("SELECT * FROM documents WHERE project_id=? AND slug=?").get(projectId, key));
|
|
507
|
+
if (!d)
|
|
508
|
+
return json(res, 404, { error: `no document '${key}' in ${projectKey}` });
|
|
509
|
+
const ver = d.current_version > 0
|
|
510
|
+
? d.current_version
|
|
511
|
+
: (db.prepare("SELECT max(version) v FROM document_versions WHERE doc_id=?").get(d.id).v ?? 0);
|
|
512
|
+
if (ver === 0)
|
|
513
|
+
return json(res, 200, { kind: d.kind, slug: d.slug, title: d.title, status: d.status, version: 0, body: "", unpublished: true, empty: true });
|
|
514
|
+
const v = db.prepare("SELECT version,body,status,summary,base_version,author,created_at FROM document_versions WHERE doc_id=? AND version=?").get(d.id, ver);
|
|
515
|
+
return json(res, 200, { kind: d.kind, slug: d.slug, title: d.title, status: d.status, current_version: d.current_version, ...v, ...(d.current_version === 0 ? { unpublished: true } : {}) });
|
|
516
|
+
}
|
|
517
|
+
// DL-36: an unknown /api/* path is a machine client → JSON 404 (unchanged). An unknown NON-API path is
|
|
518
|
+
// a page navigation (a typo'd URL) → serve the friendly HTML 404, like the ghost-ticket route, instead
|
|
519
|
+
// of a raw-JSON dead-end. Read-only; query_only preserved.
|
|
520
|
+
if (seg[0] === "api")
|
|
521
|
+
return json(res, 404, { error: `not found: ${path}` });
|
|
522
|
+
return htmlOut(res, 404, page("Not found", projectKey, `<a class="back" href="/">← board</a><p class="empty">No page <code>${esc(path)}</code> in ${esc(projectKey)}.</p>`));
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
return json(res, 500, { error: e.message });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// ─── DL-26: Human-Blocked periodic notifier (service backend, option b) ───────
|
|
530
|
+
// On `service` the daemon owns the ENTIRE Human-Blocked notification lifecycle: the FIRST ping the
|
|
531
|
+
// moment a ticket is detected in the state (no human_blocked.notified marker yet ⇒ due now) AND the
|
|
532
|
+
// periodic reminders thereafter (now − last marker ≥ cadence). Due-ness is computed STATELESS from
|
|
533
|
+
// the events ledger, so a daemon restart never double-sends and needs no counter. This is the daemon's
|
|
534
|
+
// ONE write to the SoR (the human_blocked.notified event), done via the writable `writeDb`, NEVER the
|
|
535
|
+
// query_only read connection. Absent a channel OR humanBlockedReminderHours≤0 ⇒ no timer (true no-op).
|
|
536
|
+
export async function blockedNotifyTick(opts) {
|
|
537
|
+
const { writeDb, projectId, projectKey, baseUrl, cadenceMs, nowMs } = opts;
|
|
538
|
+
// DL-59: resolve ONE send target. The DB `channels` row (getEnabledChannel) takes PRECEDENCE so a project
|
|
539
|
+
// with a registered bot/webhook channel is byte-for-byte unchanged; the §9 `notify` webhook (projects.json,
|
|
540
|
+
// resolveNotifyWebhook) is the FALLBACK that closes the L2 leak (a notify-only project previously got NO
|
|
541
|
+
// alert — a true no-op here). Choosing exactly one target means a project configured with BOTH can never
|
|
542
|
+
// double-send the same park (the AC's no-double-send), at no extra marker/state cost.
|
|
543
|
+
const dbCh = getEnabledChannel(writeDb, projectId);
|
|
544
|
+
const nt = dbCh ? null : resolveNotifyWebhook(opts.notify);
|
|
545
|
+
if (!dbCh && !nt)
|
|
546
|
+
return 0; // no DB channel AND no §9 notify webhook ⇒ nothing to do (true no-op)
|
|
547
|
+
const target = dbCh
|
|
548
|
+
? { provider: dbCh.provider, creds: resolveCreds(dbCh), channelRef: dbCh.channel_ref, transport: dbCh.transport ?? "bot", label: `${dbCh.provider}/${dbCh.transport ?? "bot"}` }
|
|
549
|
+
: { provider: nt.provider, creds: nt.creds, channelRef: "", transport: "webhook", label: `${nt.provider}/webhook (§9 notify)` };
|
|
550
|
+
const rows = writeDb.prepare("SELECT id,title FROM tickets WHERE project_id=? AND state='Human-Blocked' ORDER BY updated_at").all(projectId);
|
|
551
|
+
// DL-33: PER-TICK loop-safety cap — `sent` resets every invocation, so a long-running daemon never
|
|
552
|
+
// goes permanently silent (a per-PROCESS counter would become a lifetime ceiling on this persistent
|
|
553
|
+
// process, unlike the MCP server's short-lived per-fire process).
|
|
554
|
+
let sent = 0;
|
|
555
|
+
for (const t of rows) {
|
|
556
|
+
if (sent >= CHANNEL_SEND_CAP)
|
|
557
|
+
break; // bound sends THIS tick only (resets next tick)
|
|
558
|
+
// Stateless due-ness: the last REAL human_blocked.notified event. None ⇒ first ping (due now).
|
|
559
|
+
const last = writeDb.prepare("SELECT MAX(created_at) m FROM events WHERE ticket_id=? AND kind='human_blocked.notified'").get(t.id);
|
|
560
|
+
const due = !last.m || (nowMs - Date.parse(last.m)) >= cadenceMs;
|
|
561
|
+
if (!due)
|
|
562
|
+
continue;
|
|
563
|
+
// §16 allow-list line: id + truncated title + localhost url ONLY. No description/labels/PII/secrets.
|
|
564
|
+
const line = `[${projectKey}] human-blocked: ${t.id} ${cleanLine(t.title, 80)} · ${baseUrl}/ticket/${t.id}`;
|
|
565
|
+
try {
|
|
566
|
+
if (CHANNEL_DRYRUN) {
|
|
567
|
+
// DL-34: dry-run is WRITE-FREE (the DL-11 invariant) — preview only, NO marker / NO ledger
|
|
568
|
+
// event — so a later LIVE tick on the same DB still fires the first real ping, and the
|
|
569
|
+
// events ledger never gains a phantom "notified" that never sent. DL-52: the preview names the
|
|
570
|
+
// channel type (provider/transport) + the §16-safe message line — never the webhook URL/secret.
|
|
571
|
+
console.error(`[daemon] [dry-run] would notify human-blocked ${t.id} via ${target.label}: ${line}`);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
// DL-52/DL-59: pass the resolved target's transport — a 'webhook' target (a DB webhook channel OR the
|
|
575
|
+
// §9 notify webhook) pings the incoming-webhook URL (no bot app); a 'bot' DB channel ⇒ the provider-API
|
|
576
|
+
// send, unchanged. blockedNotifyTick's OWN logic (due-ness, the DL-33 per-tick cap, the marker) is
|
|
577
|
+
// untouched — it just threads the chosen target through (one send + one marker per due ticket).
|
|
578
|
+
await sendVia(target.provider, target.creds, target.channelRef, { kind: "notify", lines: [line] }, opts.fetchImpl ?? fetch, target.transport);
|
|
579
|
+
logEvent(writeDb, { project_id: projectId, ticket_id: t.id, actor: "daemon", kind: "human_blocked.notified", data: { provider: target.provider } }); // marker ONLY on a real send
|
|
580
|
+
}
|
|
581
|
+
sent++;
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
// id-only log, NO marker written ⇒ retried next tick (never echo the secret/body)
|
|
585
|
+
console.error(`[daemon] human-blocked notify failed for ${t.id}: ${scrubErr(e.message)}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return sent;
|
|
589
|
+
}
|
|
590
|
+
export function startBlockedNotifier(opts) {
|
|
591
|
+
if (!(opts.cadenceHours > 0))
|
|
592
|
+
return null; // disabled
|
|
593
|
+
// DL-59: start if EITHER a registered DB channel OR the §9 `notify` webhook is configured — a notify-only
|
|
594
|
+
// project must no longer be a no-op. `notify` flows through `...opts` into each blockedNotifyTick run.
|
|
595
|
+
if (!getEnabledChannel(opts.writeDb, opts.projectId) && !resolveNotifyWebhook(opts.notify))
|
|
596
|
+
return null; // neither ⇒ true no-op
|
|
597
|
+
const cadenceMs = opts.cadenceHours * 3_600_000;
|
|
598
|
+
const tickMs = opts.tickMs ?? (Number(process.env.DEVLOOP_BLOCKED_TICK_MS) || 60_000);
|
|
599
|
+
const run = () => { void blockedNotifyTick({ ...opts, cadenceMs, nowMs: Date.now() }); };
|
|
600
|
+
const timer = setInterval(run, tickMs);
|
|
601
|
+
timer.unref?.(); // never keep the process alive solely for the notifier
|
|
602
|
+
run(); // immediate first tick — a fresh park is announced without waiting a full interval
|
|
603
|
+
return timer;
|
|
604
|
+
}
|
|
605
|
+
// ─── DL-76: loop circuit-breaker — daemon no-progress / runaway detector ──────────────────────────
|
|
606
|
+
// The Ralph-Wiggum guard at the LOOP level: a stuck loop that keeps firing (and billing) but produces no
|
|
607
|
+
// ACCEPTED change should page the operator ONCE — not bill silently. "Accepted change" = a ticket reaching
|
|
608
|
+
// Done (the §3 owner-verify gate passed), the exact throughput signal the DL-17 activityPage already counts.
|
|
609
|
+
// A sibling of blockedNotifyTick: SAME channel/notify resolution (DL-26/DL-59), SAME dry-run-is-write-free
|
|
610
|
+
// invariant (DL-34), SAME id-only failure log (§16) — only the DUE condition differs.
|
|
611
|
+
//
|
|
612
|
+
// THRESHOLD = a ROLLING WINDOW of H hours (settings_json.noProgressWindowHours), NOT "N consecutive fires":
|
|
613
|
+
// the daemon observes TIME + the events ledger, never agent fires directly, and a window is STATELESS, so a
|
|
614
|
+
// daemon restart never mis-counts (the events table is the durable SoR — no in-memory counter to lose). DUE =
|
|
615
|
+
// a STALL: zero issue.transition→Done events in the trailing window. De-duped like the Human-Blocked reminder
|
|
616
|
+
// via a project-wide `no_progress.notified` marker — at most ONE alert per stall EPISODE: a fresh alert fires
|
|
617
|
+
// only after accepted change RESUMED (a Done logged AFTER the last marker) and then stalled again. A cold start
|
|
618
|
+
// (a loop younger than the window, no prior history) is NOT a stall — guarded so the detector never cries wolf
|
|
619
|
+
// on boot. Absent a channel AND a §9 notify ⇒ true no-op (DL-59). Returns 1 if it alerted this tick, else 0.
|
|
620
|
+
export async function noProgressNotifyTick(opts) {
|
|
621
|
+
const { writeDb, projectId, projectKey, baseUrl, windowMs, nowMs } = opts;
|
|
622
|
+
// Resolve ONE send target FIRST (cheap) — identical precedence to blockedNotifyTick (DL-59): a DB `channels`
|
|
623
|
+
// row wins; else the §9 `notify` webhook; else true no-op (so a no-channel project does zero ledger work).
|
|
624
|
+
const dbCh = getEnabledChannel(writeDb, projectId);
|
|
625
|
+
const nt = dbCh ? null : resolveNotifyWebhook(opts.notify);
|
|
626
|
+
if (!dbCh && !nt)
|
|
627
|
+
return 0;
|
|
628
|
+
const target = dbCh
|
|
629
|
+
? { provider: dbCh.provider, creds: resolveCreds(dbCh), channelRef: dbCh.channel_ref, transport: dbCh.transport ?? "bot", label: `${dbCh.provider}/${dbCh.transport ?? "bot"}` }
|
|
630
|
+
: { provider: nt.provider, creds: nt.creds, channelRef: "", transport: "webhook", label: `${nt.provider}/webhook (§9 notify)` };
|
|
631
|
+
// "net accepted change" over the rolling window = COUNT of issue.transition events whose `to` is Done within
|
|
632
|
+
// [now − windowMs, now]. SAME done-count logic as activityPage (the `to` lives in JSON `data`, so the filter
|
|
633
|
+
// is in-process; a malformed row is skipped, never throws — activityPage AC5).
|
|
634
|
+
const sinceIso = new Date(nowMs - windowMs).toISOString();
|
|
635
|
+
const windowTrans = writeDb.prepare("SELECT data FROM events WHERE project_id=? AND kind='issue.transition' AND created_at>=? ORDER BY id").all(projectId, sinceIso);
|
|
636
|
+
const accepted = windowTrans.reduce((n, e) => n + (eventData(e.data).to === "Done" ? 1 : 0), 0);
|
|
637
|
+
if (accepted > 0)
|
|
638
|
+
return 0; // progress within the window ⇒ healthy, nothing to do
|
|
639
|
+
// Cold-start guard: a loop YOUNGER than the window has no history to judge — "0 Done" is just "not warmed up
|
|
640
|
+
// yet", not a stall. Require ≥1 event BEFORE the window before we ever alert (cheap; LIMIT 1 short-circuits).
|
|
641
|
+
const hasHistory = !!writeDb.prepare("SELECT 1 FROM events WHERE project_id=? AND created_at<? LIMIT 1").get(projectId, sinceIso);
|
|
642
|
+
if (!hasHistory)
|
|
643
|
+
return 0;
|
|
644
|
+
// STALLED. De-dup like the Human-Blocked reminder: one alert per stall EPISODE. Re-alert only if accepted
|
|
645
|
+
// change RESUMED since the last alert — a Done transition logged strictly AFTER the last marker (which, since
|
|
646
|
+
// we are stalled NOW, must itself predate the window ⇒ it resumed-then-stalled-again). Stateless from the
|
|
647
|
+
// ledger ⇒ a daemon restart never double-sends and needs no counter.
|
|
648
|
+
const lastNotified = writeDb.prepare("SELECT MAX(created_at) m FROM events WHERE project_id=? AND kind='no_progress.notified'").get(projectId).m;
|
|
649
|
+
if (lastNotified) {
|
|
650
|
+
const sinceAlert = writeDb.prepare("SELECT data FROM events WHERE project_id=? AND kind='issue.transition' AND created_at>? ORDER BY id").all(projectId, lastNotified);
|
|
651
|
+
const resumed = sinceAlert.some((e) => eventData(e.data).to === "Done");
|
|
652
|
+
if (!resumed)
|
|
653
|
+
return 0; // still the same stall episode (no Done since the alert) ⇒ stay silent
|
|
654
|
+
}
|
|
655
|
+
// §16 closed-allow-list one-liner: projectKey + the window + the metric + the localhost /activity link ONLY.
|
|
656
|
+
// No ticket text / PII / secret; cleanLine bounds length + strips control chars (defense in depth).
|
|
657
|
+
const windowH = +(windowMs / 3_600_000).toFixed(2);
|
|
658
|
+
const line = cleanLine(`[${projectKey}] no-progress: 0 accepted change (Done) in the last ${windowH}h — loop may be stuck · ${baseUrl}/activity`, 200);
|
|
659
|
+
try {
|
|
660
|
+
if (CHANNEL_DRYRUN) {
|
|
661
|
+
// DL-34: dry-run is WRITE-FREE (the DL-11 invariant) — preview only, NO marker / NO send — so a later
|
|
662
|
+
// LIVE tick still fires the first real ping and the ledger never gains a phantom "notified".
|
|
663
|
+
console.error(`[daemon] [dry-run] would notify no-progress via ${target.label}: ${line}`);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
await sendVia(target.provider, target.creds, target.channelRef, { kind: "notify", lines: [line] }, opts.fetchImpl ?? fetch, target.transport);
|
|
667
|
+
logEvent(writeDb, { project_id: projectId, ticket_id: null, actor: "daemon", kind: "no_progress.notified", data: { windowMs } }); // marker ONLY on a real send
|
|
668
|
+
}
|
|
669
|
+
return 1;
|
|
670
|
+
}
|
|
671
|
+
catch (e) {
|
|
672
|
+
// id-less log, NO marker ⇒ retried next tick (never echo the secret/body)
|
|
673
|
+
console.error(`[daemon] no-progress notify failed: ${scrubErr(e.message)}`);
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
export function startNoProgressNotifier(opts) {
|
|
678
|
+
if (!(opts.windowHours > 0))
|
|
679
|
+
return null; // disabled (absent / ≤0 ⇒ true no-op)
|
|
680
|
+
// Start only if a send target exists (DL-59): a registered DB channel OR the §9 notify webhook — else no-op.
|
|
681
|
+
if (!getEnabledChannel(opts.writeDb, opts.projectId) && !resolveNotifyWebhook(opts.notify))
|
|
682
|
+
return null;
|
|
683
|
+
const windowMs = opts.windowHours * 3_600_000;
|
|
684
|
+
// Re-check ≈ hourly by default (the stall window is measured in hours; a tighter poll just re-scans the
|
|
685
|
+
// ledger for nothing, and the marker de-dup makes any extra tick harmless). Env-overridable for tests.
|
|
686
|
+
const tickMs = opts.tickMs ?? (Number(process.env.DEVLOOP_NOPROGRESS_TICK_MS) || 3_600_000);
|
|
687
|
+
const run = () => { void noProgressNotifyTick({ ...opts, windowMs, nowMs: Date.now() }); };
|
|
688
|
+
const timer = setInterval(run, tickMs);
|
|
689
|
+
timer.unref?.(); // never keep the process alive solely for this detector
|
|
690
|
+
run(); // immediate first tick — a stall already in progress at boot is caught without waiting
|
|
691
|
+
return timer;
|
|
692
|
+
}
|
|
693
|
+
// ─── P3b (design daemon-multicli §P3): bound the single-writer connection's WAL ───────────────────
|
|
694
|
+
// The daemon is the canonical single writer for an opted-in (`hub.transport:"daemon"`) project — every
|
|
695
|
+
// agent op-API write + human web-write flows through the ONE persistent `writeDb`. A long-lived writable
|
|
696
|
+
// handle is never auto-checkpointed by a closing connection, so the `-wal` file grows unbounded; a periodic
|
|
697
|
+
// `PRAGMA wal_checkpoint(TRUNCATE)` checkpoints the log into the main DB and truncates it back to zero.
|
|
698
|
+
//
|
|
699
|
+
// CRITICAL (Codex review 2026-06-27): node:sqlite is SYNCHRONOUS and the daemon is single-threaded, so a
|
|
700
|
+
// checkpoint runs ON the event loop. On the normal `writeDb` (busy_timeout=5000, db.ts) a TRUNCATE blocked
|
|
701
|
+
// by a concurrent reader would STALL the whole daemon up to 5s, then leave the WAL non-truncated. So the
|
|
702
|
+
// checkpoint runs on a DEDICATED maintenance connection with `busy_timeout=0`: under contention it returns
|
|
703
|
+
// BUSY *immediately* (a clean no-op retried next interval) and never blocks request handling; when the WAL
|
|
704
|
+
// is free it truncates to zero as intended. The direct-db stdio `server.ts` fallback is unaffected.
|
|
705
|
+
export function walCheckpointTick(ckDb) {
|
|
706
|
+
try {
|
|
707
|
+
ckDb.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
708
|
+
}
|
|
709
|
+
catch { /* BUSY / locked ⇒ immediate no-op, retried next interval */ }
|
|
710
|
+
}
|
|
711
|
+
export function startWalCheckpoint(dbPath, intervalMs = Number(process.env.DEVLOOP_WAL_CHECKPOINT_MS) || 300_000) {
|
|
712
|
+
const ckDb = openDb(dbPath);
|
|
713
|
+
try {
|
|
714
|
+
ckDb.exec("PRAGMA busy_timeout=0");
|
|
715
|
+
}
|
|
716
|
+
catch { /* if it can't be lowered, a BUSY still just throws → caught no-op */ }
|
|
717
|
+
const timer = setInterval(() => walCheckpointTick(ckDb), intervalMs);
|
|
718
|
+
timer.unref?.(); // never keep the process alive solely for the checkpoint
|
|
719
|
+
return timer;
|
|
720
|
+
}
|
|
721
|
+
// DL-41 dispatch — a lifecycle subcommand handles itself and exits; ANY other invocation (incl. the
|
|
722
|
+
// bare `npm run daemon`) falls through to today's foreground boot below, byte-for-byte unchanged.
|
|
723
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
724
|
+
&& LIFECYCLE_SUBS.includes(process.argv[2])) {
|
|
725
|
+
await daemonLifecycle(process.argv[2]); // calls process.exit — never returns
|
|
726
|
+
}
|
|
727
|
+
// ─── CLI entry: `npm run daemon` — open db, resolve project (same guard as the MCP server), listen ──
|
|
728
|
+
// Only runs when executed directly (not on import — the test imports createDaemon and starts it itself).
|
|
729
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
730
|
+
const DB_PATH = process.env.DEVLOOP_HUB_DB ?? `${homedir()}/.dev-loop/hub.db`;
|
|
731
|
+
const PROJECT_KEY = process.env.DEVLOOP_PROJECT ?? "demo";
|
|
732
|
+
const HOST = "127.0.0.1"; // §16 localhost-only; NEVER 0.0.0.0
|
|
733
|
+
const PORT = Number(process.env.DEVLOOP_DAEMON_PORT ?? 8787);
|
|
734
|
+
const db = openDb(DB_PATH);
|
|
735
|
+
db.exec("PRAGMA query_only=ON"); // structural read-only: this connection can never write the SoR
|
|
736
|
+
// No ensureActors/auto-create here: like the MCP server's G2 guard, refuse to serve a phantom board.
|
|
737
|
+
const projectId = findProject(db, PROJECT_KEY);
|
|
738
|
+
if (!projectId) {
|
|
739
|
+
console.error(`[daemon] unknown project '${PROJECT_KEY}'. Seed it first (e.g. start the hub, or \`node src/seed.ts ${PROJECT_KEY} "<name>" <PREFIX>\`). Refusing to serve a phantom board.`);
|
|
740
|
+
process.exit(1);
|
|
741
|
+
}
|
|
742
|
+
// DL-3: a SECOND, writable connection backs ONLY the /roadmap/* write routes — the read `db` above
|
|
743
|
+
// stays query_only, so the daemon's read surface remains structurally read-only. DEVLOOP_ACTOR (default
|
|
744
|
+
// operator, matching the MCP server) attributes writes and gates publish; refuse a phantom actor
|
|
745
|
+
// (G1-style) so a write can never land unattributable authorship.
|
|
746
|
+
const ACTOR = process.env.DEVLOOP_ACTOR ?? "operator";
|
|
747
|
+
const writeDb = openDb(DB_PATH);
|
|
748
|
+
if (!actorExists(writeDb, ACTOR)) {
|
|
749
|
+
console.error(`[daemon] DEVLOOP_ACTOR='${ACTOR}' is not a known actor — refusing to start the roadmap write surface with an unattributable identity. Seed actors via the hub first.`);
|
|
750
|
+
process.exit(1);
|
|
751
|
+
}
|
|
752
|
+
// DL-83: detect whether a repo-file strategyDoc (not the hub roadmap doc) is THIS project's north-star,
|
|
753
|
+
// from the daemon's OWN resolved config (projects.json) — never request input (§17). When it is, /roadmap
|
|
754
|
+
// shows a divergence banner naming that file. Same config-read precedent as the §9 `notify` resolve below.
|
|
755
|
+
let roadmapRepoFileStrategy;
|
|
756
|
+
try {
|
|
757
|
+
roadmapRepoFileStrategy = roadmapDivergenceDoc(loadProjectsConfig()?.projects?.[PROJECT_KEY]);
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
roadmapRepoFileStrategy = undefined;
|
|
761
|
+
}
|
|
762
|
+
const server = createDaemon({ db, projectId, projectKey: PROJECT_KEY, writeDb, actor: ACTOR, roadmapRepoFileStrategy });
|
|
763
|
+
// DL-26: read the per-project Human-Blocked reminder cadence (settings_json.humanBlockedReminderHours).
|
|
764
|
+
// DL-76: read the loop no-progress circuit-breaker window (settings_json.noProgressWindowHours) from the
|
|
765
|
+
// SAME parse — both are operator-set, hours, 0/absent ⇒ off (true no-op, no timer).
|
|
766
|
+
let cadenceHours = 0, noProgressWindowHours = 0;
|
|
767
|
+
try {
|
|
768
|
+
const row = writeDb.prepare("SELECT settings_json FROM projects WHERE id=?").get(projectId);
|
|
769
|
+
const settings = JSON.parse(row?.settings_json ?? "{}");
|
|
770
|
+
cadenceHours = Number(settings?.humanBlockedReminderHours) || 0;
|
|
771
|
+
noProgressWindowHours = Number(settings?.noProgressWindowHours) || 0;
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
cadenceHours = 0;
|
|
775
|
+
noProgressWindowHours = 0;
|
|
776
|
+
}
|
|
777
|
+
// DL-59: also resolve the §9 `notify` webhook (projects.json) so a project with ONLY a notify webhook (no
|
|
778
|
+
// registered bot/webhook channel) still receives Human-Blocked reminders — the daemon is the single emitter
|
|
779
|
+
// on `service`. §16: the block stays in config/env; the daemon reads it but never writes it to the DB.
|
|
780
|
+
let notify;
|
|
781
|
+
try {
|
|
782
|
+
notify = loadProjectsConfig()?.projects?.[PROJECT_KEY]?.notify;
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
notify = undefined;
|
|
786
|
+
}
|
|
787
|
+
server.listen(PORT, HOST, () => {
|
|
788
|
+
const addr = server.address();
|
|
789
|
+
const port = typeof addr === "object" && addr ? addr.port : PORT;
|
|
790
|
+
console.log(`[daemon] dev-loop-hub for '${PROJECT_KEY}' (actor=${ACTOR}${ACTOR === "operator" ? ", can publish" : ", drafts only"}) → http://${HOST}:${port}/ (reads read-only; /roadmap editable, localhost-only)`);
|
|
791
|
+
// Human-Blocked notifier (option b): owns first-ping + reminders on service. No channel / cadence≤0 ⇒ no-op.
|
|
792
|
+
const notifier = startBlockedNotifier({ writeDb, projectId, projectKey: PROJECT_KEY, baseUrl: `http://${HOST}:${port}`, cadenceHours, notify });
|
|
793
|
+
if (notifier)
|
|
794
|
+
console.log(`[daemon] Human-Blocked notifier active (every ${cadenceHours}h via the configured channel / §9 notify webhook)`);
|
|
795
|
+
// DL-76: loop no-progress / runaway circuit-breaker — alert ONCE when 0 accepted change (Done) lands in the
|
|
796
|
+
// rolling window. No channel/notify OR noProgressWindowHours≤0 ⇒ no-op (mirrors the Human-Blocked notifier).
|
|
797
|
+
const noProgress = startNoProgressNotifier({ writeDb, projectId, projectKey: PROJECT_KEY, baseUrl: `http://${HOST}:${port}`, windowHours: noProgressWindowHours, notify });
|
|
798
|
+
if (noProgress)
|
|
799
|
+
console.log(`[daemon] no-progress detector active (alert on 0 accepted change in ${noProgressWindowHours}h via the configured channel / §9 notify webhook)`);
|
|
800
|
+
// P3b: bound the single-writer connection's WAL via a DEDICATED busy_timeout=0 maintenance connection
|
|
801
|
+
// (never blocks the synchronous event loop under a concurrent reader — Codex review 2026-06-27).
|
|
802
|
+
startWalCheckpoint(DB_PATH);
|
|
803
|
+
console.log(`[daemon] WAL checkpoint active (periodic TRUNCATE on a dedicated non-blocking connection)`);
|
|
804
|
+
});
|
|
805
|
+
}
|