@agent-plan/server 0.2.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 +8 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/serve.d.ts +40 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +755 -0
- package/dist/ws-hub.d.ts +37 -0
- package/dist/ws-hub.d.ts.map +1 -0
- package/dist/ws-hub.js +77 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Antonio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { serve } from "./serve.js";
|
|
3
|
+
const plannerRoot = resolve(process.cwd(), ".planner");
|
|
4
|
+
const planRoot = process.env.PLAN_ROOT ?? plannerRoot;
|
|
5
|
+
const port = parseInt(process.env.PLAN_PORT ?? "3030", 10);
|
|
6
|
+
const host = process.env.PLAN_HOST ?? "127.0.0.1";
|
|
7
|
+
serve({ port, planRoot, host })
|
|
8
|
+
.then(({ url }) => {
|
|
9
|
+
console.log(`[plan-server] running at ${url}`);
|
|
10
|
+
console.log(`[plan-server] plan root: ${planRoot}`);
|
|
11
|
+
})
|
|
12
|
+
.catch((err) => {
|
|
13
|
+
console.error("[plan-server] failed:", err);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC1F,YAAY,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { WsHub } from "./ws-hub.js";
|
|
2
|
+
export interface ShortcutConfigSpec {
|
|
3
|
+
key: string;
|
|
4
|
+
primary?: boolean;
|
|
5
|
+
meta?: boolean;
|
|
6
|
+
ctrl?: boolean;
|
|
7
|
+
shift?: boolean;
|
|
8
|
+
alt?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ServerUiConfig {
|
|
11
|
+
mode: "local" | "lan";
|
|
12
|
+
bindHost: string;
|
|
13
|
+
port: number;
|
|
14
|
+
localUrl: string;
|
|
15
|
+
lanUrl?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
export interface UiConfig {
|
|
18
|
+
shortcuts?: Partial<Record<"create" | "edit" | "delete" | "submit", ShortcutConfigSpec>>;
|
|
19
|
+
server?: ServerUiConfig | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface ServeOptions {
|
|
22
|
+
port?: number;
|
|
23
|
+
planRoot: string;
|
|
24
|
+
host?: string | undefined;
|
|
25
|
+
staticDir?: string | undefined;
|
|
26
|
+
quiet?: boolean;
|
|
27
|
+
uiConfig?: UiConfig | undefined;
|
|
28
|
+
isBusy?: (() => boolean) | undefined;
|
|
29
|
+
}
|
|
30
|
+
export interface ServeHandle {
|
|
31
|
+
url: string;
|
|
32
|
+
localUrl: string;
|
|
33
|
+
lanUrl?: string | undefined;
|
|
34
|
+
mode: "local" | "lan";
|
|
35
|
+
bindHost: string;
|
|
36
|
+
hub: WsHub;
|
|
37
|
+
close: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare function serve(options: ServeOptions): Promise<ServeHandle>;
|
|
40
|
+
//# sourceMappingURL=serve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAiHpC,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,GAAG,KAAK,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC;IACzF,MAAM,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;CACrC;AAonBD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,EAAE,OAAO,GAAG,KAAK,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,KAAK,CAAC;IACX,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CA0EvE"}
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { watch, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import { cors } from "hono/cors";
|
|
8
|
+
import { ExportService, PlanStore, PlanStoreError, createFeatureId, createPhaseId, createTaskId, normalizeSlug, withFeatureLock, needsMotivation } from "@agent-plan/core";
|
|
9
|
+
import { WsHub } from "./ws-hub.js";
|
|
10
|
+
// ─── Watcher ────────────────────────────────────────────────────────────
|
|
11
|
+
let watcherAbort = null;
|
|
12
|
+
let watcherHubRef = { current: null };
|
|
13
|
+
function nowISO() {
|
|
14
|
+
return new Date().toISOString();
|
|
15
|
+
}
|
|
16
|
+
function nextTaskNumber(phase) {
|
|
17
|
+
const numbers = phase.tasks.map((task) => task.number || 0).filter((n) => Number.isFinite(n));
|
|
18
|
+
return (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
|
|
19
|
+
}
|
|
20
|
+
function isPrivateIpv4(address) {
|
|
21
|
+
return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
|
|
22
|
+
}
|
|
23
|
+
function detectLanIp() {
|
|
24
|
+
const nets = networkInterfaces();
|
|
25
|
+
let fallback;
|
|
26
|
+
for (const entries of Object.values(nets)) {
|
|
27
|
+
for (const entry of entries ?? []) {
|
|
28
|
+
if (entry.family !== "IPv4" || entry.internal)
|
|
29
|
+
continue;
|
|
30
|
+
if (isPrivateIpv4(entry.address))
|
|
31
|
+
return entry.address;
|
|
32
|
+
fallback ??= entry.address;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
function buildServerUrls(port, host) {
|
|
38
|
+
const localUrl = `http://127.0.0.1:${port}`;
|
|
39
|
+
if (host === "0.0.0.0") {
|
|
40
|
+
const lanIp = detectLanIp();
|
|
41
|
+
return {
|
|
42
|
+
mode: "lan",
|
|
43
|
+
bindHost: host,
|
|
44
|
+
localUrl,
|
|
45
|
+
lanUrl: lanIp ? `http://${lanIp}:${port}` : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { mode: "local", bindHost: host, localUrl, lanUrl: undefined };
|
|
49
|
+
}
|
|
50
|
+
function requiresGovernance(status) {
|
|
51
|
+
return status === "in-progress" || status === "done" || status === "blocked";
|
|
52
|
+
}
|
|
53
|
+
function entersGovernedState(previousStatus, nextStatus) {
|
|
54
|
+
return requiresGovernance(nextStatus) && !requiresGovernance(previousStatus);
|
|
55
|
+
}
|
|
56
|
+
function featureGovernanceReady(feature) {
|
|
57
|
+
return Boolean(feature.discussedAt || (feature.contextReady && feature.contextReadyReason.trim()));
|
|
58
|
+
}
|
|
59
|
+
function phaseGovernanceReady(phase) {
|
|
60
|
+
return Boolean(phase.discussedAt || (phase.contextReady && phase.contextReadyReason.trim()));
|
|
61
|
+
}
|
|
62
|
+
function applyTaskLifecycleDates(task, nextStatus, now) {
|
|
63
|
+
const previousStatus = task.status;
|
|
64
|
+
if (nextStatus === "in-progress" && !task.startedAt) {
|
|
65
|
+
task.startedAt = now;
|
|
66
|
+
}
|
|
67
|
+
if (nextStatus === "done") {
|
|
68
|
+
if (!task.startedAt)
|
|
69
|
+
task.startedAt = now;
|
|
70
|
+
task.completedAt = now;
|
|
71
|
+
}
|
|
72
|
+
else if (previousStatus === "done") {
|
|
73
|
+
task.completedAt = "";
|
|
74
|
+
}
|
|
75
|
+
task.status = nextStatus;
|
|
76
|
+
return task;
|
|
77
|
+
}
|
|
78
|
+
function startWatcher(planRoot, hubRef) {
|
|
79
|
+
stopWatcher();
|
|
80
|
+
if (!existsSync(planRoot))
|
|
81
|
+
return;
|
|
82
|
+
const ac = new AbortController();
|
|
83
|
+
watcherAbort = ac;
|
|
84
|
+
watcherHubRef = hubRef;
|
|
85
|
+
try {
|
|
86
|
+
watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
|
|
87
|
+
if (filename && !filename.includes(".tmp.")) {
|
|
88
|
+
hubRef.current?.broadcast({ type: "file-changed", data: { filename } });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// recursive watch may fail on some systems
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function stopWatcher() {
|
|
97
|
+
watcherAbort?.abort();
|
|
98
|
+
watcherAbort = null;
|
|
99
|
+
}
|
|
100
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
101
|
+
async function propagateTaskStatus(phaseId) {
|
|
102
|
+
// This is now handled by PlanStore.syncStatuses()
|
|
103
|
+
}
|
|
104
|
+
async function propagatePhaseStatus(featureId) {
|
|
105
|
+
// This is now handled by PlanStore.syncStatuses()
|
|
106
|
+
}
|
|
107
|
+
function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
|
|
108
|
+
const hub = () => hubRef.current;
|
|
109
|
+
const route = (path) => `${apiPrefix}${path}`;
|
|
110
|
+
const app = new Hono();
|
|
111
|
+
// Global error handler: never let an unhandled exception (e.g. a missing
|
|
112
|
+
// phase file -> PlanStoreError/ENOENT) bubble up to the host process console
|
|
113
|
+
// (the agent session). Convert it to a clean HTTP response instead.
|
|
114
|
+
app.onError((err, c) => {
|
|
115
|
+
const isStoreRead = err instanceof PlanStoreError || /ENOENT|read failed/i.test(err.message);
|
|
116
|
+
if (isStoreRead) {
|
|
117
|
+
return c.json({ error: "not found", message: err.message }, 404);
|
|
118
|
+
}
|
|
119
|
+
return c.json({ error: "internal", message: err instanceof Error ? err.message : String(err) }, 500);
|
|
120
|
+
});
|
|
121
|
+
app.use("*", cors({ origin: "*", allowHeaders: ["Content-Type"] }));
|
|
122
|
+
// Internal notify endpoint: lets adapter tools running in ANOTHER process
|
|
123
|
+
// (where they don't have the WsHub reference) force a live-update broadcast.
|
|
124
|
+
// Registered BEFORE the busy middleware so it always responds (a notify ping
|
|
125
|
+
// must never be blocked by a busy window, otherwise live updates are lost).
|
|
126
|
+
app.post(route("/internal/notify"), (c) => {
|
|
127
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
128
|
+
hub()?.broadcast({ type: "file-changed", data: { filename: "external-notify" } });
|
|
129
|
+
return c.json({ ok: true });
|
|
130
|
+
});
|
|
131
|
+
// While the agent is mutating .planner/ files, avoid serving possibly-inconsistent data.
|
|
132
|
+
// GET requests get a 503 busy signal the UI retries; mutations are rejected.
|
|
133
|
+
app.use("*", async (c, next) => {
|
|
134
|
+
if (isBusy?.()) {
|
|
135
|
+
const isRead = c.req.method === "GET" || c.req.method === "HEAD";
|
|
136
|
+
return c.json({ error: "plan-busy", busy: true, message: "Plan files are being updated by the agent. Please retry shortly." }, isRead ? 503 : 409);
|
|
137
|
+
}
|
|
138
|
+
await next();
|
|
139
|
+
});
|
|
140
|
+
// ── Project ──────────────────────────────────────────────────────
|
|
141
|
+
app.get(route("/export"), async (c) => {
|
|
142
|
+
const full = c.req.query("full") === "true";
|
|
143
|
+
const exportService = new ExportService();
|
|
144
|
+
const plan = await store.loadAll();
|
|
145
|
+
const markdown = exportService.exportToMarkdown(plan, full);
|
|
146
|
+
// Save to disk
|
|
147
|
+
await writeFile(join(store.root, "EXPORT.md"), markdown, "utf-8");
|
|
148
|
+
return c.json({ markdown, filePath: "EXPORT.md" });
|
|
149
|
+
});
|
|
150
|
+
app.get(route("/project"), async (c) => {
|
|
151
|
+
const project = await store.loadProject();
|
|
152
|
+
return c.json({
|
|
153
|
+
...project,
|
|
154
|
+
planRoot: store.root,
|
|
155
|
+
projectRoot: dirname(store.root),
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
app.put(route("/project"), async (c) => {
|
|
159
|
+
const body = await c.req.json();
|
|
160
|
+
await store.updateProject(() => body);
|
|
161
|
+
await store.writeGenerated();
|
|
162
|
+
hub()?.broadcast({ type: "project-updated", data: body });
|
|
163
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
164
|
+
return c.json(body);
|
|
165
|
+
});
|
|
166
|
+
// ── Requirements ─────────────────────────────────────────────────
|
|
167
|
+
app.get(route("/requirements"), async (c) => c.json(await store.loadRequirements()));
|
|
168
|
+
app.post(route("/requirements"), async (c) => {
|
|
169
|
+
const body = await c.req.json();
|
|
170
|
+
const reqs = await store.updateRequirements((doc) => { doc.requirements.push(body); return doc; });
|
|
171
|
+
await store.writeGenerated();
|
|
172
|
+
hub()?.broadcast({ type: "requirements-updated", data: reqs });
|
|
173
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
174
|
+
return c.json(body, 201);
|
|
175
|
+
});
|
|
176
|
+
app.put(route("/requirements/:id"), async (c) => {
|
|
177
|
+
const id = c.req.param("id");
|
|
178
|
+
const body = await c.req.json();
|
|
179
|
+
let found = false;
|
|
180
|
+
const reqs = await store.updateRequirements((doc) => {
|
|
181
|
+
const idx = doc.requirements.findIndex((r) => r.id === id);
|
|
182
|
+
if (idx === -1)
|
|
183
|
+
return doc;
|
|
184
|
+
doc.requirements[idx] = body;
|
|
185
|
+
found = true;
|
|
186
|
+
return doc;
|
|
187
|
+
});
|
|
188
|
+
if (!found)
|
|
189
|
+
return c.json({ error: "not found" }, 404);
|
|
190
|
+
await store.writeGenerated();
|
|
191
|
+
hub()?.broadcast({ type: "requirements-updated", data: reqs });
|
|
192
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
193
|
+
return c.json(body);
|
|
194
|
+
});
|
|
195
|
+
// ── Features ─────────────────────────────────────────────────────
|
|
196
|
+
app.get(route("/features"), async (c) => c.json((await store.loadFeatures()).features));
|
|
197
|
+
app.post(route("/features"), async (c) => {
|
|
198
|
+
const body = await c.req.json();
|
|
199
|
+
const name = body.name?.trim();
|
|
200
|
+
if (!name)
|
|
201
|
+
return c.json({ error: "name required" }, 400);
|
|
202
|
+
const features = await store.loadFeatures();
|
|
203
|
+
const number = features.features.length + 1;
|
|
204
|
+
const now = nowISO();
|
|
205
|
+
const feature = {
|
|
206
|
+
id: createFeatureId(),
|
|
207
|
+
number,
|
|
208
|
+
name,
|
|
209
|
+
description: body.description ?? "",
|
|
210
|
+
status: "planned",
|
|
211
|
+
discussedAt: "",
|
|
212
|
+
contextReady: false,
|
|
213
|
+
contextReadyReason: "",
|
|
214
|
+
startDate: "",
|
|
215
|
+
endDate: "",
|
|
216
|
+
workDone: "",
|
|
217
|
+
workRemaining: "",
|
|
218
|
+
acceptedDecisions: [],
|
|
219
|
+
phaseIds: [], dependsOn: [],
|
|
220
|
+
createdAt: now,
|
|
221
|
+
updatedAt: now,
|
|
222
|
+
};
|
|
223
|
+
features.features.push(feature);
|
|
224
|
+
await store.updateFeatures((doc) => { doc.features.push(feature); return doc; });
|
|
225
|
+
await store.writeGenerated();
|
|
226
|
+
hub()?.broadcast({ type: "features-updated", data: { action: "created", id: feature.id, featureId: feature.id } });
|
|
227
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
228
|
+
await store.syncStatuses();
|
|
229
|
+
await store.appendActivity("feature_created", feature.id, `Feature created: ${feature.name}`);
|
|
230
|
+
return c.json(feature, 201);
|
|
231
|
+
});
|
|
232
|
+
app.get(route("/features/:id"), async (c) => {
|
|
233
|
+
const id = c.req.param("id");
|
|
234
|
+
const features = await store.loadFeatures();
|
|
235
|
+
const feature = features.features.find((f) => f.id === id);
|
|
236
|
+
if (!feature)
|
|
237
|
+
return c.json({ error: "not found" }, 404);
|
|
238
|
+
return c.json(feature);
|
|
239
|
+
});
|
|
240
|
+
app.put(route("/features/:id"), async (c) => {
|
|
241
|
+
const id = c.req.param("id");
|
|
242
|
+
const body = await c.req.json();
|
|
243
|
+
if (body.id !== id)
|
|
244
|
+
return c.json({ error: "id mismatch" }, 400);
|
|
245
|
+
const features = await store.loadFeatures();
|
|
246
|
+
const idx = features.features.findIndex((f) => f.id === id);
|
|
247
|
+
if (idx === -1)
|
|
248
|
+
return c.json({ error: "not found" }, 404);
|
|
249
|
+
if (entersGovernedState(features.features[idx]?.status, body.status) && !featureGovernanceReady(body)) {
|
|
250
|
+
return c.json({ error: "feature governance required: discuss the feature first, or set contextReady=true with a reason before starting work." }, 400);
|
|
251
|
+
}
|
|
252
|
+
await store.updateFeatures((doc) => {
|
|
253
|
+
const i = doc.features.findIndex((f) => f.id === id);
|
|
254
|
+
if (i !== -1)
|
|
255
|
+
doc.features[i] = body;
|
|
256
|
+
return doc;
|
|
257
|
+
});
|
|
258
|
+
await store.writeGenerated();
|
|
259
|
+
hub()?.broadcast({ type: "features-updated", data: { action: "updated", id, featureId: id } });
|
|
260
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
261
|
+
await store.syncStatuses();
|
|
262
|
+
return c.json(body);
|
|
263
|
+
});
|
|
264
|
+
app.delete(route("/features/:id"), async (c) => {
|
|
265
|
+
const id = c.req.param("id");
|
|
266
|
+
await store.updateFeatures((doc) => {
|
|
267
|
+
doc.features = doc.features.filter((f) => f.id !== id);
|
|
268
|
+
return doc;
|
|
269
|
+
});
|
|
270
|
+
await store.writeGenerated();
|
|
271
|
+
hub()?.broadcast({ type: "features-updated", data: { action: "deleted", id, featureId: id } });
|
|
272
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
273
|
+
await store.syncStatuses();
|
|
274
|
+
return c.json({ deleted: id });
|
|
275
|
+
});
|
|
276
|
+
// ── Phases ───────────────────────────────────────────────────────
|
|
277
|
+
app.get(route("/phases"), async (c) => {
|
|
278
|
+
const allPhases = await store.loadAllPhases();
|
|
279
|
+
const featureId = c.req.query("featureId");
|
|
280
|
+
if (featureId)
|
|
281
|
+
return c.json(allPhases.filter((p) => p.featureId === featureId));
|
|
282
|
+
return c.json(allPhases);
|
|
283
|
+
});
|
|
284
|
+
app.post(route("/phases"), async (c) => {
|
|
285
|
+
const body = await c.req.json();
|
|
286
|
+
// Backward-compatible: accept a full Phase object, or generate one from title.
|
|
287
|
+
if (body.id && body.slug && body.number && body.tasks && body.taskIds) {
|
|
288
|
+
const full = body;
|
|
289
|
+
if (requiresGovernance(full.status) && !phaseGovernanceReady(full)) {
|
|
290
|
+
return c.json({ error: "phase governance required: discuss the phase first, or set contextReady=true with a reason before starting work." }, 400);
|
|
291
|
+
}
|
|
292
|
+
await store.updatePhase(full.id, () => full);
|
|
293
|
+
await store.writeGenerated();
|
|
294
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "created", id: full.id, phaseId: full.id, featureId: full.featureId ?? "" } });
|
|
295
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
296
|
+
return c.json(full, 201);
|
|
297
|
+
}
|
|
298
|
+
const title = body.title?.trim();
|
|
299
|
+
if (!title)
|
|
300
|
+
return c.json({ error: "title required" }, 400);
|
|
301
|
+
const featureId = body.featureId?.trim();
|
|
302
|
+
if (!featureId)
|
|
303
|
+
return c.json({ error: "featureId required: a phase must belong to a feature" }, 400);
|
|
304
|
+
let phase;
|
|
305
|
+
await withFeatureLock(featureId, async () => {
|
|
306
|
+
const allPhases = await store.loadAllPhases();
|
|
307
|
+
const number = allPhases.filter((p) => p.featureId === featureId).length + 1;
|
|
308
|
+
const slug = normalizeSlug(title);
|
|
309
|
+
const now = nowISO();
|
|
310
|
+
phase = {
|
|
311
|
+
id: createPhaseId(),
|
|
312
|
+
featureId,
|
|
313
|
+
number,
|
|
314
|
+
slug,
|
|
315
|
+
title,
|
|
316
|
+
status: "draft",
|
|
317
|
+
discussedAt: "",
|
|
318
|
+
contextReady: false,
|
|
319
|
+
contextReadyReason: "",
|
|
320
|
+
summary: body.summary ?? "",
|
|
321
|
+
description: body.description ?? "",
|
|
322
|
+
notes: "",
|
|
323
|
+
goals: [],
|
|
324
|
+
nonGoals: [],
|
|
325
|
+
dependencies: [],
|
|
326
|
+
risks: [],
|
|
327
|
+
openQuestions: [],
|
|
328
|
+
completionCriteria: [],
|
|
329
|
+
decisions: [],
|
|
330
|
+
acceptedDecisions: [],
|
|
331
|
+
taskIds: [],
|
|
332
|
+
tasks: [],
|
|
333
|
+
dependsOn: [],
|
|
334
|
+
createdAt: now,
|
|
335
|
+
updatedAt: now,
|
|
336
|
+
};
|
|
337
|
+
await store.savePhase(phase);
|
|
338
|
+
// Link to feature if featureId provided
|
|
339
|
+
if (body.featureId) {
|
|
340
|
+
await store.updateFeatures((features) => {
|
|
341
|
+
const feature = features.features.find((f) => f.id === body.featureId);
|
|
342
|
+
if (feature && !feature.phaseIds.includes(phase.id)) {
|
|
343
|
+
feature.phaseIds.push(phase.id);
|
|
344
|
+
}
|
|
345
|
+
return features;
|
|
346
|
+
});
|
|
347
|
+
hub()?.broadcast({ type: "features-updated", data: { action: "updated", id: body.featureId, featureId: body.featureId } });
|
|
348
|
+
}
|
|
349
|
+
await store.writeGenerated();
|
|
350
|
+
});
|
|
351
|
+
if (!phase)
|
|
352
|
+
return c.json({ error: "phase creation failed" }, 500);
|
|
353
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "created", id: phase.id, phaseId: phase.id, featureId: phase.featureId ?? "" } });
|
|
354
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
355
|
+
await store.syncStatuses();
|
|
356
|
+
return c.json(phase, 201);
|
|
357
|
+
});
|
|
358
|
+
app.get(route("/phases/:id"), async (c) => {
|
|
359
|
+
const id = c.req.param("id");
|
|
360
|
+
if (!id)
|
|
361
|
+
return c.json({ error: "id required" }, 400);
|
|
362
|
+
try {
|
|
363
|
+
return c.json(await store.loadPhase(id));
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return c.json({ error: "phase not found" }, 404);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
app.put(route("/phases/:id"), async (c) => {
|
|
370
|
+
const id = c.req.param("id");
|
|
371
|
+
const body = await c.req.json();
|
|
372
|
+
if (body.id !== id)
|
|
373
|
+
return c.json({ error: "id mismatch" }, 400);
|
|
374
|
+
const existingPhase = await store.loadPhase(id).catch(() => null);
|
|
375
|
+
if (!existingPhase)
|
|
376
|
+
return c.json({ error: "phase not found" }, 404);
|
|
377
|
+
if (entersGovernedState(existingPhase.status, body.status) && !phaseGovernanceReady(body)) {
|
|
378
|
+
return c.json({ error: "phase governance required: discuss the phase first, or set contextReady=true with a reason before starting work." }, 400);
|
|
379
|
+
}
|
|
380
|
+
await store.updatePhase(id, () => body);
|
|
381
|
+
await store.writeGenerated();
|
|
382
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "updated", id, phaseId: id, featureId: body.featureId ?? "" } });
|
|
383
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
384
|
+
// Propagate status up
|
|
385
|
+
await store.syncStatuses();
|
|
386
|
+
await store.appendActivity("phase_updated", id, `Phase updated: ${body.title} (${body.status})`);
|
|
387
|
+
return c.json(body);
|
|
388
|
+
});
|
|
389
|
+
app.delete(route("/phases/:id"), async (c) => {
|
|
390
|
+
const id = c.req.param("id");
|
|
391
|
+
if (!id)
|
|
392
|
+
return c.json({ error: "id required" }, 400);
|
|
393
|
+
const existing = await store.loadPhase(id).catch(() => null);
|
|
394
|
+
if (!existing)
|
|
395
|
+
return c.json({ error: "phase not found" }, 404);
|
|
396
|
+
await store.deletePhase(id);
|
|
397
|
+
if (existing.featureId) {
|
|
398
|
+
await store.updateFeatures((features) => {
|
|
399
|
+
const feature = features.features.find((f) => f.id === existing.featureId);
|
|
400
|
+
if (feature)
|
|
401
|
+
feature.phaseIds = feature.phaseIds.filter((pid) => pid !== id);
|
|
402
|
+
return features;
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
await store.writeGenerated();
|
|
406
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "deleted", id, phaseId: id, featureId: existing.featureId ?? "" } });
|
|
407
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
408
|
+
await store.syncStatuses();
|
|
409
|
+
await store.appendActivity("phase_deleted", id ?? "", `Phase deleted: ${id}`);
|
|
410
|
+
return c.json({ deleted: id });
|
|
411
|
+
});
|
|
412
|
+
app.post(route("/phases/:phaseId/tasks"), async (c) => {
|
|
413
|
+
const phaseId = c.req.param("phaseId");
|
|
414
|
+
if (!phaseId)
|
|
415
|
+
return c.json({ error: "phaseId required" }, 400);
|
|
416
|
+
const body = await c.req.json();
|
|
417
|
+
const title = body.title?.trim();
|
|
418
|
+
if (!title)
|
|
419
|
+
return c.json({ error: "title required" }, 400);
|
|
420
|
+
const phase = await store.loadPhase(phaseId).catch(() => null);
|
|
421
|
+
if (!phase)
|
|
422
|
+
return c.json({ error: "phase not found" }, 404);
|
|
423
|
+
if (requiresGovernance(body.status) && phase.featureId) {
|
|
424
|
+
const features = await store.loadFeatures();
|
|
425
|
+
const feature = features.features.find((entry) => entry.id === phase.featureId);
|
|
426
|
+
if (feature && !featureGovernanceReady(feature)) {
|
|
427
|
+
return c.json({ error: "feature governance required before starting task work: discuss the feature first, or set contextReady=true with a reason." }, 400);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const now = nowISO();
|
|
431
|
+
const taskNumber = nextTaskNumber(phase);
|
|
432
|
+
const shortName = normalizeSlug(title).trim() || `task-${Date.now().toString(36)}`;
|
|
433
|
+
const initialStatus = body.status ?? "planned";
|
|
434
|
+
const task = {
|
|
435
|
+
id: createTaskId(),
|
|
436
|
+
phaseId: phase.id,
|
|
437
|
+
number: taskNumber,
|
|
438
|
+
shortName,
|
|
439
|
+
title,
|
|
440
|
+
status: initialStatus,
|
|
441
|
+
description: body.description ?? "",
|
|
442
|
+
notes: "",
|
|
443
|
+
statusLog: [],
|
|
444
|
+
decisions: [],
|
|
445
|
+
acceptedDecisions: [],
|
|
446
|
+
checklist: [],
|
|
447
|
+
subtasks: [],
|
|
448
|
+
dependsOn: [],
|
|
449
|
+
startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
|
|
450
|
+
completedAt: initialStatus === "done" ? now : "",
|
|
451
|
+
createdAt: now,
|
|
452
|
+
updatedAt: now,
|
|
453
|
+
};
|
|
454
|
+
// Atomic: serialize concurrent task-create on the same phase file.
|
|
455
|
+
await store.updatePhase(phase.id, (p) => {
|
|
456
|
+
p.tasks.push(task);
|
|
457
|
+
p.taskIds.push(task.id);
|
|
458
|
+
p.updatedAt = now;
|
|
459
|
+
return p;
|
|
460
|
+
});
|
|
461
|
+
await store.writeGenerated();
|
|
462
|
+
await store.syncTaskStatusRollup(phase.id);
|
|
463
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "task-created", id: phase.id, phaseId: phase.id, featureId: phase.featureId ?? "", taskId: task.id } });
|
|
464
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
465
|
+
await store.appendActivity("task_created", task.id, `Task created: ${task.title} (phase ${phaseId})`);
|
|
466
|
+
// Build a human-readable composite label so agents echo this instead of the UUID.
|
|
467
|
+
const featuresDoc = await store.loadFeatures();
|
|
468
|
+
const featureNum = phase.featureId ? (featuresDoc.features.find((f) => f.id === phase.featureId)?.number ?? 0) : 0;
|
|
469
|
+
const seq = (n) => String(n && n > 0 ? n : 0).padStart(3, "0");
|
|
470
|
+
const label = `T${seq(task.number)}(P${seq(phase.number)}/F${seq(featureNum)}) - ${task.title}`;
|
|
471
|
+
return c.json({ ...task, label }, 201);
|
|
472
|
+
});
|
|
473
|
+
// ── Tasks ─────────────────────────────────────────────────────────
|
|
474
|
+
app.get(route("/tasks/active"), async (c) => {
|
|
475
|
+
const [phases, featuresDoc] = await Promise.all([store.loadAllPhases(), store.loadFeatures()]);
|
|
476
|
+
// Build lookups for IDs and numbers
|
|
477
|
+
const phaseToFeature = new Map();
|
|
478
|
+
const featureIdToNum = new Map();
|
|
479
|
+
for (const feature of featuresDoc.features) {
|
|
480
|
+
featureIdToNum.set(feature.id, feature.number);
|
|
481
|
+
for (const phaseId of feature.phaseIds) {
|
|
482
|
+
if (!phaseToFeature.has(phaseId))
|
|
483
|
+
phaseToFeature.set(phaseId, feature.id);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const activeTasksMap = new Map();
|
|
487
|
+
for (const phase of phases) {
|
|
488
|
+
for (const task of phase.tasks) {
|
|
489
|
+
if (task.status === "in-progress") {
|
|
490
|
+
const featureId = phase.featureId ?? phaseToFeature.get(phase.id);
|
|
491
|
+
if (!featureId)
|
|
492
|
+
continue; // Skip tasks without a valid feature link to avoid 404s
|
|
493
|
+
activeTasksMap.set(task.id, {
|
|
494
|
+
id: task.id,
|
|
495
|
+
number: task.number,
|
|
496
|
+
title: task.title,
|
|
497
|
+
phaseId: phase.id,
|
|
498
|
+
phaseNumber: phase.number,
|
|
499
|
+
featureId,
|
|
500
|
+
featureNumber: featureIdToNum.get(featureId) ?? 0,
|
|
501
|
+
status: task.status,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return c.json(Array.from(activeTasksMap.values()));
|
|
507
|
+
});
|
|
508
|
+
app.get(route("/tasks/:id"), async (c) => {
|
|
509
|
+
const taskId = c.req.param("id");
|
|
510
|
+
const phases = await store.loadAllPhases();
|
|
511
|
+
for (const phase of phases) {
|
|
512
|
+
const task = phase.tasks.find((t) => t.id === taskId);
|
|
513
|
+
if (task)
|
|
514
|
+
return c.json(task);
|
|
515
|
+
}
|
|
516
|
+
return c.json({ error: "task not found" }, 404);
|
|
517
|
+
});
|
|
518
|
+
app.put(route("/tasks/:id"), async (c) => {
|
|
519
|
+
const taskId = c.req.param("id");
|
|
520
|
+
const body = await c.req.json();
|
|
521
|
+
if (!body.phaseId)
|
|
522
|
+
return c.json({ error: "phaseId required" }, 400);
|
|
523
|
+
const phase = await store.loadPhase(body.phaseId).catch(() => null);
|
|
524
|
+
if (!phase)
|
|
525
|
+
return c.json({ error: "phase not found" }, 404);
|
|
526
|
+
const existing = phase.tasks.find((t) => t.id === taskId);
|
|
527
|
+
if (!existing)
|
|
528
|
+
return c.json({ error: "task not found" }, 404);
|
|
529
|
+
// Validate motivation requirement for status transitions.
|
|
530
|
+
if (body.status && body.status !== existing.status && needsMotivation(existing.status, body.status)) {
|
|
531
|
+
if (!body.motivation || !body.motivation.trim()) {
|
|
532
|
+
return c.json({ error: `Status transition "${existing.status} → ${body.status}" requires a motivation. Provide the "motivation" field with a detailed explanation.` }, 400);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (entersGovernedState(existing.status, body.status) && phase.featureId) {
|
|
536
|
+
const features = await store.loadFeatures();
|
|
537
|
+
const feature = features.features.find((entry) => entry.id === phase.featureId);
|
|
538
|
+
if (feature && !featureGovernanceReady(feature)) {
|
|
539
|
+
return c.json({ error: "feature governance required before starting task work: discuss the feature first, or set contextReady=true with a reason." }, 400);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// NOTE: checklist completeness is advisory for the AI agent (enforced via
|
|
543
|
+
// prompt rules), not a hard API gate. The web UI is for the human
|
|
544
|
+
// supervisor, who may close a task regardless of checklist state.
|
|
545
|
+
const now = nowISO();
|
|
546
|
+
const updated = {
|
|
547
|
+
...existing,
|
|
548
|
+
...body,
|
|
549
|
+
id: taskId,
|
|
550
|
+
phaseId: existing.phaseId,
|
|
551
|
+
shortName: existing.shortName,
|
|
552
|
+
createdAt: existing.createdAt,
|
|
553
|
+
startedAt: existing.startedAt,
|
|
554
|
+
completedAt: existing.completedAt,
|
|
555
|
+
updatedAt: now,
|
|
556
|
+
};
|
|
557
|
+
if (body.status) {
|
|
558
|
+
applyTaskLifecycleDates(updated, body.status, now);
|
|
559
|
+
}
|
|
560
|
+
// Record status change in the incremental statusLog.
|
|
561
|
+
if (body.status && body.status !== existing.status) {
|
|
562
|
+
const entry = {
|
|
563
|
+
id: createTaskId(),
|
|
564
|
+
date: now,
|
|
565
|
+
fromStatus: existing.status,
|
|
566
|
+
toStatus: body.status,
|
|
567
|
+
title: body.motivation?.split("\n")[0]?.trim() || `${existing.status} → ${body.status}`,
|
|
568
|
+
description: body.motivation?.trim() || "",
|
|
569
|
+
};
|
|
570
|
+
updated.statusLog = [...(existing.statusLog ?? []), entry];
|
|
571
|
+
}
|
|
572
|
+
const tIdx = phase.tasks.findIndex((t) => t.id === taskId);
|
|
573
|
+
phase.tasks[tIdx] = updated;
|
|
574
|
+
phase.updatedAt = updated.updatedAt;
|
|
575
|
+
// Atomic: serialize concurrent task updates on the same phase.
|
|
576
|
+
await store.updatePhase(phase.id, (p) => {
|
|
577
|
+
const i = p.tasks.findIndex((t) => t.id === taskId);
|
|
578
|
+
if (i !== -1)
|
|
579
|
+
p.tasks[i] = updated;
|
|
580
|
+
p.updatedAt = updated.updatedAt;
|
|
581
|
+
return p;
|
|
582
|
+
});
|
|
583
|
+
await store.writeGenerated();
|
|
584
|
+
await store.syncTaskStatusRollup(phase.id);
|
|
585
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "task-updated", id: body.phaseId, phaseId: body.phaseId, featureId: phase.featureId ?? "", taskId } });
|
|
586
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
587
|
+
// Propagate status up
|
|
588
|
+
if (body.status && body.status !== existing.status) {
|
|
589
|
+
await store.appendActivity("task_status", taskId ?? "", `Task ${taskId} → ${body.status}`);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
await store.appendActivity("task_updated", taskId ?? "", `Task updated: ${updated.title}`);
|
|
593
|
+
}
|
|
594
|
+
return c.json(updated);
|
|
595
|
+
});
|
|
596
|
+
app.delete(route("/tasks/:id"), async (c) => {
|
|
597
|
+
const taskId = c.req.param("id");
|
|
598
|
+
const phases = await store.loadAllPhases();
|
|
599
|
+
const hostPhase = phases.find((entry) => entry.tasks.some((task) => task.id === taskId));
|
|
600
|
+
if (!hostPhase)
|
|
601
|
+
return c.json({ error: "task not found" }, 404);
|
|
602
|
+
const existingTask = hostPhase.tasks.find((task) => task.id === taskId);
|
|
603
|
+
await store.updatePhase(hostPhase.id, (p) => {
|
|
604
|
+
p.tasks = p.tasks.filter((task) => task.id !== taskId);
|
|
605
|
+
p.taskIds = p.taskIds.filter((id) => id !== taskId);
|
|
606
|
+
p.updatedAt = nowISO();
|
|
607
|
+
return p;
|
|
608
|
+
});
|
|
609
|
+
await store.writeGenerated();
|
|
610
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "task-deleted", id: hostPhase.id, phaseId: hostPhase.id, featureId: hostPhase.featureId ?? "", taskId: existingTask?.id ?? taskId } });
|
|
611
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
612
|
+
await store.syncStatuses();
|
|
613
|
+
await store.appendActivity("task_deleted", taskId ?? "", `Task deleted: ${taskId}`);
|
|
614
|
+
return c.json({ deleted: taskId });
|
|
615
|
+
});
|
|
616
|
+
// ── Integrity / Repair ────────────────────────────────────────
|
|
617
|
+
app.get(route("/integrity"), async (c) => {
|
|
618
|
+
const integrity = await store.validateIntegrity();
|
|
619
|
+
return c.json(integrity);
|
|
620
|
+
});
|
|
621
|
+
app.post(route("/repair"), async (c) => {
|
|
622
|
+
const report = await store.repair();
|
|
623
|
+
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
624
|
+
hub()?.broadcast({ type: "features-updated", data: { action: "repaired" } });
|
|
625
|
+
hub()?.broadcast({ type: "phases-updated", data: { action: "repaired" } });
|
|
626
|
+
return c.json(report);
|
|
627
|
+
});
|
|
628
|
+
// ── Render ───────────────────────────────────────────────────────
|
|
629
|
+
app.post(route("/render"), async (c) => {
|
|
630
|
+
const files = await store.writeGenerated();
|
|
631
|
+
hub()?.broadcast({ type: "plan-rendered", data: { files } });
|
|
632
|
+
return c.json({ files });
|
|
633
|
+
});
|
|
634
|
+
// ── Handoff ──────────────────────────────────────────────────────
|
|
635
|
+
app.get(route("/handoff"), async (c) => {
|
|
636
|
+
const handoff = await store.loadHandoff();
|
|
637
|
+
return c.json({
|
|
638
|
+
exists: Boolean(handoff),
|
|
639
|
+
content: handoff?.content ?? "",
|
|
640
|
+
createdAt: handoff?.createdAt ?? "",
|
|
641
|
+
updatedAt: handoff?.updatedAt ?? "",
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
app.delete(route("/handoff"), async (c) => {
|
|
645
|
+
await store.deleteHandoff();
|
|
646
|
+
return c.json({ deleted: true });
|
|
647
|
+
});
|
|
648
|
+
// ── Health ───────────────────────────────────────────────────────
|
|
649
|
+
app.get(route("/health"), (c) => c.json({ status: "ok", root: store.root }));
|
|
650
|
+
app.get(route("/ui-config"), (c) => c.json(uiConfig ?? {}));
|
|
651
|
+
return app;
|
|
652
|
+
}
|
|
653
|
+
// ─── Serve static files (SPA) ────────────────────────────────────────────
|
|
654
|
+
function createSpaApp(store, hubRef, staticDir, uiConfig, isBusy) {
|
|
655
|
+
const app = createApiApp(store, hubRef, staticDir ? "/api" : "", uiConfig, isBusy);
|
|
656
|
+
if (!staticDir) {
|
|
657
|
+
return app;
|
|
658
|
+
}
|
|
659
|
+
// Serve the built React app for all unmatched GET routes
|
|
660
|
+
app.all("*", async (c) => {
|
|
661
|
+
if (c.req.method !== "GET")
|
|
662
|
+
return c.json({ error: "not found" }, 404);
|
|
663
|
+
// Try exact file first
|
|
664
|
+
const filePath = join(staticDir, c.req.path === "/" ? "index.html" : c.req.path.slice(1));
|
|
665
|
+
try {
|
|
666
|
+
const content = readFileSync(filePath);
|
|
667
|
+
const ext = filePath.split(".").pop() ?? "";
|
|
668
|
+
const mime = {
|
|
669
|
+
html: "text/html",
|
|
670
|
+
js: "application/javascript",
|
|
671
|
+
css: "text/css",
|
|
672
|
+
png: "image/png",
|
|
673
|
+
svg: "image/svg+xml",
|
|
674
|
+
json: "application/json",
|
|
675
|
+
};
|
|
676
|
+
return new Response(content, { status: 200, headers: { "Content-Type": mime[ext] ?? "application/octet-stream" } });
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// SPA fallback: serve index.html for any unmatched path
|
|
680
|
+
try {
|
|
681
|
+
const indexContent = readFileSync(join(staticDir, "index.html"));
|
|
682
|
+
return new Response(indexContent, { status: 200, headers: { "Content-Type": "text/html" } });
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return c.json({ error: "not found" }, 404);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
return app;
|
|
690
|
+
}
|
|
691
|
+
export async function serve(options) {
|
|
692
|
+
const { port = 3030, planRoot, host = "127.0.0.1" } = options;
|
|
693
|
+
const store = new PlanStore(planRoot);
|
|
694
|
+
const runtimeUiConfig = { ...(options.uiConfig ?? {}) };
|
|
695
|
+
if (!(await store.exists())) {
|
|
696
|
+
throw new Error(`.planner/ not found at: ${planRoot}. Run plan-init first.`);
|
|
697
|
+
}
|
|
698
|
+
// Self-heal stale/legacy persisted statuses before the UI reads them.
|
|
699
|
+
await store.syncStatuses();
|
|
700
|
+
// Shared mutable reference — routes see the hub after it's created
|
|
701
|
+
const hubRef = { current: null };
|
|
702
|
+
const app = createSpaApp(store, hubRef, options.staticDir, runtimeUiConfig, options.isBusy);
|
|
703
|
+
return new Promise((resolve, reject) => {
|
|
704
|
+
// Create the Node HTTP server without listening yet
|
|
705
|
+
const server = createAdaptorServer({ fetch: app.fetch, hostname: host });
|
|
706
|
+
// Attach WebSocket hub to the underlying HTTP server
|
|
707
|
+
const hub = new WsHub(server, options.quiet, (err) => reject(err));
|
|
708
|
+
hubRef.current = hub; // Now routes can use it
|
|
709
|
+
// Critical: if listen() fails (e.g. EADDRINUSE), Node emits an 'error' event.
|
|
710
|
+
// Without a listener this becomes an uncaughtException that crashes Pi.
|
|
711
|
+
// Reject the promise so callers' .catch() can handle it.
|
|
712
|
+
server.on("error", (err) => {
|
|
713
|
+
reject(err);
|
|
714
|
+
});
|
|
715
|
+
server.listen(port, host, () => {
|
|
716
|
+
// When port=0 (random), resolve the actually assigned port from the server address.
|
|
717
|
+
const actualPort = (() => {
|
|
718
|
+
try {
|
|
719
|
+
const addr = server.address();
|
|
720
|
+
if (addr && typeof addr === "object" && addr.port)
|
|
721
|
+
return addr.port;
|
|
722
|
+
}
|
|
723
|
+
catch { }
|
|
724
|
+
return port;
|
|
725
|
+
})();
|
|
726
|
+
const serverUrls = buildServerUrls(actualPort, host);
|
|
727
|
+
runtimeUiConfig.server = { ...serverUrls, port: actualPort };
|
|
728
|
+
const { localUrl, lanUrl } = serverUrls;
|
|
729
|
+
if (!options.quiet) {
|
|
730
|
+
console.log(`[plan-server] listening at ${localUrl}`);
|
|
731
|
+
console.log(`[plan-server] ws endpoint: ws://127.0.0.1:${actualPort}/ws`);
|
|
732
|
+
if (lanUrl)
|
|
733
|
+
console.log(`[plan-server] lan url: ${lanUrl}`);
|
|
734
|
+
}
|
|
735
|
+
startWatcher(planRoot, hubRef);
|
|
736
|
+
resolve({
|
|
737
|
+
url: localUrl,
|
|
738
|
+
localUrl,
|
|
739
|
+
lanUrl,
|
|
740
|
+
mode: serverUrls.mode,
|
|
741
|
+
bindHost: serverUrls.bindHost,
|
|
742
|
+
hub,
|
|
743
|
+
close: async () => {
|
|
744
|
+
stopWatcher();
|
|
745
|
+
hub.close();
|
|
746
|
+
// Force-close any lingering sockets (browser WebSocket / keep-alive),
|
|
747
|
+
// otherwise server.close() hangs until the kernel times them out.
|
|
748
|
+
const anyServer = server;
|
|
749
|
+
anyServer.closeAllConnections?.();
|
|
750
|
+
await new Promise((r) => anyServer.close(() => r()));
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
}
|
package/dist/ws-hub.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Hub — bidirectional real-time sync for plan changes.
|
|
3
|
+
*
|
|
4
|
+
* After the HTTP server starts, attach a WsHub to it.
|
|
5
|
+
* All plan mutations broadcast events so connected clients stay in sync.
|
|
6
|
+
*/
|
|
7
|
+
import type { Server } from "node:http";
|
|
8
|
+
export type WsEvent = {
|
|
9
|
+
type: "file-changed";
|
|
10
|
+
data: {
|
|
11
|
+
filename: string;
|
|
12
|
+
};
|
|
13
|
+
} | {
|
|
14
|
+
type: "project-updated";
|
|
15
|
+
data: unknown;
|
|
16
|
+
} | {
|
|
17
|
+
type: "requirements-updated";
|
|
18
|
+
data: unknown;
|
|
19
|
+
} | {
|
|
20
|
+
type: "features-updated";
|
|
21
|
+
data: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
type: "phases-updated";
|
|
24
|
+
data: unknown;
|
|
25
|
+
} | {
|
|
26
|
+
type: "plan-rendered";
|
|
27
|
+
data: unknown;
|
|
28
|
+
};
|
|
29
|
+
export declare class WsHub {
|
|
30
|
+
private wss;
|
|
31
|
+
private clients;
|
|
32
|
+
private quiet;
|
|
33
|
+
constructor(server: Server, quiet?: boolean, onError?: (err: Error) => void);
|
|
34
|
+
broadcast(event: WsEvent): void;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=ws-hub.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws-hub.d.ts","sourceRoot":"","sources":["../src/ws-hub.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,MAAM,MAAM,OAAO,GACf;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,sBAAsB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,qBAAa,KAAK;IAChB,OAAO,CAAC,GAAG,CAAkB;IAC7B,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,KAAK,CAAU;gBAEX,MAAM,EAAE,MAAM,EAAE,KAAK,UAAQ,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI;IA0CzE,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI;IAa/B,KAAK,IAAI,IAAI;CASd"}
|
package/dist/ws-hub.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Hub — bidirectional real-time sync for plan changes.
|
|
3
|
+
*
|
|
4
|
+
* After the HTTP server starts, attach a WsHub to it.
|
|
5
|
+
* All plan mutations broadcast events so connected clients stay in sync.
|
|
6
|
+
*/
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
8
|
+
export class WsHub {
|
|
9
|
+
wss;
|
|
10
|
+
clients = new Set();
|
|
11
|
+
quiet;
|
|
12
|
+
constructor(server, quiet = false, onError) {
|
|
13
|
+
this.quiet = quiet;
|
|
14
|
+
this.wss = new WebSocketServer({ server, path: "/ws" });
|
|
15
|
+
// Critical: if the underlying HTTP server fails to listen (e.g. EADDRINUSE),
|
|
16
|
+
// the WebSocketServer also emits 'error'. Without a handler this becomes
|
|
17
|
+
// an uncaughtException that crashes the host process. Forward to caller.
|
|
18
|
+
this.wss.on("error", (err) => {
|
|
19
|
+
if (onError)
|
|
20
|
+
onError(err);
|
|
21
|
+
else if (!this.quiet)
|
|
22
|
+
console.error("[ws] server error:", err);
|
|
23
|
+
});
|
|
24
|
+
this.wss.on("connection", (ws) => {
|
|
25
|
+
this.clients.add(ws);
|
|
26
|
+
if (!this.quiet)
|
|
27
|
+
console.log(`[ws] client connected (${this.clients.size} total)`);
|
|
28
|
+
// Send initial connected event
|
|
29
|
+
ws.send(JSON.stringify({ type: "connected", data: "ok" }));
|
|
30
|
+
ws.on("message", (raw) => {
|
|
31
|
+
try {
|
|
32
|
+
const msg = JSON.parse(raw.toString());
|
|
33
|
+
if (msg.type === "ping") {
|
|
34
|
+
ws.send(JSON.stringify({ type: "pong", data: "" }));
|
|
35
|
+
}
|
|
36
|
+
// Future: handle client requests like "refresh" or "subscribe"
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// ignore malformed messages
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
ws.on("close", () => {
|
|
43
|
+
this.clients.delete(ws);
|
|
44
|
+
if (!this.quiet)
|
|
45
|
+
console.log(`[ws] client disconnected (${this.clients.size} left)`);
|
|
46
|
+
});
|
|
47
|
+
ws.on("error", () => {
|
|
48
|
+
this.clients.delete(ws);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
broadcast(event) {
|
|
53
|
+
const msg = JSON.stringify(event);
|
|
54
|
+
for (const client of this.clients) {
|
|
55
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
56
|
+
try {
|
|
57
|
+
client.send(msg);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
this.clients.delete(client);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
close() {
|
|
66
|
+
// Terminate every open socket first (browser keeps WebSocket alive, which
|
|
67
|
+
// would otherwise block the HTTP server from closing and stall process exit).
|
|
68
|
+
for (const client of this.clients) {
|
|
69
|
+
try {
|
|
70
|
+
client.terminate();
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
}
|
|
74
|
+
this.clients.clear();
|
|
75
|
+
this.wss.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-plan/server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Local HTTP/WebSocket server for Agent Plan dashboards and planner APIs.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js",
|
|
12
|
+
"./serve": "./dist/serve.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/**/*.js",
|
|
16
|
+
"dist/**/*.d.ts",
|
|
17
|
+
"dist/**/*.d.ts.map",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/ovidius72/agent-planner.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/ovidius72/agent-planner#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/ovidius72/agent-planner/issues"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"planner",
|
|
34
|
+
"server",
|
|
35
|
+
"websocket",
|
|
36
|
+
"dashboard",
|
|
37
|
+
"ai-agent"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"hono": "^4.7.0",
|
|
41
|
+
"ws": "^8.18.0",
|
|
42
|
+
"@agent-plan/core": "0.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@hono/node-server": "^1.14.0",
|
|
46
|
+
"@types/ws": "^8.18.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.json",
|
|
50
|
+
"check": "tsc -p tsconfig.json --noEmit"
|
|
51
|
+
}
|
|
52
|
+
}
|