@agent-plan/server 0.4.0 → 0.4.2
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/dist/api-v1.d.ts +15 -0
- package/dist/api-v1.d.ts.map +1 -0
- package/dist/api-v1.js +181 -0
- package/dist/entity-events.d.ts +65 -0
- package/dist/entity-events.d.ts.map +1 -0
- package/dist/entity-events.js +133 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/serve.d.ts +5 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +50 -19
- package/dist/server-record.d.ts +44 -0
- package/dist/server-record.d.ts.map +1 -0
- package/dist/server-record.js +97 -0
- package/dist/ws-hub.d.ts +4 -0
- package/dist/ws-hub.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/api-v1.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only API v1 for programs outside Agent Plan (docs/api-v1.md).
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately separate from the web UI's own routes in serve.ts: those
|
|
5
|
+
* change whenever the UI does, these are promised not to. Every reply is built
|
|
6
|
+
* from the plan-core contract mappers (never from raw stored objects) and is
|
|
7
|
+
* wrapped in `{ apiVersion, data }` or `{ apiVersion, error }`.
|
|
8
|
+
*/
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { type PlanStore } from "@agent-plan/core";
|
|
11
|
+
/** Mount point, fixed: the version is part of the URL so a future v2 can live beside it. */
|
|
12
|
+
export declare const API_V1_BASE_PATH = "/api/v1";
|
|
13
|
+
/** Routes are relative: mount the result at API_V1_BASE_PATH. */
|
|
14
|
+
export declare function createApiV1App(store: PlanStore, isBusy?: () => boolean): Hono;
|
|
15
|
+
//# sourceMappingURL=api-v1.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-v1.d.ts","sourceRoot":"","sources":["../src/api-v1.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,IAAI,EAAgB,MAAM,MAAM,CAAC;AAC1C,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAE1B,4FAA4F;AAC5F,eAAO,MAAM,gBAAgB,YAAY,CAAC;AAgE1C,iEAAiE;AACjE,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,OAAO,GAAG,IAAI,CAyH7E"}
|
package/dist/api-v1.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only API v1 for programs outside Agent Plan (docs/api-v1.md).
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately separate from the web UI's own routes in serve.ts: those
|
|
5
|
+
* change whenever the UI does, these are promised not to. Every reply is built
|
|
6
|
+
* from the plan-core contract mappers (never from raw stored objects) and is
|
|
7
|
+
* wrapped in `{ apiVersion, data }` or `{ apiVersion, error }`.
|
|
8
|
+
*/
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { API_V1_VERSION, PlanStoreError, PlanWriterBusyError, apiV1Envelope, apiV1Error, buildApiV1Model, } from "@agent-plan/core";
|
|
11
|
+
/** Mount point, fixed: the version is part of the URL so a future v2 can live beside it. */
|
|
12
|
+
export const API_V1_BASE_PATH = "/api/v1";
|
|
13
|
+
/** Fields dropped by `?compact=true` so long lists stay small. */
|
|
14
|
+
const COMPACT_FIELDS = ["description", "content", "guidelines", "decision", "rationale", "implementationNotes"];
|
|
15
|
+
async function loadModel(store) {
|
|
16
|
+
const [project, features, phases, ideas] = await Promise.all([
|
|
17
|
+
store.loadProject(),
|
|
18
|
+
store.loadFeatures(),
|
|
19
|
+
store.loadAllPhases(),
|
|
20
|
+
store.loadIdeas(),
|
|
21
|
+
]);
|
|
22
|
+
return buildApiV1Model({ project, features: features.features, phases, ideas: ideas.ideas });
|
|
23
|
+
}
|
|
24
|
+
/** `P002(F001)` -> `P002`; `P002(F001)/T005` -> `P002/T005`. */
|
|
25
|
+
function withoutFeature(ref) {
|
|
26
|
+
return ref.replace(/\(F\d+\)/i, "");
|
|
27
|
+
}
|
|
28
|
+
/** Exact lookup by id, composite ref, short id, or the ref without its feature. No fuzzy title matching: a stable API never guesses. */
|
|
29
|
+
function findByRef(items, rawRef) {
|
|
30
|
+
const ref = rawRef.trim().toLowerCase();
|
|
31
|
+
if (!ref)
|
|
32
|
+
return undefined;
|
|
33
|
+
const isTaskSuffix = /^t\d+$/.test(ref);
|
|
34
|
+
return items.find((item) => {
|
|
35
|
+
const itemRef = item.ref.toLowerCase();
|
|
36
|
+
return item.id.toLowerCase() === ref
|
|
37
|
+
|| itemRef === ref
|
|
38
|
+
|| withoutFeature(itemRef) === ref
|
|
39
|
+
|| (item.shortId ? item.shortId.toLowerCase() === ref : false)
|
|
40
|
+
// A task number is unique across the project, so `T005` alone is unambiguous.
|
|
41
|
+
|| (isTaskSuffix && itemRef.endsWith(`/${ref}`));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function compact(view) {
|
|
45
|
+
const copy = { ...view };
|
|
46
|
+
for (const field of COMPACT_FIELDS)
|
|
47
|
+
if (field in copy)
|
|
48
|
+
delete copy[field];
|
|
49
|
+
return copy;
|
|
50
|
+
}
|
|
51
|
+
function maybeCompact(c, items) {
|
|
52
|
+
return c.req.query("compact") === "true" ? items.map(compact) : items;
|
|
53
|
+
}
|
|
54
|
+
/** The path after the mount point, decoded, so refs like `P002(F001)/T005` (which contain `/`) work. */
|
|
55
|
+
function refFromPath(c, collection) {
|
|
56
|
+
const path = c.req.path;
|
|
57
|
+
const start = path.indexOf(`${API_V1_BASE_PATH}/${collection}/`);
|
|
58
|
+
const raw = start === -1 ? "" : path.slice(start + API_V1_BASE_PATH.length + collection.length + 2);
|
|
59
|
+
try {
|
|
60
|
+
return decodeURIComponent(raw);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return raw;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Routes are relative: mount the result at API_V1_BASE_PATH. */
|
|
67
|
+
export function createApiV1App(store, isBusy) {
|
|
68
|
+
const app = new Hono();
|
|
69
|
+
const notFound = (c, what, ref) => c.json(apiV1Error("NOT_FOUND", `${what} not found: ${ref}`), 404);
|
|
70
|
+
app.onError((err, c) => {
|
|
71
|
+
if (err instanceof PlanWriterBusyError)
|
|
72
|
+
return c.json(apiV1Error("PLAN_BUSY", err.message), 503);
|
|
73
|
+
if (err instanceof PlanStoreError || /ENOENT|read failed/i.test(err.message)) {
|
|
74
|
+
return c.json(apiV1Error("NOT_FOUND", err.message), 404);
|
|
75
|
+
}
|
|
76
|
+
return c.json(apiV1Error("INTERNAL", err instanceof Error ? err.message : String(err)), 500);
|
|
77
|
+
});
|
|
78
|
+
app.use("*", async (c, next) => {
|
|
79
|
+
if (c.req.method !== "GET") {
|
|
80
|
+
c.header("Allow", "GET");
|
|
81
|
+
return c.json(apiV1Error("METHOD_NOT_ALLOWED", "API v1 is read-only. Use GET."), 405);
|
|
82
|
+
}
|
|
83
|
+
// While an agent is writing .planner/ files, a read could see a half-written state.
|
|
84
|
+
if (isBusy?.())
|
|
85
|
+
return c.json(apiV1Error("PLAN_BUSY", "Plan files are being updated. Retry shortly."), 503);
|
|
86
|
+
await next();
|
|
87
|
+
});
|
|
88
|
+
app.get("/", (c) => c.json(apiV1Envelope({
|
|
89
|
+
apiVersion: API_V1_VERSION,
|
|
90
|
+
links: {
|
|
91
|
+
project: `${API_V1_BASE_PATH}/project`,
|
|
92
|
+
features: `${API_V1_BASE_PATH}/features`,
|
|
93
|
+
phases: `${API_V1_BASE_PATH}/phases`,
|
|
94
|
+
tasks: `${API_V1_BASE_PATH}/tasks`,
|
|
95
|
+
decisions: `${API_V1_BASE_PATH}/decisions`,
|
|
96
|
+
handoffs: `${API_V1_BASE_PATH}/handoffs`,
|
|
97
|
+
ideas: `${API_V1_BASE_PATH}/ideas`,
|
|
98
|
+
},
|
|
99
|
+
})));
|
|
100
|
+
app.get(`/project`, async (c) => c.json(apiV1Envelope((await loadModel(store)).project)));
|
|
101
|
+
app.get(`/features`, async (c) => {
|
|
102
|
+
const { features } = await loadModel(store);
|
|
103
|
+
const status = c.req.query("status");
|
|
104
|
+
return c.json(apiV1Envelope(maybeCompact(c, status ? features.filter((feature) => feature.status === status) : features)));
|
|
105
|
+
});
|
|
106
|
+
app.get(`/features/*`, async (c) => {
|
|
107
|
+
const ref = refFromPath(c, "features");
|
|
108
|
+
const feature = findByRef((await loadModel(store)).features, ref);
|
|
109
|
+
return feature ? c.json(apiV1Envelope(feature)) : notFound(c, "Feature", ref);
|
|
110
|
+
});
|
|
111
|
+
app.get(`/phases`, async (c) => {
|
|
112
|
+
const model = await loadModel(store);
|
|
113
|
+
let phases = model.phases;
|
|
114
|
+
const featureRef = c.req.query("feature");
|
|
115
|
+
if (featureRef) {
|
|
116
|
+
const feature = findByRef(model.features, featureRef);
|
|
117
|
+
if (!feature)
|
|
118
|
+
return notFound(c, "Feature", featureRef);
|
|
119
|
+
phases = phases.filter((phase) => phase.featureRef === feature.ref);
|
|
120
|
+
}
|
|
121
|
+
const status = c.req.query("status");
|
|
122
|
+
if (status)
|
|
123
|
+
phases = phases.filter((phase) => phase.status === status);
|
|
124
|
+
return c.json(apiV1Envelope(maybeCompact(c, phases)));
|
|
125
|
+
});
|
|
126
|
+
app.get(`/phases/*`, async (c) => {
|
|
127
|
+
const ref = refFromPath(c, "phases");
|
|
128
|
+
const phase = findByRef((await loadModel(store)).phases, ref);
|
|
129
|
+
return phase ? c.json(apiV1Envelope(phase)) : notFound(c, "Phase", ref);
|
|
130
|
+
});
|
|
131
|
+
app.get(`/tasks`, async (c) => {
|
|
132
|
+
const model = await loadModel(store);
|
|
133
|
+
let tasks = model.tasks;
|
|
134
|
+
const phaseRef = c.req.query("phase");
|
|
135
|
+
if (phaseRef) {
|
|
136
|
+
const phase = findByRef(model.phases, phaseRef);
|
|
137
|
+
if (!phase)
|
|
138
|
+
return notFound(c, "Phase", phaseRef);
|
|
139
|
+
tasks = tasks.filter((task) => task.phaseRef === phase.ref);
|
|
140
|
+
}
|
|
141
|
+
const status = c.req.query("status");
|
|
142
|
+
if (status)
|
|
143
|
+
tasks = tasks.filter((task) => task.status === status);
|
|
144
|
+
return c.json(apiV1Envelope(maybeCompact(c, tasks)));
|
|
145
|
+
});
|
|
146
|
+
app.get(`/tasks/*`, async (c) => {
|
|
147
|
+
const ref = refFromPath(c, "tasks");
|
|
148
|
+
const task = findByRef((await loadModel(store)).tasks, ref);
|
|
149
|
+
return task ? c.json(apiV1Envelope(task)) : notFound(c, "Task", ref);
|
|
150
|
+
});
|
|
151
|
+
app.get(`/decisions`, async (c) => {
|
|
152
|
+
const { decisions } = await loadModel(store);
|
|
153
|
+
const owner = c.req.query("owner");
|
|
154
|
+
const filtered = !owner
|
|
155
|
+
? decisions
|
|
156
|
+
: decisions.filter((decision) => owner.toLowerCase() === "project"
|
|
157
|
+
? decision.owner.kind === "project"
|
|
158
|
+
: decision.owner.ref !== null && (decision.owner.ref.toLowerCase() === owner.toLowerCase()
|
|
159
|
+
|| withoutFeature(decision.owner.ref).toLowerCase() === owner.toLowerCase()));
|
|
160
|
+
return c.json(apiV1Envelope(maybeCompact(c, filtered)));
|
|
161
|
+
});
|
|
162
|
+
app.get(`/handoffs`, async (c) => {
|
|
163
|
+
const { handoffs } = await loadModel(store);
|
|
164
|
+
return c.json(apiV1Envelope(maybeCompact(c, handoffs)));
|
|
165
|
+
});
|
|
166
|
+
app.get(`/handoffs/*`, async (c) => {
|
|
167
|
+
const ref = refFromPath(c, "handoffs");
|
|
168
|
+
const { handoffs, phases } = await loadModel(store);
|
|
169
|
+
const phase = findByRef(phases, ref);
|
|
170
|
+
const handoff = phase ? handoffs.find((entry) => entry.phaseRef === phase.ref) : undefined;
|
|
171
|
+
return handoff ? c.json(apiV1Envelope(handoff)) : notFound(c, "Handoff", ref);
|
|
172
|
+
});
|
|
173
|
+
app.get(`/ideas`, async (c) => c.json(apiV1Envelope(maybeCompact(c, (await loadModel(store)).ideas))));
|
|
174
|
+
app.get(`/ideas/*`, async (c) => {
|
|
175
|
+
const ref = refFromPath(c, "ideas");
|
|
176
|
+
const idea = findByRef((await loadModel(store)).ideas, ref);
|
|
177
|
+
return idea ? c.json(apiV1Envelope(idea)) : notFound(c, "Idea", ref);
|
|
178
|
+
});
|
|
179
|
+
app.all("*", (c) => c.json(apiV1Error("NOT_FOUND", `No such API v1 route: ${c.req.path}`), 404));
|
|
180
|
+
return app;
|
|
181
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change detector for the `entity-changed` live event (docs/api-v1.md).
|
|
3
|
+
*
|
|
4
|
+
* Agents write to `.planner/` from other processes (the MCP server), so route
|
|
5
|
+
* handlers in this process never see those writes. Only the folder watcher and
|
|
6
|
+
* `/internal/notify` do, and they only say "something changed". This detector
|
|
7
|
+
* turns that signal into exact answers: it keeps a fingerprint of every API v1
|
|
8
|
+
* view, reloads the plan on each signal, and reports which entities were
|
|
9
|
+
* created, updated or deleted.
|
|
10
|
+
*/
|
|
11
|
+
import { API_V1_VERSION, type ApiV1EntityEntry, type ApiV1EntityKind, type PlanStore } from "@agent-plan/core";
|
|
12
|
+
export type EntityChangeOp = "created" | "updated" | "deleted";
|
|
13
|
+
export interface EntityChangedData {
|
|
14
|
+
apiVersion: typeof API_V1_VERSION;
|
|
15
|
+
kind: ApiV1EntityKind;
|
|
16
|
+
ref: string;
|
|
17
|
+
id: string;
|
|
18
|
+
op: EntityChangeOp;
|
|
19
|
+
parents: {
|
|
20
|
+
featureRef?: string;
|
|
21
|
+
phaseRef?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
interface Fingerprint {
|
|
25
|
+
kind: ApiV1EntityKind;
|
|
26
|
+
id: string;
|
|
27
|
+
ref: string;
|
|
28
|
+
parents: ApiV1EntityEntry["parents"];
|
|
29
|
+
hash: string;
|
|
30
|
+
}
|
|
31
|
+
export type EntitySnapshot = ReadonlyMap<string, Fingerprint>;
|
|
32
|
+
export declare function snapshotOf(entries: readonly ApiV1EntityEntry[]): EntitySnapshot;
|
|
33
|
+
/** Exactly what differs between two snapshots, one change per entity. */
|
|
34
|
+
export declare function diffSnapshots(previous: EntitySnapshot, next: EntitySnapshot): EntityChangedData[];
|
|
35
|
+
export interface EntityEventDetectorOptions {
|
|
36
|
+
/** Wait this long after the last signal before reloading, so one burst of writes is one pass. */
|
|
37
|
+
debounceMs?: number;
|
|
38
|
+
/** Wait before retrying when the plan could not be loaded (for example mid-write). */
|
|
39
|
+
retryMs?: number;
|
|
40
|
+
maxRetries?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare class EntityEventDetector {
|
|
43
|
+
private readonly store;
|
|
44
|
+
private readonly emit;
|
|
45
|
+
private snapshot;
|
|
46
|
+
private timer;
|
|
47
|
+
private running;
|
|
48
|
+
private dirty;
|
|
49
|
+
private retries;
|
|
50
|
+
private closed;
|
|
51
|
+
private readonly debounceMs;
|
|
52
|
+
private readonly retryMs;
|
|
53
|
+
private readonly maxRetries;
|
|
54
|
+
constructor(store: PlanStore, emit: (change: EntityChangedData) => void, options?: EntityEventDetectorOptions);
|
|
55
|
+
private currentSnapshot;
|
|
56
|
+
/** Record the starting state. If it cannot be read yet, the first successful pass only records and never reports. */
|
|
57
|
+
prime(): Promise<void>;
|
|
58
|
+
/** Something in `.planner/` may have changed. */
|
|
59
|
+
signal(): void;
|
|
60
|
+
private schedule;
|
|
61
|
+
private run;
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=entity-events.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-events.d.ts","sourceRoot":"","sources":["../src/entity-events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EACL,cAAc,EAGd,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAE1B,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,OAAO,cAAc,CAAC;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,cAAc,CAAC;IACnB,OAAO,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACrD;AAED,UAAU,WAAW;IACnB,IAAI,EAAE,eAAe,CAAC;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAE9D,wBAAgB,UAAU,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,cAAc,CAY/E;AAMD,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,EAAE,cAAc,GAAG,iBAAiB,EAAE,CAWjG;AAED,MAAM,WAAW,0BAA0B;IACzC,iGAAiG;IACjG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sFAAsF;IACtF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,mBAAmB;IAY5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAZvB,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAGjB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,EAC1D,OAAO,GAAE,0BAA+B;YAO5B,eAAe;IAU7B,qHAAqH;IAC/G,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B,iDAAiD;IACjD,MAAM,IAAI,IAAI;IAMd,OAAO,CAAC,QAAQ;YAQF,GAAG;IA2BjB,KAAK,IAAI,IAAI;CAKd"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change detector for the `entity-changed` live event (docs/api-v1.md).
|
|
3
|
+
*
|
|
4
|
+
* Agents write to `.planner/` from other processes (the MCP server), so route
|
|
5
|
+
* handlers in this process never see those writes. Only the folder watcher and
|
|
6
|
+
* `/internal/notify` do, and they only say "something changed". This detector
|
|
7
|
+
* turns that signal into exact answers: it keeps a fingerprint of every API v1
|
|
8
|
+
* view, reloads the plan on each signal, and reports which entities were
|
|
9
|
+
* created, updated or deleted.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { API_V1_VERSION, buildApiV1Model, listApiV1Entities, } from "@agent-plan/core";
|
|
13
|
+
export function snapshotOf(entries) {
|
|
14
|
+
const snapshot = new Map();
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
snapshot.set(`${entry.kind}:${entry.key}`, {
|
|
17
|
+
kind: entry.kind,
|
|
18
|
+
id: entry.id,
|
|
19
|
+
ref: entry.ref,
|
|
20
|
+
parents: entry.parents,
|
|
21
|
+
hash: createHash("sha1").update(JSON.stringify(entry.view)).digest("hex"),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return snapshot;
|
|
25
|
+
}
|
|
26
|
+
function changeOf(fingerprint, op) {
|
|
27
|
+
return { apiVersion: API_V1_VERSION, kind: fingerprint.kind, ref: fingerprint.ref, id: fingerprint.id, op, parents: fingerprint.parents };
|
|
28
|
+
}
|
|
29
|
+
/** Exactly what differs between two snapshots, one change per entity. */
|
|
30
|
+
export function diffSnapshots(previous, next) {
|
|
31
|
+
const changes = [];
|
|
32
|
+
for (const [key, fingerprint] of next) {
|
|
33
|
+
const before = previous.get(key);
|
|
34
|
+
if (!before)
|
|
35
|
+
changes.push(changeOf(fingerprint, "created"));
|
|
36
|
+
else if (before.hash !== fingerprint.hash)
|
|
37
|
+
changes.push(changeOf(fingerprint, "updated"));
|
|
38
|
+
}
|
|
39
|
+
for (const [key, fingerprint] of previous) {
|
|
40
|
+
if (!next.has(key))
|
|
41
|
+
changes.push(changeOf(fingerprint, "deleted"));
|
|
42
|
+
}
|
|
43
|
+
return changes;
|
|
44
|
+
}
|
|
45
|
+
export class EntityEventDetector {
|
|
46
|
+
store;
|
|
47
|
+
emit;
|
|
48
|
+
snapshot = null;
|
|
49
|
+
timer = null;
|
|
50
|
+
running = false;
|
|
51
|
+
dirty = false;
|
|
52
|
+
retries = 0;
|
|
53
|
+
closed = false;
|
|
54
|
+
debounceMs;
|
|
55
|
+
retryMs;
|
|
56
|
+
maxRetries;
|
|
57
|
+
constructor(store, emit, options = {}) {
|
|
58
|
+
this.store = store;
|
|
59
|
+
this.emit = emit;
|
|
60
|
+
this.debounceMs = options.debounceMs ?? 100;
|
|
61
|
+
this.retryMs = options.retryMs ?? 300;
|
|
62
|
+
this.maxRetries = options.maxRetries ?? 10;
|
|
63
|
+
}
|
|
64
|
+
async currentSnapshot() {
|
|
65
|
+
const [project, features, phases, ideas] = await Promise.all([
|
|
66
|
+
this.store.loadProject(),
|
|
67
|
+
this.store.loadFeatures(),
|
|
68
|
+
this.store.loadAllPhases(),
|
|
69
|
+
this.store.loadIdeas(),
|
|
70
|
+
]);
|
|
71
|
+
return snapshotOf(listApiV1Entities(buildApiV1Model({ project, features: features.features, phases, ideas: ideas.ideas })));
|
|
72
|
+
}
|
|
73
|
+
/** Record the starting state. If it cannot be read yet, the first successful pass only records and never reports. */
|
|
74
|
+
async prime() {
|
|
75
|
+
try {
|
|
76
|
+
this.snapshot = await this.currentSnapshot();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
this.snapshot = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Something in `.planner/` may have changed. */
|
|
83
|
+
signal() {
|
|
84
|
+
if (this.closed)
|
|
85
|
+
return;
|
|
86
|
+
this.retries = 0;
|
|
87
|
+
this.schedule(this.debounceMs);
|
|
88
|
+
}
|
|
89
|
+
schedule(delayMs) {
|
|
90
|
+
if (this.timer)
|
|
91
|
+
clearTimeout(this.timer);
|
|
92
|
+
this.timer = setTimeout(() => {
|
|
93
|
+
this.timer = null;
|
|
94
|
+
void this.run();
|
|
95
|
+
}, delayMs);
|
|
96
|
+
}
|
|
97
|
+
async run() {
|
|
98
|
+
if (this.running) {
|
|
99
|
+
this.dirty = true;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.running = true;
|
|
103
|
+
try {
|
|
104
|
+
const next = await this.currentSnapshot();
|
|
105
|
+
const previous = this.snapshot;
|
|
106
|
+
this.snapshot = next;
|
|
107
|
+
if (previous)
|
|
108
|
+
for (const change of diffSnapshots(previous, next))
|
|
109
|
+
this.emit(change);
|
|
110
|
+
this.retries = 0;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A writer is mid-update. Try again shortly instead of waiting for another signal.
|
|
114
|
+
if (!this.closed && this.retries < this.maxRetries) {
|
|
115
|
+
this.retries += 1;
|
|
116
|
+
this.schedule(this.retryMs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
this.running = false;
|
|
121
|
+
if (this.dirty && !this.closed) {
|
|
122
|
+
this.dirty = false;
|
|
123
|
+
this.schedule(this.debounceMs);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
close() {
|
|
128
|
+
this.closed = true;
|
|
129
|
+
if (this.timer)
|
|
130
|
+
clearTimeout(this.timer);
|
|
131
|
+
this.timer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,6 @@ export { serve } from "./serve.js";
|
|
|
2
2
|
export { WsHub } from "./ws-hub.js";
|
|
3
3
|
export type { ServeOptions, ServeHandle, UiConfig, ShortcutConfigSpec } from "./serve.js";
|
|
4
4
|
export type { WsEvent } from "./ws-hub.js";
|
|
5
|
+
export { readLiveServerRecord, serverRecordPath } from "./server-record.js";
|
|
6
|
+
export type { ServerRecord, ServerKind } from "./server-record.js";
|
|
5
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +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"}
|
|
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;AAC3C,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/serve.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WsHub } from "./ws-hub.js";
|
|
2
|
+
import { type ServerKind } from "./server-record.js";
|
|
2
3
|
export interface ShortcutConfigSpec {
|
|
3
4
|
key: string;
|
|
4
5
|
primary?: boolean;
|
|
@@ -26,6 +27,10 @@ export interface ServeOptions {
|
|
|
26
27
|
quiet?: boolean;
|
|
27
28
|
uiConfig?: UiConfig | undefined;
|
|
28
29
|
isBusy?: (() => boolean) | undefined;
|
|
30
|
+
/** Write `.planner/.local/server.json` so other programs can find this server. Default true. */
|
|
31
|
+
recordServer?: boolean;
|
|
32
|
+
/** `standalone` only for `agent-plan serve`, where the process exists just to run this server. Default `embedded`. */
|
|
33
|
+
serverKind?: ServerKind;
|
|
29
34
|
}
|
|
30
35
|
export interface ServeHandle {
|
|
31
36
|
url: string;
|
package/dist/serve.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAGpC,OAAO,EAAyC,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAmJ5F,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;AAw7CD,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;IACrC,gGAAgG;IAChG,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,sHAAsH;IACtH,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB;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,CAmGvE"}
|
package/dist/serve.js
CHANGED
|
@@ -7,10 +7,12 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { createAdaptorServer } from "@hono/node-server";
|
|
8
8
|
import { Hono } from "hono";
|
|
9
9
|
import { cors } from "hono/cors";
|
|
10
|
-
import { ExportService, PlanStore, PlanStoreError, PlanStaleWriteError, PlanWriterBusyError, createFeatureId, createPhaseId, createChecklistItemId, createRequirementId, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask, recommendNextWork, reconcileRequirementMacroTasks, RequirementMacroTaskError, packageVersionFromModule, ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE, ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, deleteFeatureCascade, nowISO, applyTaskLifecycleDates, resolveAcceptedDecisionTarget } from "@agent-plan/core";
|
|
10
|
+
import { API_V1_VERSION, ExportService, PlanStore, PlanStoreError, PlanStaleWriteError, PlanWriterBusyError, createFeatureId, createPhaseId, createChecklistItemId, createRequirementId, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask, recommendNextWork, reconcileRequirementMacroTasks, RequirementMacroTaskError, packageVersionFromModule, ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE, ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, deleteFeatureCascade, nowISO, applyTaskLifecycleDates, resolveAcceptedDecisionTarget } from "@agent-plan/core";
|
|
11
11
|
import { WsHub } from "./ws-hub.js";
|
|
12
|
+
import { API_V1_BASE_PATH, createApiV1App } from "./api-v1.js";
|
|
13
|
+
import { EntityEventDetector } from "./entity-events.js";
|
|
14
|
+
import { removeServerRecord, writeServerRecord } from "./server-record.js";
|
|
12
15
|
// ─── Watcher ────────────────────────────────────────────────────────────
|
|
13
|
-
let watcherAbort = null;
|
|
14
16
|
const SERVER_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/server");
|
|
15
17
|
/** Resolve requirement phase refs before persistence so requirements never dangle. */
|
|
16
18
|
async function resolveRequirementPhaseIds(store, linkedPhaseIds) {
|
|
@@ -112,16 +114,16 @@ function phaseGovernanceReady(phase) {
|
|
|
112
114
|
function fromWebUi(c) {
|
|
113
115
|
return c.req.header("X-Planner-Source") === "web-ui";
|
|
114
116
|
}
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
+
/** Watch the planner folder. Returns a function that stops this watcher only, so several servers in one process never stop each other's. */
|
|
118
|
+
function startWatcher(planRoot, hubRef, onChange) {
|
|
117
119
|
if (!existsSync(planRoot))
|
|
118
|
-
return;
|
|
120
|
+
return () => { };
|
|
119
121
|
const ac = new AbortController();
|
|
120
|
-
watcherAbort = ac;
|
|
121
122
|
try {
|
|
122
123
|
const watcher = watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
|
|
123
124
|
if (filename && !filename.includes(".tmp.")) {
|
|
124
125
|
hubRef.current?.broadcast({ type: "file-changed", data: { filename } });
|
|
126
|
+
onChange();
|
|
125
127
|
}
|
|
126
128
|
});
|
|
127
129
|
watcher.on("error", () => {
|
|
@@ -132,12 +134,9 @@ function startWatcher(planRoot, hubRef) {
|
|
|
132
134
|
catch {
|
|
133
135
|
// recursive watch may fail on some systems
|
|
134
136
|
}
|
|
137
|
+
return () => ac.abort();
|
|
135
138
|
}
|
|
136
|
-
function
|
|
137
|
-
watcherAbort?.abort();
|
|
138
|
-
watcherAbort = null;
|
|
139
|
-
}
|
|
140
|
-
function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
|
|
139
|
+
function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy, onExternalChange) {
|
|
141
140
|
const hub = () => hubRef.current;
|
|
142
141
|
const route = (path) => `${apiPrefix}${path}`;
|
|
143
142
|
const app = new Hono();
|
|
@@ -171,8 +170,12 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
|
|
|
171
170
|
app.post(route("/internal/notify"), (c) => {
|
|
172
171
|
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
173
172
|
hub()?.broadcast({ type: "file-changed", data: { filename: "external-notify" } });
|
|
173
|
+
onExternalChange?.();
|
|
174
174
|
return c.json({ ok: true });
|
|
175
175
|
});
|
|
176
|
+
// The outside API has its own versioned contract and its own busy/error replies,
|
|
177
|
+
// so it is mounted before the web UI's busy middleware below.
|
|
178
|
+
app.route(API_V1_BASE_PATH, createApiV1App(store, isBusy));
|
|
176
179
|
// While the agent is mutating .planner/ files, avoid serving possibly-inconsistent data.
|
|
177
180
|
// GET requests get a 503 busy signal the UI retries; mutations are rejected.
|
|
178
181
|
app.use("*", async (c, next) => {
|
|
@@ -1572,13 +1575,18 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
|
|
|
1572
1575
|
return c.json({ cleared: true });
|
|
1573
1576
|
});
|
|
1574
1577
|
// ── Health ───────────────────────────────────────────────────────
|
|
1575
|
-
|
|
1578
|
+
const health = (c) => c.json({ status: "ok", root: store.root, apiVersion: API_V1_VERSION, pid: process.pid, version: SERVER_PACKAGE.version });
|
|
1579
|
+
app.get(route("/health"), health);
|
|
1580
|
+
// With the web UI bundled the API sits under /api and unknown paths fall through to the UI,
|
|
1581
|
+
// so a program probing the server must also find /health at the root.
|
|
1582
|
+
if (apiPrefix)
|
|
1583
|
+
app.get("/health", health);
|
|
1576
1584
|
app.get(route("/ui-config"), (c) => c.json(uiConfig ?? {}));
|
|
1577
1585
|
return app;
|
|
1578
1586
|
}
|
|
1579
1587
|
// ─── Serve static files (SPA) ────────────────────────────────────────────
|
|
1580
|
-
function createSpaApp(store, hubRef, staticDir, uiConfig, isBusy) {
|
|
1581
|
-
const app = createApiApp(store, hubRef, staticDir ? "/api" : "", uiConfig, isBusy);
|
|
1588
|
+
function createSpaApp(store, hubRef, staticDir, uiConfig, isBusy, onExternalChange) {
|
|
1589
|
+
const app = createApiApp(store, hubRef, staticDir ? "/api" : "", uiConfig, isBusy, onExternalChange);
|
|
1582
1590
|
if (!staticDir) {
|
|
1583
1591
|
return app;
|
|
1584
1592
|
}
|
|
@@ -1655,7 +1663,7 @@ function resolveBundledStaticDir() {
|
|
|
1655
1663
|
}
|
|
1656
1664
|
}
|
|
1657
1665
|
export async function serve(options) {
|
|
1658
|
-
const { port = 3030, planRoot, host = "127.0.0.1" } = options;
|
|
1666
|
+
const { port = 3030, planRoot, host = "127.0.0.1", recordServer = true, serverKind = "embedded" } = options;
|
|
1659
1667
|
const store = new PlanStore(planRoot);
|
|
1660
1668
|
const runtimeUiConfig = { ...(options.uiConfig ?? {}) };
|
|
1661
1669
|
if (!(await store.exists())) {
|
|
@@ -1668,7 +1676,10 @@ export async function serve(options) {
|
|
|
1668
1676
|
// Default to the bundled web UI when the caller doesn't pass staticDir. Pass
|
|
1669
1677
|
// an empty string ("") to force API-only even when the bundle is present.
|
|
1670
1678
|
const staticDir = options.staticDir ?? resolveBundledStaticDir();
|
|
1671
|
-
|
|
1679
|
+
// Detects exactly which entities changed, including writes by other processes (MCP).
|
|
1680
|
+
const detector = new EntityEventDetector(store, (change) => hubRef.current?.broadcast({ type: "entity-changed", data: change }));
|
|
1681
|
+
await detector.prime();
|
|
1682
|
+
const app = createSpaApp(store, hubRef, staticDir, runtimeUiConfig, options.isBusy, () => detector.signal());
|
|
1672
1683
|
return new Promise((resolve, reject) => {
|
|
1673
1684
|
// Create the Node HTTP server without listening yet
|
|
1674
1685
|
const server = createAdaptorServer({ fetch: app.fetch, hostname: host });
|
|
@@ -1701,8 +1712,8 @@ export async function serve(options) {
|
|
|
1701
1712
|
if (lanUrl)
|
|
1702
1713
|
console.log(`[plan-server] lan url: ${lanUrl}`);
|
|
1703
1714
|
}
|
|
1704
|
-
startWatcher(planRoot, hubRef);
|
|
1705
|
-
|
|
1715
|
+
const stopWatcher = startWatcher(planRoot, hubRef, () => detector.signal());
|
|
1716
|
+
const handle = {
|
|
1706
1717
|
url: localUrl,
|
|
1707
1718
|
localUrl,
|
|
1708
1719
|
lanUrl,
|
|
@@ -1711,6 +1722,9 @@ export async function serve(options) {
|
|
|
1711
1722
|
hub,
|
|
1712
1723
|
close: async () => {
|
|
1713
1724
|
stopWatcher();
|
|
1725
|
+
detector.close();
|
|
1726
|
+
if (recordServer)
|
|
1727
|
+
await removeServerRecord(store.root, { pid: process.pid, localUrl });
|
|
1714
1728
|
hub.close();
|
|
1715
1729
|
// Force-close any lingering sockets (browser WebSocket / keep-alive),
|
|
1716
1730
|
// otherwise server.close() hangs until the kernel times them out.
|
|
@@ -1718,7 +1732,24 @@ export async function serve(options) {
|
|
|
1718
1732
|
anyServer.closeAllConnections?.();
|
|
1719
1733
|
await new Promise((r) => anyServer.close(() => r()));
|
|
1720
1734
|
},
|
|
1721
|
-
}
|
|
1735
|
+
};
|
|
1736
|
+
if (!recordServer) {
|
|
1737
|
+
resolve(handle);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
void writeServerRecord(store.root, {
|
|
1741
|
+
kind: serverKind,
|
|
1742
|
+
pid: process.pid,
|
|
1743
|
+
url: handle.url,
|
|
1744
|
+
localUrl: handle.localUrl,
|
|
1745
|
+
lanUrl: handle.lanUrl ?? null,
|
|
1746
|
+
host: handle.bindHost,
|
|
1747
|
+
port: actualPort,
|
|
1748
|
+
root: store.root,
|
|
1749
|
+
startedAt: new Date().toISOString(),
|
|
1750
|
+
version: SERVER_PACKAGE.version,
|
|
1751
|
+
apiVersion: API_V1_VERSION,
|
|
1752
|
+
}).finally(() => resolve(handle));
|
|
1722
1753
|
});
|
|
1723
1754
|
});
|
|
1724
1755
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record of a running planner server: `<planner folder>/.local/server.json`.
|
|
3
|
+
*
|
|
4
|
+
* Several things start a server (`agent-plan serve`, the MCP `planner-web` and
|
|
5
|
+
* `planner-load`, the Pi adapter) and until now nothing said that one was
|
|
6
|
+
* already running for a folder. serve() writes this record, so every starter
|
|
7
|
+
* gets it, and a program that wants to reuse a server reads it with
|
|
8
|
+
* readLiveServerRecord(). The first live server owns the record.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* How the server was started. `standalone` is `agent-plan serve`: the whole
|
|
12
|
+
* process is the server, so it is safe to stop by signalling its pid.
|
|
13
|
+
* `embedded` is a server inside another program (an agent's MCP server, the Pi
|
|
14
|
+
* adapter): signalling that pid would kill the host, so only its owner stops it.
|
|
15
|
+
*/
|
|
16
|
+
export type ServerKind = "standalone" | "embedded";
|
|
17
|
+
export interface ServerRecord {
|
|
18
|
+
kind: ServerKind;
|
|
19
|
+
pid: number;
|
|
20
|
+
url: string;
|
|
21
|
+
localUrl: string;
|
|
22
|
+
lanUrl: string | null;
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
root: string;
|
|
26
|
+
startedAt: string;
|
|
27
|
+
version: string;
|
|
28
|
+
apiVersion: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function serverRecordPath(planRoot: string): string;
|
|
31
|
+
/** The record of a server that is really running for this folder, or null. A stale record is deleted. */
|
|
32
|
+
export declare function readLiveServerRecord(planRoot: string): Promise<ServerRecord | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Write the record unless another live server already owns it. Never throws:
|
|
35
|
+
* a folder that cannot be written must not stop the server from running.
|
|
36
|
+
* Returns true when this server now owns the record.
|
|
37
|
+
*/
|
|
38
|
+
export declare function writeServerRecord(planRoot: string, record: ServerRecord): Promise<boolean>;
|
|
39
|
+
/** Remove the record, but only when it describes this very server (pid and address). Never throws. */
|
|
40
|
+
export declare function removeServerRecord(planRoot: string, owner: {
|
|
41
|
+
pid: number;
|
|
42
|
+
localUrl: string;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
//# sourceMappingURL=server-record.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-record.d.ts","sourceRoot":"","sources":["../src/server-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,UAAU,CAAC;AAEnD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAuCD,yGAAyG;AACzG,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAMzF;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CAchG;AAED,sGAAsG;AACtG,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAOlH"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record of a running planner server: `<planner folder>/.local/server.json`.
|
|
3
|
+
*
|
|
4
|
+
* Several things start a server (`agent-plan serve`, the MCP `planner-web` and
|
|
5
|
+
* `planner-load`, the Pi adapter) and until now nothing said that one was
|
|
6
|
+
* already running for a folder. serve() writes this record, so every starter
|
|
7
|
+
* gets it, and a program that wants to reuse a server reads it with
|
|
8
|
+
* readLiveServerRecord(). The first live server owns the record.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
/** How long a liveness check waits for /health before calling the record stale. */
|
|
13
|
+
const HEALTH_TIMEOUT_MS = 1000;
|
|
14
|
+
export function serverRecordPath(planRoot) {
|
|
15
|
+
return join(planRoot, ".local", "server.json");
|
|
16
|
+
}
|
|
17
|
+
function isServerRecord(value) {
|
|
18
|
+
if (value === null || typeof value !== "object")
|
|
19
|
+
return false;
|
|
20
|
+
const record = value;
|
|
21
|
+
return typeof record.pid === "number" && typeof record.localUrl === "string" && typeof record.root === "string"
|
|
22
|
+
&& (record.kind === "standalone" || record.kind === "embedded");
|
|
23
|
+
}
|
|
24
|
+
async function readRecordFile(planRoot) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(await readFile(serverRecordPath(planRoot), "utf-8"));
|
|
27
|
+
return isServerRecord(parsed) ? parsed : null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function processExists(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// EPERM means the process exists but belongs to someone else.
|
|
40
|
+
return error.code === "EPERM";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function answersHealth(record) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`${record.localUrl.replace(/\/$/, "")}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) });
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
return false;
|
|
48
|
+
const body = (await response.json());
|
|
49
|
+
return typeof body.root === "string" && resolve(body.root) === resolve(record.root);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The record of a server that is really running for this folder, or null. A stale record is deleted. */
|
|
56
|
+
export async function readLiveServerRecord(planRoot) {
|
|
57
|
+
const record = await readRecordFile(planRoot);
|
|
58
|
+
if (!record)
|
|
59
|
+
return null;
|
|
60
|
+
if (processExists(record.pid) && (await answersHealth(record)))
|
|
61
|
+
return record;
|
|
62
|
+
await rm(serverRecordPath(planRoot), { force: true }).catch(() => { });
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Write the record unless another live server already owns it. Never throws:
|
|
67
|
+
* a folder that cannot be written must not stop the server from running.
|
|
68
|
+
* Returns true when this server now owns the record.
|
|
69
|
+
*/
|
|
70
|
+
export async function writeServerRecord(planRoot, record) {
|
|
71
|
+
try {
|
|
72
|
+
const existing = await readLiveServerRecord(planRoot);
|
|
73
|
+
// Compare the address, not the pid: one process can run several servers.
|
|
74
|
+
if (existing && existing.localUrl !== record.localUrl)
|
|
75
|
+
return false;
|
|
76
|
+
const path = serverRecordPath(planRoot);
|
|
77
|
+
await mkdir(dirname(path), { recursive: true });
|
|
78
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
79
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`);
|
|
80
|
+
await rename(temporary, path);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Remove the record, but only when it describes this very server (pid and address). Never throws. */
|
|
88
|
+
export async function removeServerRecord(planRoot, owner) {
|
|
89
|
+
try {
|
|
90
|
+
const record = await readRecordFile(planRoot);
|
|
91
|
+
if (record?.pid === owner.pid && record.localUrl === owner.localUrl)
|
|
92
|
+
await rm(serverRecordPath(planRoot), { force: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Nothing useful to do: the record is advisory.
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/ws-hub.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* All plan mutations broadcast events so connected clients stay in sync.
|
|
6
6
|
*/
|
|
7
7
|
import type { Server } from "node:http";
|
|
8
|
+
import type { EntityChangedData } from "./entity-events.js";
|
|
8
9
|
export type WsEvent = {
|
|
9
10
|
type: "file-changed";
|
|
10
11
|
data: {
|
|
@@ -35,6 +36,9 @@ export type WsEvent = {
|
|
|
35
36
|
data: {
|
|
36
37
|
phaseId: string;
|
|
37
38
|
};
|
|
39
|
+
} | {
|
|
40
|
+
type: "entity-changed";
|
|
41
|
+
data: EntityChangedData;
|
|
38
42
|
};
|
|
39
43
|
export declare class WsHub {
|
|
40
44
|
private wss;
|
package/dist/ws-hub.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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;AACxC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,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,GACxC;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAExD,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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-plan/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local HTTP/WebSocket server for Agent Plan dashboards and planner APIs.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@hono/node-server": "^1.14.0",
|
|
42
42
|
"hono": "^4.7.0",
|
|
43
43
|
"ws": "^8.18.0",
|
|
44
|
-
"@agent-plan/core": "0.4.
|
|
44
|
+
"@agent-plan/core": "0.4.2"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/ws": "^8.18.0"
|