@felixdotgo/aidlc-kit 0.2.0 → 0.3.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/DEVELOPMENT.md +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request routing and server start-up. Every request first passes the request
|
|
3
|
+
* guards (Host pinning; Origin, Sec-Fetch-Site, and the custom header on every
|
|
4
|
+
* state-changing request — see ./security.ts); routes are then tried in table
|
|
5
|
+
* order (first match wins).
|
|
6
|
+
*/
|
|
7
|
+
import { type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
|
8
|
+
import { type UiOptions } from "./options.js";
|
|
9
|
+
/** Path parameters a route's matcher extracted (already URI-decoded). */
|
|
10
|
+
export type RouteParams = Record<string, string>;
|
|
11
|
+
export interface RouteContext {
|
|
12
|
+
options: UiOptions;
|
|
13
|
+
req: IncomingMessage;
|
|
14
|
+
res: ServerResponse;
|
|
15
|
+
url: URL;
|
|
16
|
+
params: RouteParams;
|
|
17
|
+
}
|
|
18
|
+
export interface Route {
|
|
19
|
+
method: "GET" | "POST";
|
|
20
|
+
/** Params when the path matches, else undefined. May throw `URIError` (answered with 400). */
|
|
21
|
+
match: (path: string) => RouteParams | undefined;
|
|
22
|
+
handle: (ctx: RouteContext) => unknown;
|
|
23
|
+
}
|
|
24
|
+
/** Exact path. */
|
|
25
|
+
export declare const exact: (route: string) => (path: string) => RouteParams | undefined;
|
|
26
|
+
/** `<prefix><rest>`: `rest` is URI-decoded into `params[name]` (may be empty or contain `/`). */
|
|
27
|
+
export declare const prefixed: (prefix: string, name?: string) => (path: string) => RouteParams | undefined;
|
|
28
|
+
/** `<prefix><id><suffix>` with a single non-empty path segment as `params.id` (URI-decoded). */
|
|
29
|
+
export declare const taskSubpath: (prefix: string, suffix: string) => (path: string) => RouteParams | undefined;
|
|
30
|
+
/** Routes in match order. Add a more specific route before a broader prefix it overlaps
|
|
31
|
+
* (e.g. `/tasks/:id/<page>` before `/tasks/`, `/api/tasks/:id/<api>` before `/api/tasks/`). */
|
|
32
|
+
export declare const ROUTES: Route[];
|
|
33
|
+
/** Builds the request handler for the UI server (exported for unit tests). */
|
|
34
|
+
export declare const createUiHandler: (options: UiOptions) => (req: IncomingMessage, res: ServerResponse) => void;
|
|
35
|
+
/** Starts the UI HTTP server and resolves once it is listening. */
|
|
36
|
+
export declare const startUiServer: (options: UiOptions) => Promise<{
|
|
37
|
+
server: Server;
|
|
38
|
+
port: number;
|
|
39
|
+
}>;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request routing and server start-up. Every request first passes the request
|
|
3
|
+
* guards (Host pinning; Origin, Sec-Fetch-Site, and the custom header on every
|
|
4
|
+
* state-changing request — see ./security.ts); routes are then tried in table
|
|
5
|
+
* order (first match wins).
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { listTaskSummaries } from "../state.js";
|
|
9
|
+
import { staticAsset } from "./assets.js";
|
|
10
|
+
import { activateTask, approveGate, childError, daemonStatus, ensureDaemon, feedbackGate, intakeTask, loopStatusJson, runNode } from "./daemon.js";
|
|
11
|
+
import { taskChanges } from "./changes.js";
|
|
12
|
+
import { containerLog, taskContainers } from "./containers.js";
|
|
13
|
+
import { artifactPayload, readUiState, readUiTask, taskGatePayload } from "./data.js";
|
|
14
|
+
import { BadRequest, readBody, readFields, sendAsset, sendHtml, sendJson } from "./http.js";
|
|
15
|
+
import { livePayload } from "./live.js";
|
|
16
|
+
import { loopArgs, resolveUiMode, uiMode } from "./options.js";
|
|
17
|
+
import { requestAllowed } from "./security.js";
|
|
18
|
+
import { streamEvents } from "./sse.js";
|
|
19
|
+
import { renderDashboard } from "./views/board.js";
|
|
20
|
+
import { renderChangesPage } from "./views/changes.js";
|
|
21
|
+
import { renderDocsPage } from "./views/docs.js";
|
|
22
|
+
import { DEFAULT_DOC } from "./docs.js";
|
|
23
|
+
import { markdownToHtml } from "./markdown.js";
|
|
24
|
+
import { gateLabel } from "./views/components.js";
|
|
25
|
+
import { renderTaskDetail } from "./views/task.js";
|
|
26
|
+
/** Exact path. */
|
|
27
|
+
export const exact = (route) => (path) => path === route ? {} : undefined;
|
|
28
|
+
/** `<prefix><rest>`: `rest` is URI-decoded into `params[name]` (may be empty or contain `/`). */
|
|
29
|
+
export const prefixed = (prefix, name = "id") => (path) => path.startsWith(prefix) ? { [name]: decodeURIComponent(path.slice(prefix.length)) } : undefined;
|
|
30
|
+
/** `<prefix><id><suffix>` with a single non-empty path segment as `params.id` (URI-decoded). */
|
|
31
|
+
export const taskSubpath = (prefix, suffix) => (path) => {
|
|
32
|
+
if (!path.startsWith(prefix) || !path.endsWith(suffix) || path.length <= prefix.length + suffix.length)
|
|
33
|
+
return undefined;
|
|
34
|
+
const raw = path.slice(prefix.length, path.length - suffix.length);
|
|
35
|
+
return raw.includes("/") ? undefined : { id: decodeURIComponent(raw) };
|
|
36
|
+
};
|
|
37
|
+
/** The optional `?item=` selector (empty means the whole task). */
|
|
38
|
+
const itemParam = (url) => url.searchParams.get("item") || null;
|
|
39
|
+
/** The optional `?tail=<bytes>` of a log request: absent means the default; anything but digits is rejected downstream (NaN). */
|
|
40
|
+
const tailParam = (url) => {
|
|
41
|
+
const raw = url.searchParams.get("tail");
|
|
42
|
+
if (raw === null || raw === "")
|
|
43
|
+
return undefined;
|
|
44
|
+
return /^\d{1,12}$/.test(raw) ? Number(raw) : Number.NaN;
|
|
45
|
+
};
|
|
46
|
+
/** A POST body's fields (JSON object or form-encoded, 8 KiB cap). */
|
|
47
|
+
const fieldsOf = async (req, limit) => readFields(await readBody(req, limit), req.headers["content-type"]);
|
|
48
|
+
/** Body cap for routes whose field is itself capped at 8192 bytes: JSON escaping can
|
|
49
|
+
* double it (every newline is two bytes), so the envelope gets room to spare. */
|
|
50
|
+
const TEXT_BODY_LIMIT = 64 * 1024;
|
|
51
|
+
const NOT_FOUND = { ok: false, error: "Not found" };
|
|
52
|
+
/** Routes in match order. Add a more specific route before a broader prefix it overlaps
|
|
53
|
+
* (e.g. `/tasks/:id/<page>` before `/tasks/`, `/api/tasks/:id/<api>` before `/api/tasks/`). */
|
|
54
|
+
export const ROUTES = [
|
|
55
|
+
// SSE first: the stream writes its headers synchronously and stays open.
|
|
56
|
+
{ method: "GET", match: exact("/api/events"), handle: ({ options, res, req, url }) => streamEvents(options, res, req, url) },
|
|
57
|
+
{
|
|
58
|
+
method: "GET",
|
|
59
|
+
match: (path) => (path.startsWith("/assets/") && staticAsset(path) ? {} : undefined),
|
|
60
|
+
handle: ({ res, url }) => { const asset = staticAsset(url.pathname); sendAsset(res, asset.contentType, asset.body); },
|
|
61
|
+
},
|
|
62
|
+
{ method: "GET", match: exact("/"), handle: async ({ options, res }) => sendHtml(res, await renderDashboard(options)) },
|
|
63
|
+
// In-app guides: whitelisted packaged docs by slug (see ./docs.ts); `/docs` is the landing guide.
|
|
64
|
+
{
|
|
65
|
+
method: "GET",
|
|
66
|
+
match: (path) => (path === "/docs" || path === "/docs/" ? { slug: DEFAULT_DOC } : prefixed("/docs/", "slug")(path)),
|
|
67
|
+
handle: async ({ options, res, params }) => {
|
|
68
|
+
const page = await renderDocsPage(options, params.slug ?? "");
|
|
69
|
+
if (page)
|
|
70
|
+
sendHtml(res, page);
|
|
71
|
+
else
|
|
72
|
+
sendHtml(res, `<!doctype html><meta charset="utf-8"><title>Not found · Docs</title><p>No such guide. <a href="/docs">Back to the docs</a></p>`, 404);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
// Identity probe for the launcher's ui.json record; reveals nothing but a random id.
|
|
76
|
+
{ method: "GET", match: exact("/api/instance"), handle: ({ options, res }) => sendJson(res, 200, { ok: true, instance: options.instance ?? null }) },
|
|
77
|
+
{ method: "GET", match: exact("/api/live"), handle: async ({ options, res, url }) => sendJson(res, 200, await livePayload(options, url)) },
|
|
78
|
+
// Code changes: only a task id and an item id come from the request; SHAs come from records.
|
|
79
|
+
{
|
|
80
|
+
method: "GET",
|
|
81
|
+
match: taskSubpath("/tasks/", "/changes"),
|
|
82
|
+
handle: async ({ options, res, url, params }) => { const page = await renderChangesPage(options, params.id ?? "", itemParam(url), { ws: url.searchParams.get("ws") }); sendHtml(res, page.html, page.status); },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
method: "GET",
|
|
86
|
+
match: taskSubpath("/api/tasks/", "/changes"),
|
|
87
|
+
handle: async ({ options, res, url, params }) => { const result = await taskChanges(options, params.id ?? "", itemParam(url), undefined, null, { ws: url.searchParams.get("ws") }); sendJson(res, result.status, result.payload); },
|
|
88
|
+
},
|
|
89
|
+
// Worker containers: the task's runtime record (`runtime/workers/`), enriched by `docker ps`.
|
|
90
|
+
{
|
|
91
|
+
method: "GET",
|
|
92
|
+
match: taskSubpath("/api/tasks/", "/containers"),
|
|
93
|
+
handle: async ({ options, res, params }) => { const result = await taskContainers(options, params.id ?? ""); sendJson(res, result.status, result.payload); },
|
|
94
|
+
},
|
|
95
|
+
// Raw log tail of one container assignment; `?tail=<bytes>` (default 64 KiB, clamped to 1 MiB).
|
|
96
|
+
{
|
|
97
|
+
method: "GET",
|
|
98
|
+
match: (path) => {
|
|
99
|
+
const match = /^\/api\/tasks\/([^/]+)\/containers\/([^/]+)\/log$/.exec(path);
|
|
100
|
+
return match ? { id: decodeURIComponent(match[1] ?? ""), assignment: decodeURIComponent(match[2] ?? "") } : undefined;
|
|
101
|
+
},
|
|
102
|
+
handle: async ({ options, res, url, params }) => {
|
|
103
|
+
const result = await containerLog(options, params.id ?? "", params.assignment ?? "", tailParam(url));
|
|
104
|
+
res.writeHead(result.status, { "content-type": result.contentType, "cache-control": "no-store", "x-aidlc-log-size": String(result.size), "x-aidlc-log-truncated": result.truncated ? "1" : "0" });
|
|
105
|
+
res.end(result.body);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
method: "GET",
|
|
110
|
+
match: prefixed("/partial/tasks/"),
|
|
111
|
+
handle: async ({ options, res, params }) => params.id ? sendHtml(res, await renderTaskDetail(options, params.id, true)) : sendJson(res, 404, NOT_FOUND),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
method: "GET",
|
|
115
|
+
match: prefixed("/tasks/"),
|
|
116
|
+
handle: async ({ options, res, params }) => params.id ? sendHtml(res, await renderTaskDetail(options, params.id)) : sendJson(res, 404, NOT_FOUND),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
method: "GET",
|
|
120
|
+
match: exact("/api/tasks"),
|
|
121
|
+
handle: async ({ options, res, url }) => {
|
|
122
|
+
const query = url.searchParams;
|
|
123
|
+
const summary = listTaskSummaries(await readUiState(options), {
|
|
124
|
+
includeArchive: query.get("archive") === "1",
|
|
125
|
+
statuses: query.get("statuses") ? query.get("statuses").split(",").filter(Boolean) : undefined,
|
|
126
|
+
query: query.get("query") ?? undefined,
|
|
127
|
+
limit: query.get("limit") ? Number(query.get("limit")) : undefined,
|
|
128
|
+
cursor: query.get("cursor") ? Number(query.get("cursor")) : undefined,
|
|
129
|
+
});
|
|
130
|
+
sendJson(res, 200, summary);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
method: "GET",
|
|
135
|
+
match: (path) => {
|
|
136
|
+
if (!path.startsWith("/api/tasks/") || !path.endsWith("/gate"))
|
|
137
|
+
return undefined;
|
|
138
|
+
return { id: decodeURIComponent(path.slice("/api/tasks/".length)).slice(0, -"/gate".length) };
|
|
139
|
+
},
|
|
140
|
+
handle: async ({ options, res, params }) => {
|
|
141
|
+
const payload = await taskGatePayload(options, params.id ?? "");
|
|
142
|
+
sendJson(res, payload.ok ? 200 : 409, payload);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
method: "GET",
|
|
147
|
+
match: (path) => {
|
|
148
|
+
const rest = prefixed("/api/tasks/")(path)?.id;
|
|
149
|
+
const artifact = rest === undefined ? null : /^(.+)\/artifact\/(intent|design|workplan)$/.exec(rest);
|
|
150
|
+
return artifact ? { id: artifact[1] ?? "", name: artifact[2] ?? "" } : undefined;
|
|
151
|
+
},
|
|
152
|
+
handle: async ({ options, res, params }) => {
|
|
153
|
+
const taskId = params.id ?? "";
|
|
154
|
+
const payload = artifactPayload(options.root, taskId, params.name ?? "", readUiTask(options, taskId, await readUiState(options)));
|
|
155
|
+
sendJson(res, payload.ok ? 200 : 404, payload);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
method: "GET",
|
|
160
|
+
match: prefixed("/api/tasks/"),
|
|
161
|
+
handle: async ({ options, res, params }) => {
|
|
162
|
+
const id = params.id ?? "";
|
|
163
|
+
const task = readUiTask(options, id, await readUiState(options));
|
|
164
|
+
if (!task)
|
|
165
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${id}` });
|
|
166
|
+
sendJson(res, 200, { ok: true, task });
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
method: "GET",
|
|
171
|
+
match: exact("/api/loop/status"),
|
|
172
|
+
handle: async ({ options, res, url }) => {
|
|
173
|
+
const taskId = url.searchParams.get("task-id") ?? "";
|
|
174
|
+
if (!taskId)
|
|
175
|
+
return sendJson(res, 400, { ok: false, error: "task-id query parameter is required" });
|
|
176
|
+
try {
|
|
177
|
+
sendJson(res, 200, { ok: true, result: await loopStatusJson(options.root, taskId) });
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
sendJson(res, 502, { ok: false, error: childError(error) });
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
// Markdown preview for the New task editor: the same escaping renderer as artifacts.
|
|
185
|
+
{
|
|
186
|
+
method: "POST",
|
|
187
|
+
match: exact("/api/markdown"),
|
|
188
|
+
handle: async ({ req, res }) => {
|
|
189
|
+
const markdown = (await fieldsOf(req, TEXT_BODY_LIMIT))["markdown"] ?? "";
|
|
190
|
+
if (Buffer.byteLength(markdown) > 8192)
|
|
191
|
+
return sendJson(res, 400, { ok: false, error: "markdown exceeds 8192 bytes" });
|
|
192
|
+
sendJson(res, 200, { ok: true, html: markdownToHtml(markdown) });
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
method: "POST",
|
|
197
|
+
match: exact("/api/loop/start"),
|
|
198
|
+
handle: async ({ options, req, res }) => {
|
|
199
|
+
const fields = await fieldsOf(req, TEXT_BODY_LIMIT);
|
|
200
|
+
const requirement = fields["requirement"] ?? "";
|
|
201
|
+
if (!requirement.trim())
|
|
202
|
+
return sendJson(res, 400, { ok: false, error: "requirement is required" });
|
|
203
|
+
if (Buffer.byteLength(requirement) > 8192)
|
|
204
|
+
return sendJson(res, 400, { ok: false, error: "requirement exceeds 8192 bytes" });
|
|
205
|
+
let created;
|
|
206
|
+
try {
|
|
207
|
+
created = await intakeTask(options.root, requirement);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
return sendJson(res, 409, { ok: false, error: childError(error) });
|
|
211
|
+
}
|
|
212
|
+
sendJson(res, 201, { ok: true, ...created, daemon: ensureDaemon(options.root) });
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
method: "POST",
|
|
217
|
+
match: exact("/api/loop/resume"),
|
|
218
|
+
handle: async ({ options, req, res }) => {
|
|
219
|
+
const fields = await fieldsOf(req);
|
|
220
|
+
const taskId = fields["taskId"] ?? "";
|
|
221
|
+
if (!taskId.trim())
|
|
222
|
+
return sendJson(res, 400, { ok: false, error: "taskId is required" });
|
|
223
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
224
|
+
if (!task)
|
|
225
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${taskId}` });
|
|
226
|
+
if (task.handoff)
|
|
227
|
+
return sendJson(res, 409, { ok: false, error: "Task is handed off; follow its handoff actions (state.mjs task next) instead of resuming" });
|
|
228
|
+
if (task.status === "blocked_on_user")
|
|
229
|
+
return sendJson(res, 409, { ok: false, error: "Task is waiting at a human gate; approve it on the task detail page" });
|
|
230
|
+
if (task.status !== "paused")
|
|
231
|
+
return sendJson(res, 409, { ok: false, error: `Only a paused task can be resumed (status: ${task.status})` });
|
|
232
|
+
try {
|
|
233
|
+
const result = uiMode(options) === "fleet" ? JSON.parse(await runNode(options.root, loopArgs(options.root, "resume-task", taskId), { timeout: 30000 })) : await activateTask(options.root, taskId);
|
|
234
|
+
sendJson(res, 200, { ok: true, result, daemon: daemonStatus(options.root) });
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
sendJson(res, 502, { ok: false, error: childError(error) });
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
method: "POST",
|
|
243
|
+
match: exact("/api/gate/feedback"),
|
|
244
|
+
handle: async ({ options, req, res }) => {
|
|
245
|
+
const fields = await fieldsOf(req);
|
|
246
|
+
const taskId = fields["taskId"] ?? "";
|
|
247
|
+
const feedback = (fields["feedback"] ?? "").trim();
|
|
248
|
+
const source = (fields["source"] ?? "").trim() || "Dashboard feedback";
|
|
249
|
+
if (!taskId.trim())
|
|
250
|
+
return sendJson(res, 400, { ok: false, error: "taskId is required" });
|
|
251
|
+
if (!feedback)
|
|
252
|
+
return sendJson(res, 400, { ok: false, error: "feedback is required" });
|
|
253
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
254
|
+
if (!task)
|
|
255
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${taskId}` });
|
|
256
|
+
if (task.status !== "blocked_on_user" || task.gate === "none")
|
|
257
|
+
return sendJson(res, 409, { ok: false, error: "Task is not waiting at a human gate" });
|
|
258
|
+
try {
|
|
259
|
+
// Feedback never approves, so it never needs the fleet reviewer credential.
|
|
260
|
+
const result = uiMode(options) === "fleet"
|
|
261
|
+
? JSON.parse(await runNode(options.root, loopArgs(options.root, "feedback", taskId), { input: JSON.stringify({ reason: feedback, source }), timeout: 30000 }))
|
|
262
|
+
: await feedbackGate(options.root, taskId, feedback, source);
|
|
263
|
+
sendJson(res, 200, { ok: true, result });
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
sendJson(res, 502, { ok: false, error: childError(error) });
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
method: "POST",
|
|
272
|
+
match: exact("/api/gate/approve"),
|
|
273
|
+
handle: async ({ options, req, res }) => {
|
|
274
|
+
const fields = await fieldsOf(req);
|
|
275
|
+
const taskId = fields["taskId"] ?? "";
|
|
276
|
+
const source = fields["source"] ?? "";
|
|
277
|
+
if (!taskId.trim())
|
|
278
|
+
return sendJson(res, 400, { ok: false, error: "taskId is required" });
|
|
279
|
+
if (!source.trim())
|
|
280
|
+
return sendJson(res, 400, { ok: false, error: "source (approval note) is required" });
|
|
281
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
282
|
+
if (!task)
|
|
283
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${taskId}` });
|
|
284
|
+
if (task.status !== "blocked_on_user" || task.gate === "none")
|
|
285
|
+
return sendJson(res, 409, { ok: false, error: "Task is not waiting at a human gate" });
|
|
286
|
+
const shownGate = (fields["gate"] ?? "").trim();
|
|
287
|
+
if (shownGate && shownGate !== task.gate)
|
|
288
|
+
return sendJson(res, 409, { ok: false, error: `The task moved on: it now waits at ${gateLabel(task.gate)} (${task.gate}), not ${shownGate}. Review it again before approving.` });
|
|
289
|
+
if (uiMode(options) === "fleet" && !options.reviewerToken)
|
|
290
|
+
return sendJson(res, 403, { ok: false, error: "Fleet approval needs the reviewer credential; restart aidlc-kit ui with AIDLC_FLEET_REVIEWER_TOKEN or --reviewer-token-file, or approve from a reviewer terminal" });
|
|
291
|
+
try {
|
|
292
|
+
// Fleet: the reviewer credential reaches only this single approve child process.
|
|
293
|
+
const result = uiMode(options) === "fleet"
|
|
294
|
+
? JSON.parse(await runNode(options.root, loopArgs(options.root, "approve", taskId), { input: source, timeout: 30000, env: { AIDLC_FLEET_REVIEWER_TOKEN: options.reviewerToken ?? "" } }))
|
|
295
|
+
: await approveGate(options.root, taskId, task.gate, source);
|
|
296
|
+
sendJson(res, 200, { ok: true, result });
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
sendJson(res, 502, { ok: false, error: childError(error) });
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
/** Builds the request handler for the UI server (exported for unit tests). */
|
|
305
|
+
export const createUiHandler = (options) => {
|
|
306
|
+
return (req, res) => {
|
|
307
|
+
const url = new URL(req.url ?? "/", `http://${options.host || "localhost"}`);
|
|
308
|
+
const method = req.method ?? "GET";
|
|
309
|
+
const path = url.pathname;
|
|
310
|
+
const guard = requestAllowed(options, req);
|
|
311
|
+
if (!guard.ok)
|
|
312
|
+
return sendJson(res, 403, { ok: false, error: guard.error });
|
|
313
|
+
void (async () => {
|
|
314
|
+
try {
|
|
315
|
+
for (const route of ROUTES) {
|
|
316
|
+
if (route.method !== method)
|
|
317
|
+
continue;
|
|
318
|
+
const params = route.match(path);
|
|
319
|
+
if (!params)
|
|
320
|
+
continue;
|
|
321
|
+
await route.handle({ options, req, res, url, params });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
return sendJson(res, 404, { ok: false, error: `Not found: ${method} ${path}` });
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (res.headersSent || res.destroyed)
|
|
328
|
+
return;
|
|
329
|
+
if (error instanceof BadRequest || error instanceof URIError)
|
|
330
|
+
return sendJson(res, 400, { ok: false, error: error.message });
|
|
331
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
332
|
+
}
|
|
333
|
+
})();
|
|
334
|
+
};
|
|
335
|
+
};
|
|
336
|
+
/** Starts the UI HTTP server and resolves once it is listening. */
|
|
337
|
+
export const startUiServer = (options) => new Promise((resolveServer, reject) => {
|
|
338
|
+
const mode = options.mode ?? resolveUiMode(options.root);
|
|
339
|
+
const reviewerToken = options.reviewerToken ?? (mode === "fleet" ? process.env["AIDLC_FLEET_REVIEWER_TOKEN"] || undefined : undefined);
|
|
340
|
+
const server = createServer(createUiHandler({ ...options, mode, ...(reviewerToken ? { reviewerToken } : {}) }));
|
|
341
|
+
server.once("error", reject);
|
|
342
|
+
server.listen(options.port, options.host, () => {
|
|
343
|
+
const address = server.address();
|
|
344
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
345
|
+
resolveServer({ server, port });
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type UiOptions } from "./options.js";
|
|
3
|
+
/** On a fresh connection the SSE stream replays at most this many lines. */
|
|
4
|
+
export declare const SSE_REPLAY_LINES = 200;
|
|
5
|
+
/**
|
|
6
|
+
* Streams loop events as SSE. Tracks byte offsets (no full re-read per tick),
|
|
7
|
+
* emits only newline-terminated lines (a half-written line waits for the next
|
|
8
|
+
* tick), handles truncation and rotation (inode change), and replays only the
|
|
9
|
+
* last SSE_REPLAY_LINES on a fresh connection. `?task-id=<id>` (a canonical task
|
|
10
|
+
* id, else 400) reads that task's own history file when it exists, and falls
|
|
11
|
+
* back to filtering the global sidecar (older logs) when it does not. Event ids
|
|
12
|
+
* let EventSource reconnects (`Last-Event-ID`) resume exactly.
|
|
13
|
+
*/
|
|
14
|
+
export declare const streamEvents: (options: UiOptions, res: ServerResponse, req: IncomingMessage, url: URL) => void;
|