@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/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `dev-loop` — the unified CLI for the standalone hub (P4 packaging, design daemon-multicli §6).
|
|
3
|
+
// A THIN dispatcher over the existing zero-build entry points (each keeps its own arg-parsing). After
|
|
4
|
+
// `npm i -g dev-loop` this is on PATH, so a product `.mcp.json` can say {command:"dev-loop", args:["shim"]}
|
|
5
|
+
// or {args:["serve"]} instead of a fragile absolute `node .../hub/src/server.ts` path. Zero build: Node
|
|
6
|
+
// >=23.6 type-strips the .ts entries directly; the bin shebang runs THIS file the same way.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url)); // hub/src (dev) | dist (published)
|
|
12
|
+
// Resolve siblings by THIS file's own extension: `.ts` when run from source (zero-build dev), `.js` when
|
|
13
|
+
// run from the compiled, published package (node refuses to type-strip under node_modules — P4 ships JS).
|
|
14
|
+
const EXT = fileURLToPath(import.meta.url).endsWith(".js") ? ".js" : ".ts";
|
|
15
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
16
|
+
// subcommand → [entry base (no ext), ...prefix args]; the entry's OWN dispatcher consumes the rest unchanged.
|
|
17
|
+
const ROUTES = {
|
|
18
|
+
serve: ["server"], // the stdio MCP server (the agent transport; = the dev-loop-hub bin)
|
|
19
|
+
shim: ["shim"], // thin stdio MCP → loopback daemon op-API (DL-55)
|
|
20
|
+
daemon: ["server", "daemon"], // up | down | status | ensure (DL-41)
|
|
21
|
+
doctor: ["server", "doctor"],
|
|
22
|
+
seed: ["seed"],
|
|
23
|
+
"init-service": ["init-service"], // turnkey bootstrap (DL-60)
|
|
24
|
+
"mcp-merge": ["mcp-merge"], // merge into a product .mcp.json, never clobbers (DL-61)
|
|
25
|
+
"identity-check": ["server", "identity-check"], // the portability gate (PORTABILITY.md §4)
|
|
26
|
+
"resolve-project": ["server", "resolve-project"],
|
|
27
|
+
tickets: ["cli-tickets", "tickets"], // read-only terminal board list (DL-90)
|
|
28
|
+
ticket: ["cli-tickets", "ticket"], // read-only single-ticket detail + comments (DL-90)
|
|
29
|
+
// NB: `release-version` is deliberately NOT routed here — it mutates repo-only manifests
|
|
30
|
+
// (.claude-plugin/*) absent from the npm package, so it's a source-tree-only tool: run it in-repo
|
|
31
|
+
// via `node hub/src/release-version.ts <semver>` (Codex review 2026-06-27).
|
|
32
|
+
};
|
|
33
|
+
const version = () => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version ?? "0.0.0";
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "0.0.0";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const usage = () => {
|
|
42
|
+
console.log(`dev-loop ${version()} — standalone coordination hub (daemon + MCP + CLI)
|
|
43
|
+
|
|
44
|
+
Usage: dev-loop <command> [args]
|
|
45
|
+
|
|
46
|
+
serve run the stdio MCP server (the agent transport; same as the dev-loop-hub bin)
|
|
47
|
+
shim run the thin stdio MCP shim → the loopback daemon op-API (hub.transport:"daemon")
|
|
48
|
+
daemon up|down|status per-project daemon lifecycle — idempotent, auto-starts the localhost web UI
|
|
49
|
+
init-service <key> <name> <PREFIX> turnkey-bootstrap a service-backend project (seed → doctor → daemon up)
|
|
50
|
+
mcp-merge <args> merge dev-loop-hub into a product .mcp.json (never clobbers other servers)
|
|
51
|
+
seed <key> <name> [PREFIX] seed a project + actors + labels into the hub db
|
|
52
|
+
doctor health-check the hub system-of-record (DOCTOR_OK)
|
|
53
|
+
identity-check [--expect <actor>[/<project>]] verify this shell resolves the intended identity
|
|
54
|
+
tickets [--all] [--state S] [--type T] [--owner O] [--label L] [--q TEXT] read-only: list the resolved project's board (no daemon)
|
|
55
|
+
ticket <id> read-only: show one ticket — detail + comments
|
|
56
|
+
version | help
|
|
57
|
+
|
|
58
|
+
Identity rides DEVLOOP_ACTOR (per pane); project DEVLOOP_PROJECT (or the cwd); db DEVLOOP_HUB_DB.
|
|
59
|
+
Docs: https://github.com/dyzsasd/dev-loop (docs/RUNNING.md, docs/PORTABILITY.md, docs/HUB-ARCHITECTURE.md)`);
|
|
60
|
+
};
|
|
61
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
62
|
+
usage();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
66
|
+
console.log(version());
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
const route = ROUTES[cmd];
|
|
70
|
+
if (!route) {
|
|
71
|
+
console.error(`dev-loop: unknown command '${cmd}'\n`);
|
|
72
|
+
usage();
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
const [entryBase, ...prefix] = route;
|
|
76
|
+
const r = spawnSync(process.execPath, [join(here, entryBase + EXT), ...prefix, ...rest], { stdio: "inherit" });
|
|
77
|
+
process.exit(r.status ?? 1);
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// dev-loop hub daemon — the per-project process-lifecycle / supervisor subsystem (DL-74 extraction).
|
|
2
|
+
//
|
|
3
|
+
// DL-41: idempotent per-project daemon lifecycle (up | ensure | down | status). A thin, additive wrapper
|
|
4
|
+
// around the foreground boot that lives in `daemon.ts` (this module's sibling): `up` resolves the project
|
|
5
|
+
// (cwd or DEVLOOP_PROJECT), picks a stable per-project port, and spawns `daemon.ts` detached so the web UI
|
|
6
|
+
// survives the launching shell; `down`/`status` operate on a machine-local runfile. Designed so the DL-42
|
|
7
|
+
// SessionStart hook can call `up` unconditionally: a non-service / unresolved project is a clean no-op +
|
|
8
|
+
// exit 0, and a second `up` never double-starts. The foreground boot path (`npm run daemon`) is NOT touched
|
|
9
|
+
// by any of this — daemon.ts's own top-level dispatch routes a lifecycle subcommand here before its
|
|
10
|
+
// foreground `if` is reached. This module has NO top-level side effects (pure declarations), so importing
|
|
11
|
+
// it is always safe (server.ts delegates `dev-loop-hub daemon <sub>` to it; daemon.ts imports the dispatch).
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { createServer as netCreateServer } from "node:net";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, closeSync, renameSync } from "node:fs";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { openDb } from "./db.js";
|
|
19
|
+
import { findProject } from "./seed.js";
|
|
20
|
+
import { loadProjectsConfig, resolveProjectFromCwd } from "./resolve-project.js";
|
|
21
|
+
// The runfile lives next to the hub DB (machine-local, never committed — ~/.dev-loop by default), one
|
|
22
|
+
// file per project so distinct projects never clobber each other. DEVLOOP_RUN_DIR overrides for tests.
|
|
23
|
+
function lcDbPath() { return process.env.DEVLOOP_HUB_DB ?? `${homedir()}/.dev-loop/hub.db`; }
|
|
24
|
+
function lcRunDir() { return process.env.DEVLOOP_RUN_DIR ?? dirname(lcDbPath()); }
|
|
25
|
+
function lcRunfile(key) { return join(lcRunDir(), `daemon-${key}.json`); }
|
|
26
|
+
function lcReadRun(key) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(lcRunfile(key), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function lcWriteRun(info) {
|
|
35
|
+
mkdirSync(lcRunDir(), { recursive: true });
|
|
36
|
+
const f = lcRunfile(info.project), tmp = `${f}.${process.pid}.tmp`;
|
|
37
|
+
writeFileSync(tmp, JSON.stringify(info, null, 2));
|
|
38
|
+
renameSync(tmp, f); // atomic replace (§11 atomic-write discipline) — a partial write never yields invalid JSON
|
|
39
|
+
}
|
|
40
|
+
function lcRemoveRun(key) { try {
|
|
41
|
+
unlinkSync(lcRunfile(key));
|
|
42
|
+
}
|
|
43
|
+
catch { /* already gone */ } }
|
|
44
|
+
function lcLockfile(key) { return join(lcRunDir(), `daemon-${key}.lock`); }
|
|
45
|
+
function lcReadLockAt(path) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function lcReadLock(key) { return lcReadLockAt(lcLockfile(key)); }
|
|
54
|
+
// staleMs MUST exceed daemonUp's worst-case in-lock hold (lcStop ≤3s + lcWaitHealthy ≤8s + probing ≈ ≤15s)
|
|
55
|
+
// so a legitimately-busy live holder is never broken, AND stay well under totalMs so stale-recovery wins
|
|
56
|
+
// before a waiter's own acquire deadline fires (else a pid-reused stale lock is breakable only at the exact
|
|
57
|
+
// instant the waiter gives up). 30s/60s gives ~2× margin on each side.
|
|
58
|
+
async function lcAcquireLock(key, totalMs = 60000, staleMs = 30000) {
|
|
59
|
+
const lf = lcLockfile(key);
|
|
60
|
+
const bf = `${lf}.break`; // DL-51: a dedicated O_EXCL break-mutex that serializes stale-lock breaking (below)
|
|
61
|
+
mkdirSync(lcRunDir(), { recursive: true });
|
|
62
|
+
const deadline = Date.now() + totalMs;
|
|
63
|
+
const stamp = () => JSON.stringify({ pid: process.pid, at: new Date().toISOString() });
|
|
64
|
+
// Stale ⇔ holder gone, or older than staleMs (a crashed `up`, or a dead holder whose pid got recycled).
|
|
65
|
+
// `!(age <= staleMs)` (not `age > staleMs`) so a missing/corrupt `at` → NaN → stale (never trust an
|
|
66
|
+
// unparseable lock to be fresh). A dead pid reads stale immediately (no need to wait out staleMs).
|
|
67
|
+
const isStale = (h) => {
|
|
68
|
+
const age = h ? Date.now() - Date.parse(h.at) : Infinity;
|
|
69
|
+
return !h || !lcIsAlive(h.pid) || !(age <= staleMs);
|
|
70
|
+
};
|
|
71
|
+
// Throw once we've waited out totalMs on a LIVE holder/breaker. A stale lock is always broken (the break
|
|
72
|
+
// path below is never deadline-gated), so a recoverable stale lock present at the deadline is cleared and
|
|
73
|
+
// acquired, not reported as a hard failure (preserving the pre-DL-51 break-past-the-deadline behavior).
|
|
74
|
+
const checkDeadline = () => {
|
|
75
|
+
if (Date.now() < deadline)
|
|
76
|
+
return;
|
|
77
|
+
const h = lcReadLock(key);
|
|
78
|
+
throw new Error(`could not acquire daemon cold-start lock for '${key}'${h ? ` (held by pid ${h.pid})` : ""}`);
|
|
79
|
+
};
|
|
80
|
+
for (;;) {
|
|
81
|
+
try {
|
|
82
|
+
// `wx` = O_CREAT|O_EXCL|O_WRONLY — the OS guarantees exactly one creator wins (atomic, race-free).
|
|
83
|
+
// This wx-create is the SINGLE arbiter of who acquires: the stale-break below only ever REMOVES a
|
|
84
|
+
// stale lock so a wx-create can proceed — it never itself grants ownership — so two racers can never
|
|
85
|
+
// both acquire, even if both decide to break.
|
|
86
|
+
writeFileSync(lf, stamp(), { flag: "wx" });
|
|
87
|
+
let released = false;
|
|
88
|
+
// Ownership-checked release: only remove the lock if it's still OURS. If our hold somehow outlived
|
|
89
|
+
// staleMs and another `up` broke + re-took it, deleting unconditionally would clobber the NEW owner's
|
|
90
|
+
// lock and re-admit a concurrent cold start — so re-read and unlink only when the pid is still ours.
|
|
91
|
+
return () => { if (released)
|
|
92
|
+
return; released = true; try {
|
|
93
|
+
if (lcReadLock(key)?.pid === process.pid)
|
|
94
|
+
unlinkSync(lf);
|
|
95
|
+
}
|
|
96
|
+
catch { /* already gone */ } };
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (e.code !== "EEXIST")
|
|
100
|
+
throw e; // a real fs error, not "held"
|
|
101
|
+
if (!isStale(lcReadLock(key))) {
|
|
102
|
+
checkDeadline();
|
|
103
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
104
|
+
continue;
|
|
105
|
+
} // live, fresh `up` — wait
|
|
106
|
+
// DL-51 — break a stale lock under a dedicated O_EXCL break-mutex. Breaking lf directly is a TOCTOU that
|
|
107
|
+
// re-admits a 2nd cold start (the DL-46 orphan): two `up`s both read the OLD lock stale; one breaks it and
|
|
108
|
+
// wx-creates a FRESH lock, and the other's path-keyed remove (which cannot say "only if STILL the stale
|
|
109
|
+
// one") then clobbers that VALID lock. Under the mutex exactly ONE racer breaks at a time and re-confirms
|
|
110
|
+
// staleness while holding it: while `lf` exists nobody can wx-create over it and only the mutex-holder
|
|
111
|
+
// removes it, so this read→remove only ever deletes a STILL-stale lock; a fresh lock is left intact. The
|
|
112
|
+
// top-of-loop wx-create stays the SOLE arbiter of acquisition, so a break can never itself admit a second.
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(bf, stamp(), { flag: "wx" }); // sole breaker
|
|
115
|
+
try {
|
|
116
|
+
if (isStale(lcReadLock(key))) {
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(lf);
|
|
119
|
+
}
|
|
120
|
+
catch { /* already gone */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Ownership-checked release (mirrors the lf release): only unlink bf if it's still OURS, so we never
|
|
124
|
+
// clobber a mutex a racer legitimately re-took (the same hazard the lf release guards against).
|
|
125
|
+
finally {
|
|
126
|
+
try {
|
|
127
|
+
if (lcReadLockAt(bf)?.pid === process.pid)
|
|
128
|
+
unlinkSync(bf);
|
|
129
|
+
}
|
|
130
|
+
catch { /* already released */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (be) {
|
|
134
|
+
if (be.code !== "EEXIST")
|
|
135
|
+
throw be;
|
|
136
|
+
// Another racer holds the break-mutex: clear a dead/stale breaker (a crash mid-break can't wedge the
|
|
137
|
+
// next `up`); wait out a live breaker (it holds the mutex for only a couple of fs ops).
|
|
138
|
+
if (isStale(lcReadLockAt(bf))) {
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(bf);
|
|
141
|
+
}
|
|
142
|
+
catch { /* already gone */ }
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
checkDeadline();
|
|
146
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// loop: the stale lock (if any) is now gone → the wx-create at the top arbitrates the single acquirer.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// A stable, deterministic per-project port (FNV-1a of the key → a fixed high port) so the URL is the
|
|
154
|
+
// same across restarts even before a runfile exists; probed for freeness at `up` time so a hash
|
|
155
|
+
// collision between two projects just shifts the loser to the next free port (then records it).
|
|
156
|
+
function lcPortFor(key) {
|
|
157
|
+
let h = 2166136261 >>> 0;
|
|
158
|
+
for (let i = 0; i < key.length; i++) {
|
|
159
|
+
h ^= key.charCodeAt(i);
|
|
160
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
161
|
+
}
|
|
162
|
+
return 20000 + (h % 20000); // 20000–39999: clear of the registered-port crush and the 8787 default
|
|
163
|
+
}
|
|
164
|
+
function lcIsAlive(pid) {
|
|
165
|
+
if (!pid || pid <= 0)
|
|
166
|
+
return false;
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
return e.code === "EPERM";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Stop a pid gracefully: SIGTERM, wait up to graceMs, then escalate to SIGKILL so a wedged/slow daemon
|
|
176
|
+
// is never leaked. Shared by `down` and `up`'s reclaim + failed-spawn paths (one shutdown semantics).
|
|
177
|
+
async function lcStop(pid, graceMs = 3000) {
|
|
178
|
+
if (!lcIsAlive(pid))
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
process.kill(pid, "SIGTERM");
|
|
182
|
+
}
|
|
183
|
+
catch { /* already gone */ }
|
|
184
|
+
const deadline = Date.now() + graceMs;
|
|
185
|
+
while (Date.now() < deadline && lcIsAlive(pid))
|
|
186
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
187
|
+
if (lcIsAlive(pid)) {
|
|
188
|
+
try {
|
|
189
|
+
process.kill(pid, "SIGKILL");
|
|
190
|
+
}
|
|
191
|
+
catch { /* ignore */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function lcTryBind(port, host) {
|
|
195
|
+
return new Promise((res) => {
|
|
196
|
+
const s = netCreateServer();
|
|
197
|
+
s.once("error", () => res(false));
|
|
198
|
+
s.listen(port, host, () => s.close(() => res(true)));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async function lcFreePort(start, host, tries = 64) {
|
|
202
|
+
for (let i = 0; i < tries; i++) {
|
|
203
|
+
const p = start + i;
|
|
204
|
+
if (p > 65535)
|
|
205
|
+
break;
|
|
206
|
+
if (await lcTryBind(p, host))
|
|
207
|
+
return p;
|
|
208
|
+
}
|
|
209
|
+
return start; // give up probing — the spawned daemon will surface EADDRINUSE loudly rather than silently
|
|
210
|
+
}
|
|
211
|
+
async function lcProbe(url, key, timeoutMs = 1000) {
|
|
212
|
+
try {
|
|
213
|
+
const ac = new AbortController();
|
|
214
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
215
|
+
const r = await fetch(`${url}/api/health`, { signal: ac.signal }).finally(() => clearTimeout(t));
|
|
216
|
+
if (r.status !== 200)
|
|
217
|
+
return false;
|
|
218
|
+
const b = (await r.json().catch(() => null));
|
|
219
|
+
return !!b && b.ok === true && b.project === key; // confirm it's OUR project on that port, not a stranger
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function lcWaitHealthy(url, key, totalMs = 8000) {
|
|
226
|
+
const deadline = Date.now() + totalMs;
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
if (await lcProbe(url, key, 800))
|
|
229
|
+
return true;
|
|
230
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
// Resolve the project key like the MCP server / DL-13 launcher: explicit DEVLOOP_PROJECT wins; else
|
|
235
|
+
// match cwd against the configured repo paths. null ⇒ unresolved (the caller no-ops, never guesses).
|
|
236
|
+
function lcResolveKey() {
|
|
237
|
+
const explicit = process.env.DEVLOOP_PROJECT?.trim();
|
|
238
|
+
if (explicit)
|
|
239
|
+
return explicit; // parity with server.ts:22 — a present-but-empty/whitespace value is NOT a key
|
|
240
|
+
const cfg = loadProjectsConfig();
|
|
241
|
+
return cfg ? resolveProjectFromCwd(process.cwd(), cfg) : null;
|
|
242
|
+
}
|
|
243
|
+
async function daemonUp() {
|
|
244
|
+
const key = lcResolveKey();
|
|
245
|
+
if (!key) {
|
|
246
|
+
console.log("[daemon] up: no project resolved from cwd and DEVLOOP_PROJECT is unset — nothing to start.");
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
// Serveable ⇔ seeded in the hub DB. A non-service / unknown project is never in the hub ⇒ clean no-op.
|
|
250
|
+
const dbPath = lcDbPath();
|
|
251
|
+
let serveable = false;
|
|
252
|
+
try {
|
|
253
|
+
const probe = openDb(dbPath);
|
|
254
|
+
try {
|
|
255
|
+
serveable = !!findProject(probe, key);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
probe.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
serveable = false;
|
|
263
|
+
}
|
|
264
|
+
if (!serveable) {
|
|
265
|
+
console.log(`[daemon] up: '${key}' is not a service-backend hub project (not seeded) — nothing to start.`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
const host = "127.0.0.1"; // §16 localhost-only
|
|
269
|
+
// Fast path (lock-free): a healthy daemon is already running ⇒ no-op without taking the lock. This is the
|
|
270
|
+
// common case (the DL-42 hook fires `up` on every pane, but the daemon is usually already up), so routine
|
|
271
|
+
// `up`s never serialize on the lock — only an actual cold start does.
|
|
272
|
+
const pre = lcReadRun(key);
|
|
273
|
+
if (pre && lcIsAlive(pre.pid) && await lcProbe(pre.url, key)) {
|
|
274
|
+
console.log(`[daemon] up: already running for '${key}' → ${pre.url} (pid ${pre.pid})`);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
// DL-46: serialize cold start under the per-project lock — the second concurrent `up` waits here, then
|
|
278
|
+
// re-reads the runfile below and no-ops on the winner (no second spawn, no last-writer-wins runfile race).
|
|
279
|
+
let release;
|
|
280
|
+
try {
|
|
281
|
+
release = await lcAcquireLock(key);
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
console.error(`[daemon] up: ${e.message}`);
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const existing = lcReadRun(key);
|
|
289
|
+
if (existing && lcIsAlive(existing.pid)) {
|
|
290
|
+
if (await lcProbe(existing.url, key)) {
|
|
291
|
+
console.log(`[daemon] up: already running for '${key}' → ${existing.url} (pid ${existing.pid})`);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
// alive but not answering /api/health (now a real DB-writable probe) — a bound-but-wedged daemon;
|
|
295
|
+
// fully stop it (SIGTERM→SIGKILL) so we cleanly restart on its port rather than no-op onto a dead one.
|
|
296
|
+
await lcStop(existing.pid);
|
|
297
|
+
}
|
|
298
|
+
// port: explicit env override > recorded (stable across restarts) > deterministic; probe for free.
|
|
299
|
+
const envPort = process.env.DEVLOOP_DAEMON_PORT ? Number(process.env.DEVLOOP_DAEMON_PORT) : 0;
|
|
300
|
+
const port = envPort > 0 ? envPort : await lcFreePort(existing?.port || lcPortFor(key), host);
|
|
301
|
+
const url = `http://${host}:${port}`;
|
|
302
|
+
// Spawn the daemon ENTRY POINT — the foreground boot lives in daemon.ts (this module's sibling), so
|
|
303
|
+
// resolve it relative to here (NOT import.meta.url, which is this lifecycle module and has no server).
|
|
304
|
+
const self = fileURLToPath(new URL("./daemon.ts", import.meta.url));
|
|
305
|
+
mkdirSync(lcRunDir(), { recursive: true });
|
|
306
|
+
const logFd = openSync(join(lcRunDir(), `daemon-${key}.log`), "a");
|
|
307
|
+
const child = spawn(process.execPath, [self], {
|
|
308
|
+
detached: true, // survive the launching session (DL-42 hook)
|
|
309
|
+
stdio: ["ignore", logFd, logFd],
|
|
310
|
+
env: { ...process.env, DEVLOOP_PROJECT: key, DEVLOOP_DAEMON_PORT: String(port), DEVLOOP_HUB_DB: dbPath },
|
|
311
|
+
});
|
|
312
|
+
child.unref();
|
|
313
|
+
closeSync(logFd);
|
|
314
|
+
if (!child.pid) {
|
|
315
|
+
console.error("[daemon] up: failed to spawn the daemon process.");
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
if (!(await lcWaitHealthy(url, key))) {
|
|
319
|
+
console.error(`[daemon] up: spawned daemon for '${key}' did not become healthy at ${url} (see ${join(lcRunDir(), `daemon-${key}.log`)}).`);
|
|
320
|
+
await lcStop(child.pid); // never leak a slow/wedged child — escalate to SIGKILL if SIGTERM doesn't take
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
lcWriteRun({ project: key, pid: child.pid, port, host, url, startedAt: new Date().toISOString() });
|
|
324
|
+
console.log(`[daemon] up: started '${key}' → ${url} (pid ${child.pid})`);
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
release();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function daemonDown() {
|
|
332
|
+
const key = lcResolveKey();
|
|
333
|
+
if (!key) {
|
|
334
|
+
console.log("[daemon] down: no project resolved — nothing to stop.");
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
const info = lcReadRun(key);
|
|
338
|
+
if (!info) {
|
|
339
|
+
console.log(`[daemon] down: no daemon recorded for '${key}'.`);
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
if (lcIsAlive(info.pid)) {
|
|
343
|
+
await lcStop(info.pid); // SIGTERM→SIGKILL; stops a wedged daemon too (down must work even when unhealthy)
|
|
344
|
+
console.log(`[daemon] down: stopped '${key}' (pid ${info.pid}).`);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
console.log(`[daemon] down: '${key}' was not running (stale runfile cleared).`);
|
|
348
|
+
}
|
|
349
|
+
lcRemoveRun(key);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
async function daemonStatus() {
|
|
353
|
+
const key = lcResolveKey();
|
|
354
|
+
if (!key) {
|
|
355
|
+
console.log("[daemon] status: no project resolved (DEVLOOP_PROJECT unset, cwd outside every repo). Set DEVLOOP_PROJECT=<key>, or run from inside a configured repo.");
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
const info = lcReadRun(key);
|
|
359
|
+
if (info && lcIsAlive(info.pid) && (await lcProbe(info.url, key))) {
|
|
360
|
+
console.log(`[daemon] status: '${key}' RUNNING → ${info.url} (pid ${info.pid})`);
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
if (info && !lcIsAlive(info.pid))
|
|
364
|
+
lcRemoveRun(key); // a dead pid must never read as "running" — clear it
|
|
365
|
+
console.log(`[daemon] status: '${key}' stopped. Start it with \`dev-loop daemon up\`.`);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
export const LIFECYCLE_SUBS = ["up", "ensure", "down", "status"];
|
|
369
|
+
export async function daemonLifecycle(sub) {
|
|
370
|
+
const code = sub === "up" || sub === "ensure" ? await daemonUp() : sub === "down" ? await daemonDown() : await daemonStatus();
|
|
371
|
+
process.exit(code);
|
|
372
|
+
}
|