@felixdotgo/aidlc-kit 0.1.1 → 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/DEVELOPMENT.md +14 -0
- package/README.md +11 -13
- package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
- package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
- package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
- package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +245 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +15 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +40 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +14 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +42 -15
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +75 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +57 -2
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
- package/dist/assets/.agents/aidlc/templates/design.md +9 -0
- package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
- package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
- package/dist/assets/ui/bulma.min.css +3 -0
- package/dist/assets/ui/dashboard.css +414 -0
- package/dist/services/aidlc-coordination/Dockerfile +11 -3
- package/dist/services/aidlc-coordination/compose.yaml +12 -0
- package/dist/services/aidlc-coordination/package-lock.json +3 -1
- package/dist/services/aidlc-coordination/package.json +9 -4
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +57 -2
- package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
- package/dist/services/aidlc-coordination/src/server.mjs +9 -3
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
- package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
- package/dist/src/adapters.d.ts +0 -3
- package/dist/src/adapters.js +3 -5
- package/dist/src/cli/argv.d.ts +12 -0
- package/dist/src/cli/argv.js +62 -0
- package/dist/src/cli/commands.d.ts +8 -0
- package/dist/src/cli/commands.js +129 -0
- package/dist/src/cli/options.d.ts +25 -0
- package/dist/src/cli/options.js +32 -0
- package/dist/src/cli/prompt.d.ts +9 -0
- package/dist/src/cli/prompt.js +66 -0
- package/dist/src/cli.js +25 -191
- package/dist/src/gate.js +3 -2
- package/dist/src/installer.d.ts +14 -7
- package/dist/src/installer.js +38 -141
- package/dist/src/legacy.js +1 -0
- package/dist/src/managed-content.d.ts +7 -0
- package/dist/src/managed-content.js +15 -4
- package/dist/src/managed-planning.d.ts +65 -0
- package/dist/src/managed-planning.js +219 -0
- package/dist/src/mcp-setup.js +3 -1
- package/dist/src/model/config-contracts.d.ts +65 -0
- package/dist/src/model/config-contracts.js +1 -0
- package/dist/src/model/install-contracts.d.ts +29 -0
- package/dist/src/model/install-contracts.js +1 -0
- package/dist/src/model/lifecycle-enums.d.ts +13 -0
- package/dist/src/model/lifecycle-enums.js +1 -0
- package/dist/src/model/loop.d.ts +20 -0
- package/dist/src/model/loop.js +1 -0
- package/dist/src/model/manifest.d.ts +16 -0
- package/dist/src/model/manifest.js +1 -0
- package/dist/src/model/records.d.ts +138 -0
- package/dist/src/model/records.js +1 -0
- package/dist/src/model.d.ts +11 -264
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +2 -0
- package/dist/src/profiles.js +53 -9
- package/dist/src/state/archive-record.d.ts +9 -0
- package/dist/src/state/archive-record.js +26 -0
- package/dist/src/state/commands.d.ts +18 -0
- package/dist/src/state/commands.js +27 -0
- package/dist/src/state/domain.d.ts +9 -0
- package/dist/src/state/domain.js +9 -0
- package/dist/src/state/guards.d.ts +7 -0
- package/dist/src/state/guards.js +17 -0
- package/dist/src/state/io.d.ts +10 -0
- package/dist/src/state/io.js +35 -0
- package/dist/src/state/items.d.ts +3 -4
- package/dist/src/state/items.js +58 -57
- package/dist/src/state/lessons.js +14 -7
- package/dist/src/state/lifecycle.js +2 -2
- package/dist/src/state/lock.js +7 -6
- package/dist/src/state/memory.js +24 -12
- package/dist/src/state/{archive.js → migrate.js} +7 -7
- package/dist/src/state/partition.d.ts +15 -0
- package/dist/src/state/partition.js +90 -0
- package/dist/src/state/paths.d.ts +11 -0
- package/dist/src/state/paths.js +12 -0
- package/dist/src/state/render.js +9 -6
- package/dist/src/state/store.js +9 -15
- package/dist/src/state/transitions.js +9 -7
- package/dist/src/state/validate.js +50 -7
- package/dist/src/state.d.ts +16 -11
- package/dist/src/state.js +16 -11
- package/dist/src/task-ui.d.ts +54 -0
- package/dist/src/task-ui.js +1570 -0
- package/dist/src/upgrade-board.js +10 -10
- package/dist/src/upgrade.d.ts +0 -1
- package/dist/src/upgrade.js +19 -83
- package/dist/src/workflow.js +3 -3
- package/docs/README.md +6 -2
- package/docs/architecture.md +9 -8
- package/docs/command-reference.md +81 -20
- package/docs/configuration.md +28 -8
- package/docs/coordination-service.md +18 -6
- package/docs/design-decisions.md +83 -0
- package/docs/development.md +27 -2
- package/docs/loop-local-run.md +495 -0
- package/docs/loop-runbook.md +235 -0
- package/docs/operating-workflow.md +2 -2
- package/docs/testing-and-release.md +5 -1
- package/docs/troubleshooting.md +125 -0
- package/package.json +4 -1
- package/dist/src/ledger.d.ts +0 -22
- package/dist/src/ledger.js +0 -119
- package/dist/src/state/shared.d.ts +0 -29
- package/dist/src/state/shared.js +0 -71
- package/dist/src/tracker.d.ts +0 -36
- package/dist/src/tracker.js +0 -44
- /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local web UI for the AI-DLC loop and task status.
|
|
3
|
+
*
|
|
4
|
+
* Read-only over canonical local state (`.agents/data/state/aidlc-state.json`).
|
|
5
|
+
* Loop start/resume are thin facades that spawn the installed `loop-entry.mjs`
|
|
6
|
+
* exactly as `aidlc-kit loop` does; gate approval is a thin facade that spawns
|
|
7
|
+
* the same atomic `state.mjs gate approve` command. This module never mutates
|
|
8
|
+
* lifecycle state itself and never bypasses the fail-closed gate channel.
|
|
9
|
+
*
|
|
10
|
+
* Built on `node:http` only; Bulma CSS and the dashboard theme layer are
|
|
11
|
+
* vendored static assets (no CDN, no runtime npm dependency). Mutating actions (loop start/resume, gate
|
|
12
|
+
* approve) require a per-session token generated at server start and printed
|
|
13
|
+
* once to the terminal.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile, spawn } from "node:child_process";
|
|
16
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { join, resolve, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { checkGate, gateView } from "./gate.js";
|
|
22
|
+
import { assertTaskArtifactPath, listTaskSummaries, loadState, loadTask } from "./state.js";
|
|
23
|
+
/** Credentials that never reach the daemon or read-only child processes. */
|
|
24
|
+
const SCRUBBED_ENV = ["AIDLC_FLEET_REVIEWER_TOKEN", "AIDLC_UI_TOKEN", "AIDLC_UI_TOKEN_FILE", "AIDLC_UI_REVIEWER_TOKEN_FILE"];
|
|
25
|
+
export const scrubbedEnv = (extra = {}) => {
|
|
26
|
+
const env = { ...process.env };
|
|
27
|
+
for (const name of SCRUBBED_ENV)
|
|
28
|
+
delete env[name];
|
|
29
|
+
return { ...env, ...extra };
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Fixed token from a file (`--token-file`, `--reviewer-token-file`) so the browser stays signed in across
|
|
33
|
+
* UI restarts. The file must live outside the project (agents work inside it), be a regular non-symlink
|
|
34
|
+
* file, and be private to the owner (mode 600/400). With `create`, a missing file is created with a
|
|
35
|
+
* random token. The token is never passed to the daemon, lead, or workers.
|
|
36
|
+
*/
|
|
37
|
+
export const readUiTokenFile = (root, path, { create }) => {
|
|
38
|
+
const target = resolve(path);
|
|
39
|
+
const project = resolve(root);
|
|
40
|
+
if (target === project || target.startsWith(`${project}${sep}`))
|
|
41
|
+
throw new Error("Token file must live outside the project directory");
|
|
42
|
+
if (!existsSync(target)) {
|
|
43
|
+
if (!create)
|
|
44
|
+
throw new Error(`Token file not found: ${target}`);
|
|
45
|
+
const token = randomBytes(32).toString("base64url");
|
|
46
|
+
writeFileSync(target, `${token}\n`, { flag: "wx", mode: 0o600 });
|
|
47
|
+
return token;
|
|
48
|
+
}
|
|
49
|
+
const stat = lstatSync(target);
|
|
50
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
51
|
+
throw new Error("Token file must be a regular file, not a symlink");
|
|
52
|
+
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0)
|
|
53
|
+
throw new Error(`Token file must be private to its owner (chmod 600 ${target})`);
|
|
54
|
+
const token = readFileSync(target, "utf8").trim();
|
|
55
|
+
if (token.length < 16 || token.length > 256 || /\s/.test(token))
|
|
56
|
+
throw new Error("Token file must contain one token of 16–256 non-whitespace characters");
|
|
57
|
+
return token;
|
|
58
|
+
};
|
|
59
|
+
/** Best-effort browser launch for `aidlc-kit ui --open`; failures are ignored (the URL is printed anyway). */
|
|
60
|
+
export const openBrowser = (url) => {
|
|
61
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
62
|
+
try {
|
|
63
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
64
|
+
child.on("error", () => { });
|
|
65
|
+
child.unref();
|
|
66
|
+
}
|
|
67
|
+
catch { /* no browser launcher */ }
|
|
68
|
+
};
|
|
69
|
+
/** The configured loop authority: `fleet` only when `loop.mode` says so; everything else reads local state. */
|
|
70
|
+
export const resolveUiMode = (root) => {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(join(root, ".agents", "config.json"), "utf8"))?.loop?.mode === "fleet" ? "fleet" : "local";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "local";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const uiMode = (options) => options.mode ?? "local";
|
|
79
|
+
const loopEntryPath = (root) => join(root, ".agents", "aidlc", "scripts", "loop-entry.mjs");
|
|
80
|
+
const stateScriptPath = (root) => join(root, ".agents", "aidlc", "scripts", "state.mjs");
|
|
81
|
+
const loopDataDir = (root) => join(root, ".agents", "data", "loop");
|
|
82
|
+
const eventsLogPath = (root) => join(loopDataDir(root), "ui-events.jsonl");
|
|
83
|
+
const daemonLockPath = (root) => join(loopDataDir(root), "daemon.lock");
|
|
84
|
+
const daemonLogPath = (root) => join(loopDataDir(root), "daemon.log");
|
|
85
|
+
const loopArgs = (root, action, taskId = "") => [loopEntryPath(root), action, taskId, root, "", "", "", ""];
|
|
86
|
+
const ARTIFACT_NAMES = ["intent", "design", "workplan"];
|
|
87
|
+
const PHASES = ["clarify", "plan", "build", "wrap", "done"];
|
|
88
|
+
/** Upper bound on dashboard cards (matches the `listTaskSummaries` page cap). */
|
|
89
|
+
const DASHBOARD_LIMIT = 100;
|
|
90
|
+
/** Vendored Bulma plus the dashboard theme layer, read once and cached. */
|
|
91
|
+
const STYLESHEETS = new Map([["/assets/bulma.css", "bulma.min.css"], ["/assets/dashboard.css", "dashboard.css"]]);
|
|
92
|
+
const stylesheetCache = new Map();
|
|
93
|
+
const stylesheet = (route) => {
|
|
94
|
+
const file = STYLESHEETS.get(route);
|
|
95
|
+
if (!file)
|
|
96
|
+
return undefined;
|
|
97
|
+
const cached = stylesheetCache.get(route);
|
|
98
|
+
if (cached !== undefined)
|
|
99
|
+
return cached;
|
|
100
|
+
let css = "";
|
|
101
|
+
try {
|
|
102
|
+
css = readFileSync(fileURLToPath(new URL(`../assets/ui/${file}`, import.meta.url)), "utf8");
|
|
103
|
+
}
|
|
104
|
+
catch { /* serve an empty stylesheet rather than failing the page */ }
|
|
105
|
+
stylesheetCache.set(route, css);
|
|
106
|
+
return css;
|
|
107
|
+
};
|
|
108
|
+
const sendJson = (res, status, body) => {
|
|
109
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
110
|
+
res.end(JSON.stringify(body));
|
|
111
|
+
};
|
|
112
|
+
const sendHtml = (res, body) => {
|
|
113
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
114
|
+
res.end(body);
|
|
115
|
+
};
|
|
116
|
+
const sendCss = (res, body) => {
|
|
117
|
+
res.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
118
|
+
res.end(body);
|
|
119
|
+
};
|
|
120
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
|
|
121
|
+
switch (char) {
|
|
122
|
+
case "&": return "&";
|
|
123
|
+
case "<": return "<";
|
|
124
|
+
case ">": return ">";
|
|
125
|
+
case '"': return """;
|
|
126
|
+
default: return "'";
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const readBody = (req) => new Promise((resolveBody, reject) => {
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
req.on("data", (chunk) => {
|
|
133
|
+
size += chunk.length;
|
|
134
|
+
if (size > 8192) {
|
|
135
|
+
reject(new BadRequest("Request body exceeds 8192 bytes"));
|
|
136
|
+
req.destroy();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
chunks.push(chunk);
|
|
140
|
+
});
|
|
141
|
+
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
|
|
142
|
+
req.on("error", reject);
|
|
143
|
+
});
|
|
144
|
+
const decode = (value) => { try {
|
|
145
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return value;
|
|
149
|
+
} };
|
|
150
|
+
const parseForm = (body) => {
|
|
151
|
+
const result = {};
|
|
152
|
+
for (const pair of body.split("&")) {
|
|
153
|
+
if (!pair)
|
|
154
|
+
continue;
|
|
155
|
+
const [rawKey, rawValue = ""] = pair.split("=");
|
|
156
|
+
const key = decode(rawKey ?? "");
|
|
157
|
+
if (key)
|
|
158
|
+
result[key] = decode(rawValue);
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
};
|
|
162
|
+
const isJson = (contentType) => (contentType ?? "").includes("application/json");
|
|
163
|
+
/** A client error mapped to HTTP 400 by the request handler. */
|
|
164
|
+
class BadRequest extends Error {
|
|
165
|
+
}
|
|
166
|
+
const readFields = (body, contentType) => {
|
|
167
|
+
if (isJson(contentType)) {
|
|
168
|
+
let value;
|
|
169
|
+
try {
|
|
170
|
+
value = JSON.parse(body);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new BadRequest("Request body is not valid JSON");
|
|
174
|
+
}
|
|
175
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
176
|
+
throw new BadRequest("JSON body must be an object");
|
|
177
|
+
const result = {};
|
|
178
|
+
for (const [key, entry] of Object.entries(value))
|
|
179
|
+
if (typeof entry === "string")
|
|
180
|
+
result[key] = entry;
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
return parseForm(body);
|
|
184
|
+
};
|
|
185
|
+
/** The approve token travels only in a header (`Authorization: Bearer` or `x-aidlc-token`). A custom
|
|
186
|
+
* header forces a CORS preflight, so a cross-site form or `no-cors` request can never carry it; a body
|
|
187
|
+
* field would (classic CSRF), so it is not accepted. */
|
|
188
|
+
const suppliedToken = (req) => {
|
|
189
|
+
const authorization = req.headers["authorization"];
|
|
190
|
+
if (typeof authorization === "string" && authorization.startsWith("Bearer "))
|
|
191
|
+
return authorization.slice(7).trim();
|
|
192
|
+
const header = req.headers["x-aidlc-token"];
|
|
193
|
+
return typeof header === "string" ? header.trim() : "";
|
|
194
|
+
};
|
|
195
|
+
const LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
|
|
196
|
+
/** DNS-rebinding and CSRF guard: the Host header must name the bound address (any loopback name when
|
|
197
|
+
* bound to loopback; a wildcard bind cannot be pinned and relies on the token and Origin check), and a
|
|
198
|
+
* state-changing request that carries an Origin must be same-origin. */
|
|
199
|
+
export const requestAllowed = (options, req) => {
|
|
200
|
+
const host = String(req.headers.host ?? "").toLowerCase();
|
|
201
|
+
const port = req.socket?.localPort;
|
|
202
|
+
const bind = (options.host || "127.0.0.1").toLowerCase();
|
|
203
|
+
const wildcard = bind === "0.0.0.0" || bind === "::" || bind === "[::]";
|
|
204
|
+
if (!wildcard) {
|
|
205
|
+
const named = bind.includes(":") && !bind.startsWith("[") ? `[${bind}]` : bind;
|
|
206
|
+
const loopback = LOOPBACK_HOSTS.includes(named) || named === "::1";
|
|
207
|
+
const allowed = new Set([named, ...(loopback ? LOOPBACK_HOSTS : [])].map((name) => `${name}:${port}`));
|
|
208
|
+
if (!allowed.has(host))
|
|
209
|
+
return { ok: false, error: "Host header does not match this dashboard; open it at its bound address" };
|
|
210
|
+
}
|
|
211
|
+
const method = req.method ?? "GET";
|
|
212
|
+
const origin = req.headers.origin;
|
|
213
|
+
if (method !== "GET" && method !== "HEAD" && origin !== undefined && origin !== `http://${host}`)
|
|
214
|
+
return { ok: false, error: "Cross-origin request rejected" };
|
|
215
|
+
return { ok: true };
|
|
216
|
+
};
|
|
217
|
+
const tokenMatches = (token, supplied) => {
|
|
218
|
+
if (!token || !supplied)
|
|
219
|
+
return false;
|
|
220
|
+
const a = createHash("sha256").update(token).digest();
|
|
221
|
+
const b = createHash("sha256").update(supplied).digest();
|
|
222
|
+
return timingSafeEqual(a, b);
|
|
223
|
+
};
|
|
224
|
+
/** Runs an installed workflow script with the current Node binary without
|
|
225
|
+
* blocking the event loop; rejects with the child's stderr attached. */
|
|
226
|
+
const runNode = (root, args, options) => new Promise((resolveRun, rejectRun) => {
|
|
227
|
+
const child = execFile(process.execPath, args, { cwd: root, encoding: "utf8", maxBuffer: 8 * 1024 * 1024, timeout: options.timeout, env: scrubbedEnv(options.env) }, (error, stdout, stderr) => {
|
|
228
|
+
if (error) {
|
|
229
|
+
rejectRun(Object.assign(error, { stderr }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
resolveRun(stdout);
|
|
233
|
+
});
|
|
234
|
+
child.stdin?.end(options.input ?? "");
|
|
235
|
+
});
|
|
236
|
+
const pidAlive = (pid) => {
|
|
237
|
+
try {
|
|
238
|
+
process.kill(pid, 0);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return error.code !== "ESRCH";
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
/** Reads the singleton loop daemon lock written by `loop-daemon.mjs`. */
|
|
246
|
+
export const daemonStatus = (root) => {
|
|
247
|
+
try {
|
|
248
|
+
const lock = JSON.parse(readFileSync(daemonLockPath(root), "utf8"));
|
|
249
|
+
const pid = typeof lock.pid === "number" && Number.isSafeInteger(lock.pid) && lock.pid > 0 ? lock.pid : null;
|
|
250
|
+
return { running: pid !== null && pidAlive(pid), pid, startedAt: typeof lock.startedAt === "string" ? lock.startedAt : null };
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return { running: false, pid: null, startedAt: null };
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
/** Starts the local daemon unless a live one already holds the singleton lock.
|
|
257
|
+
* The daemon's stderr goes to `daemon.log` so start-up failures are visible. */
|
|
258
|
+
const ensureDaemon = (root) => {
|
|
259
|
+
const current = daemonStatus(root);
|
|
260
|
+
if (current.running)
|
|
261
|
+
return { pid: current.pid, started: false };
|
|
262
|
+
mkdirSync(loopDataDir(root), { recursive: true });
|
|
263
|
+
const log = openSync(daemonLogPath(root), "a");
|
|
264
|
+
try {
|
|
265
|
+
const child = spawn(process.execPath, loopArgs(root, "start"), {
|
|
266
|
+
cwd: root,
|
|
267
|
+
detached: true,
|
|
268
|
+
stdio: ["ignore", log, log],
|
|
269
|
+
// The daemon (and every lead/worker it spawns) never sees UI or reviewer credentials.
|
|
270
|
+
env: scrubbedEnv({ AIDLC_LOOP_EVENT_LOG: eventsLogPath(root) }),
|
|
271
|
+
});
|
|
272
|
+
// Without a listener a spawn failure would be an unhandled 'error' event and
|
|
273
|
+
// crash the UI server; the daemon's own start-up errors go to daemon.log.
|
|
274
|
+
child.on("error", () => { });
|
|
275
|
+
child.unref();
|
|
276
|
+
return { pid: child.pid ?? null, started: true };
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
closeSync(log);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const intakeTask = async (root, requirement) => {
|
|
283
|
+
const output = await runNode(root, loopArgs(root, "intake"), { input: requirement, timeout: 30000 });
|
|
284
|
+
const parsed = JSON.parse(output);
|
|
285
|
+
if (typeof parsed.taskId !== "string" || typeof parsed.title !== "string")
|
|
286
|
+
throw new Error("loop intake returned no task id");
|
|
287
|
+
return { taskId: parsed.taskId, title: parsed.title };
|
|
288
|
+
};
|
|
289
|
+
const activateTask = async (root, taskId) => JSON.parse(await runNode(root, [stateScriptPath(root), "task", "status", taskId, "--status", "active", "--root", root], { timeout: 30000 }));
|
|
290
|
+
const loopStatusJson = async (root, taskId) => JSON.parse(await runNode(root, loopArgs(root, "status", taskId), { timeout: 5000 }));
|
|
291
|
+
const approveGate = async (root, taskId, gate, source) => JSON.parse(await runNode(root, [stateScriptPath(root), "gate", "approve", taskId, "--gate", gate, "--source", source, "--root", root], { timeout: 30000 }));
|
|
292
|
+
/* Authority-neutral read model: local reads canonical state in-process; fleet asks
|
|
293
|
+
* `loop-entry.mjs` (the only place that speaks to the coordination service). */
|
|
294
|
+
const readUiState = async (options) => {
|
|
295
|
+
if (uiMode(options) === "local")
|
|
296
|
+
return loadState(options.root);
|
|
297
|
+
const parsed = JSON.parse(await runNode(options.root, loopArgs(options.root, "tasks"), { timeout: 15000 }));
|
|
298
|
+
if (!Array.isArray(parsed.tasks))
|
|
299
|
+
throw new Error("Fleet task list is unavailable");
|
|
300
|
+
return { schemaVersion: 4, tasks: Object.fromEntries(parsed.tasks.map((task) => [task.id, task])), archive: {} };
|
|
301
|
+
};
|
|
302
|
+
const readUiTask = (options, id, state) => uiMode(options) === "local" ? loadTask(options.root, id, state) : state.tasks[id];
|
|
303
|
+
const fleetGate = async (options, id) => JSON.parse(await runNode(options.root, loopArgs(options.root, "gate", id), { timeout: 15000 }));
|
|
304
|
+
const childError = (error) => {
|
|
305
|
+
if (error && typeof error === "object" && "stderr" in error) {
|
|
306
|
+
const stderr = String(error.stderr ?? "").trim();
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(stderr);
|
|
309
|
+
if (parsed.error?.message)
|
|
310
|
+
return parsed.error.message;
|
|
311
|
+
}
|
|
312
|
+
catch { /* not JSON */ }
|
|
313
|
+
if (stderr)
|
|
314
|
+
return stderr;
|
|
315
|
+
}
|
|
316
|
+
return error instanceof Error ? error.message : String(error);
|
|
317
|
+
};
|
|
318
|
+
const taskGatePayload = async (options, id) => {
|
|
319
|
+
const state = await readUiState(options);
|
|
320
|
+
const task = readUiTask(options, id, state);
|
|
321
|
+
if (!task)
|
|
322
|
+
return { ok: false, error: `Task not found: ${id}` };
|
|
323
|
+
if (task.status !== "blocked_on_user" || task.gate === "none")
|
|
324
|
+
return { ok: false, error: "Task is not waiting at a human gate" };
|
|
325
|
+
if (uiMode(options) === "fleet") {
|
|
326
|
+
const gate = await fleetGate(options, id);
|
|
327
|
+
return gate.snapshot ? { ok: true, gate: task.gate, snapshot: gate.snapshot } : { ok: false, error: "No current review snapshot for this gate yet" };
|
|
328
|
+
}
|
|
329
|
+
const diagnostics = checkGate(options.root, state, id, task.gate);
|
|
330
|
+
return { ok: true, gate: task.gate, view: gateView(task, diagnostics) };
|
|
331
|
+
};
|
|
332
|
+
const artifactPayload = (root, id, name, task = loadTask(root, id)) => {
|
|
333
|
+
if (!ARTIFACT_NAMES.includes(name))
|
|
334
|
+
return { ok: false, error: `Unknown artifact: ${name}` };
|
|
335
|
+
if (!task)
|
|
336
|
+
return { ok: false, error: `Task not found: ${id}` };
|
|
337
|
+
const artifactPath = task.artifacts?.[name];
|
|
338
|
+
if (!artifactPath)
|
|
339
|
+
return { ok: false, error: `Artifact ${name} not recorded for ${id}` };
|
|
340
|
+
assertTaskArtifactPath(id, artifactPath);
|
|
341
|
+
try {
|
|
342
|
+
return { ok: true, name, content: readFileSync(join(root, artifactPath), "utf8") };
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return { ok: false, error: `Artifact ${name} is missing for ${id}` };
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const pageHead = (title) => `<!doctype html>
|
|
349
|
+
<html lang="en">
|
|
350
|
+
<head>
|
|
351
|
+
<meta charset="utf-8">
|
|
352
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
353
|
+
<title>${escapeHtml(title)}</title>
|
|
354
|
+
<meta name="color-scheme" content="light dark">
|
|
355
|
+
<link rel="stylesheet" href="/assets/bulma.css">
|
|
356
|
+
<link rel="stylesheet" href="/assets/dashboard.css">
|
|
357
|
+
</head>
|
|
358
|
+
<body>`;
|
|
359
|
+
/* ---------------------------------------------------------------------------
|
|
360
|
+
* Markdown renderer (minimal, escaping-first, no runtime dependency).
|
|
361
|
+
* ------------------------------------------------------------------------- */
|
|
362
|
+
const safeUrl = (raw) => {
|
|
363
|
+
const url = raw.trim();
|
|
364
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(url);
|
|
365
|
+
if (scheme && !/^(https?|mailto)$/i.test(scheme[1] ?? ""))
|
|
366
|
+
return "#";
|
|
367
|
+
return escapeHtml(url);
|
|
368
|
+
};
|
|
369
|
+
const renderInline = (text) => {
|
|
370
|
+
let out = escapeHtml(text);
|
|
371
|
+
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
372
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
373
|
+
out = out.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
374
|
+
out = out.replace(/\*([^*\s][^*]*)\*/g, "<em>$1</em>");
|
|
375
|
+
out = out.replace(/(^|[^\w])_([^_\s][^_]*)_(?![\w])/g, "$1<em>$2</em>");
|
|
376
|
+
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, url) => `<a href="${safeUrl(url)}">${label}</a>`);
|
|
377
|
+
return out;
|
|
378
|
+
};
|
|
379
|
+
const splitRow = (line) => line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|");
|
|
380
|
+
const isTableSeparator = (line) => /^[\s|:-]+$/.test(line.trim()) && line.includes("-");
|
|
381
|
+
/** Renders a small subset of Markdown to escaped HTML. Exported for tests. */
|
|
382
|
+
export const markdownToHtml = (markdown) => {
|
|
383
|
+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
384
|
+
const html = [];
|
|
385
|
+
let i = 0;
|
|
386
|
+
let listKind = null;
|
|
387
|
+
let paragraph = [];
|
|
388
|
+
const flushParagraph = () => {
|
|
389
|
+
if (paragraph.length) {
|
|
390
|
+
html.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
|
|
391
|
+
paragraph = [];
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
const closeList = () => {
|
|
395
|
+
if (listKind) {
|
|
396
|
+
html.push(`</${listKind}>`);
|
|
397
|
+
listKind = null;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
while (i < lines.length) {
|
|
401
|
+
const line = lines[i] ?? "";
|
|
402
|
+
const trimmed = line.trim();
|
|
403
|
+
if (trimmed.startsWith("```")) {
|
|
404
|
+
flushParagraph();
|
|
405
|
+
closeList();
|
|
406
|
+
const codeLines = [];
|
|
407
|
+
i += 1;
|
|
408
|
+
while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) {
|
|
409
|
+
codeLines.push(lines[i] ?? "");
|
|
410
|
+
i += 1;
|
|
411
|
+
}
|
|
412
|
+
i += 1;
|
|
413
|
+
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
|
|
417
|
+
if (heading) {
|
|
418
|
+
flushParagraph();
|
|
419
|
+
closeList();
|
|
420
|
+
const level = (heading[1] ?? "#").length;
|
|
421
|
+
html.push(`<h${level}>${renderInline(heading[2] ?? "")}</h${level}>`);
|
|
422
|
+
i += 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (/^([-*_])(\s*\1){2,}\s*$/.test(trimmed)) {
|
|
426
|
+
flushParagraph();
|
|
427
|
+
closeList();
|
|
428
|
+
html.push("<hr>");
|
|
429
|
+
i += 1;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (trimmed.startsWith(">")) {
|
|
433
|
+
flushParagraph();
|
|
434
|
+
closeList();
|
|
435
|
+
const quote = [];
|
|
436
|
+
while (i < lines.length && (lines[i] ?? "").trim().startsWith(">")) {
|
|
437
|
+
quote.push((lines[i] ?? "").trim().replace(/^>\s?/, ""));
|
|
438
|
+
i += 1;
|
|
439
|
+
}
|
|
440
|
+
html.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (trimmed.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1] ?? "")) {
|
|
444
|
+
flushParagraph();
|
|
445
|
+
closeList();
|
|
446
|
+
const header = splitRow(trimmed);
|
|
447
|
+
i += 2;
|
|
448
|
+
const rows = [];
|
|
449
|
+
while (i < lines.length && (lines[i] ?? "").trim().includes("|")) {
|
|
450
|
+
rows.push(splitRow((lines[i] ?? "").trim()));
|
|
451
|
+
i += 1;
|
|
452
|
+
}
|
|
453
|
+
const cols = header.length;
|
|
454
|
+
const ths = header.map((c) => `<th>${renderInline(c.trim())}</th>`).join("");
|
|
455
|
+
const trs = rows.map((r) => `<tr>${Array.from({ length: cols }, (_x, k) => `<td>${renderInline((r[k] ?? "").trim())}</td>`).join("")}</tr>`).join("");
|
|
456
|
+
html.push(`<table class="table is-fullwidth is-striped is-bordered"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table>`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
460
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
461
|
+
if (ul || ol) {
|
|
462
|
+
flushParagraph();
|
|
463
|
+
const kind = ol ? "ol" : "ul";
|
|
464
|
+
if (listKind !== kind) {
|
|
465
|
+
closeList();
|
|
466
|
+
html.push(`<${kind}>`);
|
|
467
|
+
listKind = kind;
|
|
468
|
+
}
|
|
469
|
+
html.push(`<li>${renderInline((ul?.[1] ?? ol?.[1] ?? "").trim())}</li>`);
|
|
470
|
+
i += 1;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (trimmed === "") {
|
|
474
|
+
flushParagraph();
|
|
475
|
+
closeList();
|
|
476
|
+
i += 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
closeList();
|
|
480
|
+
paragraph.push(trimmed);
|
|
481
|
+
i += 1;
|
|
482
|
+
}
|
|
483
|
+
flushParagraph();
|
|
484
|
+
closeList();
|
|
485
|
+
return html.join("\n");
|
|
486
|
+
};
|
|
487
|
+
const summarize = (counts) => Object.entries(counts ?? {}).map(([k, n]) => `${n} ${k}`).join(" · ") || "none";
|
|
488
|
+
/* ---------------------------------------------------------------------------
|
|
489
|
+
* Presentation helpers: labels, pills, phase stepper, relative time.
|
|
490
|
+
* Colour is never the only signal; every pill carries a text label.
|
|
491
|
+
* ------------------------------------------------------------------------- */
|
|
492
|
+
const GATE_LABELS = {
|
|
493
|
+
G0_confirm: "Confirm requirements",
|
|
494
|
+
G1_review: "Approve plan",
|
|
495
|
+
G2_codereview: "Review implementation",
|
|
496
|
+
};
|
|
497
|
+
const PHASE_GATE = { clarify: "G0_confirm", plan: "G1_review", build: "G2_codereview" };
|
|
498
|
+
const STATUS_META = {
|
|
499
|
+
active: { label: "Active", tone: "is-info" },
|
|
500
|
+
blocked_on_user: { label: "Needs review", tone: "is-warning" },
|
|
501
|
+
paused: { label: "Paused", tone: "" },
|
|
502
|
+
done: { label: "Done", tone: "is-success" },
|
|
503
|
+
closed: { label: "Closed", tone: "is-danger" },
|
|
504
|
+
superseded: { label: "Superseded", tone: "is-danger" },
|
|
505
|
+
};
|
|
506
|
+
const RISK_TONE = { low: "is-success", normal: "", high: "is-warning", regulated: "is-danger" };
|
|
507
|
+
const RESULT_TONE = { pass: "is-success", fail: "is-danger", skip: "is-warning" };
|
|
508
|
+
const DECISION_TONE = { approved: "is-success", changed: "is-info", dropped: "is-ghost", unresolved: "is-warning" };
|
|
509
|
+
const ITEM_TONE = { done: "is-success", in_progress: "is-info", todo: "", deferred: "is-warning" };
|
|
510
|
+
const gateLabel = (gate) => GATE_LABELS[gate] ?? gate;
|
|
511
|
+
const pill = (label, tone = "", title = "", dot = true) => `<span class="ad-pill ${tone}"${title ? ` title="${escapeHtml(title)}"` : ""}>${dot ? '<span class="ad-dot"></span>' : ""}${escapeHtml(label)}</span>`;
|
|
512
|
+
const statusPill = (status, handedOff = false) => {
|
|
513
|
+
if (handedOff)
|
|
514
|
+
return pill("Handed off", "is-danger", `status: ${status} · handed off`);
|
|
515
|
+
const meta = STATUS_META[status] ?? { label: status, tone: "" };
|
|
516
|
+
return pill(meta.label, meta.tone, `status: ${status}`);
|
|
517
|
+
};
|
|
518
|
+
const gatePill = (gate) => gate && gate !== "none" ? pill(gateLabel(gate), "is-ghost", `gate: ${gate}`, false) : "";
|
|
519
|
+
const riskPill = (risk) => pill(`${risk} risk`, RISK_TONE[risk] ?? "", `risk: ${risk}`);
|
|
520
|
+
const phaseSteps = (phase) => {
|
|
521
|
+
const current = PHASES.indexOf(phase);
|
|
522
|
+
const done = phase === "done";
|
|
523
|
+
const bars = PHASES.map((name, index) => {
|
|
524
|
+
const state = done || index < current ? "is-complete" : index === current ? "is-current" : "";
|
|
525
|
+
return `<span class="${state}" title="${escapeHtml(name)}"></span>`;
|
|
526
|
+
}).join("");
|
|
527
|
+
return `<div class="ad-steps${done ? " is-done" : ""}" role="img" aria-label="Phase ${current + 1} of ${PHASES.length}: ${escapeHtml(phase)}">${bars}</div>`;
|
|
528
|
+
};
|
|
529
|
+
/** Horizontal phase stepper shown above the artifacts (detail page and drawer). */
|
|
530
|
+
const phaseStepper = (phase, gate, status) => {
|
|
531
|
+
const current = PHASES.indexOf(phase);
|
|
532
|
+
return `<ol class="ad-stepper" aria-label="Progress">${PHASES.map((name, index) => {
|
|
533
|
+
const complete = phase === "done" || index < current;
|
|
534
|
+
const isCurrent = !complete && index === current;
|
|
535
|
+
const phaseGate = PHASE_GATE[name];
|
|
536
|
+
const note = isCurrent && status === "blocked_on_user" && gate !== "none"
|
|
537
|
+
? `waiting at ${gateLabel(gate)}`
|
|
538
|
+
: phaseGate ? `gate · ${gateLabel(phaseGate)}` : name === "done" ? "complete" : "no gate";
|
|
539
|
+
return `<li class="${complete ? "is-complete" : isCurrent ? "is-current" : ""}"${isCurrent ? ' aria-current="step"' : ""}><span class="ad-stepper-marker">${complete ? "✓" : index + 1}</span><span class="ad-stepper-text"><strong>${escapeHtml(name)}</strong><small>${escapeHtml(note)}</small></span></li>`;
|
|
540
|
+
}).join("")}</ol>`;
|
|
541
|
+
};
|
|
542
|
+
/** Server-rendered relative time; the absolute ISO value stays in the title. */
|
|
543
|
+
const relativeTime = (iso) => {
|
|
544
|
+
if (!iso)
|
|
545
|
+
return "";
|
|
546
|
+
const at = Date.parse(iso);
|
|
547
|
+
if (Number.isNaN(at))
|
|
548
|
+
return escapeHtml(iso);
|
|
549
|
+
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
550
|
+
const text = seconds < 60 ? "just now"
|
|
551
|
+
: seconds < 3600 ? `${Math.floor(seconds / 60)}m ago`
|
|
552
|
+
: seconds < 86400 ? `${Math.floor(seconds / 3600)}h ago`
|
|
553
|
+
: `${Math.floor(seconds / 86400)}d ago`;
|
|
554
|
+
return `<time datetime="${escapeHtml(iso)}" title="${escapeHtml(iso)}">${text}</time>`;
|
|
555
|
+
};
|
|
556
|
+
const renderGateViewHtml = (view) => {
|
|
557
|
+
const v = view;
|
|
558
|
+
const task = v.task ?? {};
|
|
559
|
+
const artifacts = v.artifacts ?? [];
|
|
560
|
+
const evidence = v.evidence ?? [];
|
|
561
|
+
const diagnostics = v.diagnostics ?? [];
|
|
562
|
+
const levelTone = (level) => level === "ERROR" ? "is-danger" : level === "WARN" ? "is-warning" : "is-info";
|
|
563
|
+
const cell = (label, value) => `<div class="ad-gate-cell"><small>${escapeHtml(label)}</small><span>${value}</span></div>`;
|
|
564
|
+
return `<section class="ad-panel is-warning">
|
|
565
|
+
<header class="ad-panel-head"><span aria-hidden="true">${escapeHtml(String(v.icon ?? ""))}</span><h2>${escapeHtml(String(v.title ?? ""))}</h2><span class="ad-pill is-warning ad-panel-tools" title="gate">${escapeHtml(String(v.gate ?? ""))}</span></header>
|
|
566
|
+
<div class="ad-panel-body">
|
|
567
|
+
<div class="ad-gate-grid">
|
|
568
|
+
${cell("Status", escapeHtml(task.status ?? ""))}
|
|
569
|
+
${cell("Risk", escapeHtml(task.risk ?? ""))}
|
|
570
|
+
${cell("Areas", escapeHtml((task.areas ?? []).join(", ")))}
|
|
571
|
+
${cell("Decisions", escapeHtml(summarize(v.decisions)))}
|
|
572
|
+
${cell("Execution", escapeHtml(summarize(v.execution)))}
|
|
573
|
+
</div>
|
|
574
|
+
<h3 class="ad-subhead">Review artifacts</h3>
|
|
575
|
+
${artifacts.length ? `<ul class="ad-plain-list">${artifacts.map((a) => `<li><strong>${escapeHtml(a.name ?? "")}</strong><code>${escapeHtml(a.path ?? "")}</code></li>`).join("")}</ul>` : '<p class="has-text-grey">none</p>'}
|
|
576
|
+
<h3 class="ad-subhead">Verification evidence</h3>
|
|
577
|
+
${evidence.length ? `<ul class="ad-plain-list">${evidence.map((e) => `<li>${pill(e.result ?? "", RESULT_TONE[e.result ?? ""] ?? "")} <strong>${escapeHtml(e.kind ?? "")}</strong> <span class="has-text-grey">${escapeHtml(e.source ?? "")}</span></li>`).join("")}</ul>` : '<p class="has-text-grey">none yet</p>'}
|
|
578
|
+
${diagnostics.length ? `<h3 class="ad-subhead">Diagnostics</h3><ul class="ad-plain-list">${diagnostics.map((d) => `<li>${pill(d.level ?? "", levelTone(d.level))} <code>${escapeHtml(d.code ?? "")}</code> ${escapeHtml(d.message ?? "")}</li>`).join("")}</ul>` : ""}
|
|
579
|
+
<div class="ad-callout"><span aria-hidden="true">→</span><span>Action required: ${escapeHtml(String(v.action ?? ""))}</span></div>
|
|
580
|
+
</div>
|
|
581
|
+
</section>`;
|
|
582
|
+
};
|
|
583
|
+
/* ---------------------------------------------------------------------------
|
|
584
|
+
* Client script shared across dashboard and detail page.
|
|
585
|
+
* All dynamic text is written with textContent; never innerHTML of user data.
|
|
586
|
+
* ------------------------------------------------------------------------- */
|
|
587
|
+
const sharedScript = `<script>
|
|
588
|
+
const storedToken = () => (localStorage.getItem("aidlc-token") || "");
|
|
589
|
+
const setStoredToken = (value) => localStorage.setItem("aidlc-token", value);
|
|
590
|
+
const post = async (url, body) => {
|
|
591
|
+
const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-aidlc-token": storedToken() }, body: JSON.stringify(body) });
|
|
592
|
+
let parsed = {};
|
|
593
|
+
try { parsed = await response.json(); } catch (error) { parsed = { error: "Unexpected response (" + response.status + ")" }; }
|
|
594
|
+
return { status: response.status, body: parsed };
|
|
595
|
+
};
|
|
596
|
+
const toast = (message, kind = "info", detail = "") => {
|
|
597
|
+
const stack = document.getElementById("toasts");
|
|
598
|
+
if (!stack) return;
|
|
599
|
+
const icons = { success: "✓", danger: "!", warning: "!", info: "i" };
|
|
600
|
+
const item = document.createElement("div");
|
|
601
|
+
item.className = "ad-toast is-" + kind;
|
|
602
|
+
item.setAttribute("role", kind === "danger" ? "alert" : "status");
|
|
603
|
+
const icon = document.createElement("span");
|
|
604
|
+
icon.className = "ad-toast-icon";
|
|
605
|
+
icon.textContent = icons[kind] || "i";
|
|
606
|
+
const body = document.createElement("div");
|
|
607
|
+
body.className = "ad-toast-body";
|
|
608
|
+
body.textContent = message;
|
|
609
|
+
if (detail) { const small = document.createElement("small"); small.textContent = detail; body.appendChild(small); }
|
|
610
|
+
const close = document.createElement("button");
|
|
611
|
+
close.className = "ad-toast-close";
|
|
612
|
+
close.setAttribute("aria-label", "dismiss");
|
|
613
|
+
close.textContent = "×";
|
|
614
|
+
const dismiss = () => { item.classList.add("is-leaving"); setTimeout(() => item.remove(), 220); };
|
|
615
|
+
close.addEventListener("click", dismiss);
|
|
616
|
+
item.append(icon, body, close);
|
|
617
|
+
stack.appendChild(item);
|
|
618
|
+
setTimeout(dismiss, kind === "danger" ? 9000 : 4500);
|
|
619
|
+
};
|
|
620
|
+
const failureToast = (res, fallback) => {
|
|
621
|
+
const message = (res.body && res.body.error) || fallback;
|
|
622
|
+
toast(message, "danger", res.status === 401 ? "Open the login link printed by aidlc-kit ui (or paste the token in Settings) and retry." : "HTTP " + res.status);
|
|
623
|
+
};
|
|
624
|
+
const withLoading = async (button, work) => {
|
|
625
|
+
if (button) { button.classList.add("is-loading"); button.disabled = true; }
|
|
626
|
+
try { return await work(); }
|
|
627
|
+
catch (error) { toast("Request failed", "danger", String(error && error.message || error)); return undefined; }
|
|
628
|
+
finally { if (button) { button.classList.remove("is-loading"); button.disabled = false; } }
|
|
629
|
+
};
|
|
630
|
+
const openModal = (id) => {
|
|
631
|
+
const el = document.getElementById(id);
|
|
632
|
+
if (!el) return;
|
|
633
|
+
el.classList.add("is-active");
|
|
634
|
+
document.documentElement.classList.add("is-clipped");
|
|
635
|
+
const focusable = el.querySelector("[data-autofocus]");
|
|
636
|
+
if (focusable) setTimeout(() => focusable.focus(), 30);
|
|
637
|
+
};
|
|
638
|
+
const closeModal = (id) => { const el = document.getElementById(id); if (el) el.classList.remove("is-active"); if (!document.querySelector(".modal.is-active")) document.documentElement.classList.remove("is-clipped"); };
|
|
639
|
+
const closeAllModals = () => { document.querySelectorAll(".modal").forEach((m) => m.classList.remove("is-active")); document.documentElement.classList.remove("is-clipped"); };
|
|
640
|
+
// Task side panel (drawer): slides in from the right, mirrors ?task=<id> in the
|
|
641
|
+
// URL so refresh/share reopens it and Back closes it. Only the newest fetch
|
|
642
|
+
// may write the body when the user switches tasks quickly.
|
|
643
|
+
let drawerRequest = 0;
|
|
644
|
+
let drawerReturnFocus = null;
|
|
645
|
+
const drawerEl = () => document.getElementById("task-drawer");
|
|
646
|
+
// "Open" means shown or opening; a drawer mid-close counts as closed so a quick
|
|
647
|
+
// reopen re-adds is-open instead of being hidden by the pending close timer.
|
|
648
|
+
const drawerOpen = () => { const el = drawerEl(); return !!el && el.classList.contains("is-open"); };
|
|
649
|
+
const drawerTaskFromUrl = () => new URLSearchParams(location.search).get("task");
|
|
650
|
+
const openTaskDrawer = async (id, options = {}) => {
|
|
651
|
+
const el = drawerEl();
|
|
652
|
+
if (!el || !id) return;
|
|
653
|
+
const body = document.getElementById("task-drawer-body");
|
|
654
|
+
const title = document.getElementById("task-drawer-title");
|
|
655
|
+
const link = document.getElementById("task-drawer-open");
|
|
656
|
+
if (title) title.textContent = id;
|
|
657
|
+
if (link) link.href = "/tasks/" + encodeURIComponent(id);
|
|
658
|
+
if (!drawerOpen()) {
|
|
659
|
+
if (el.hidden) drawerReturnFocus = document.activeElement;
|
|
660
|
+
el.hidden = false;
|
|
661
|
+
document.documentElement.classList.add("is-clipped");
|
|
662
|
+
el.classList.add("is-opening");
|
|
663
|
+
requestAnimationFrame(() => { el.classList.add("is-open"); el.classList.remove("is-opening"); });
|
|
664
|
+
const panel = el.querySelector(".ad-drawer-panel");
|
|
665
|
+
if (panel) setTimeout(() => panel.focus(), 30);
|
|
666
|
+
}
|
|
667
|
+
if (options.push !== false && drawerTaskFromUrl() !== id) history.pushState({ task: id }, "", "?task=" + encodeURIComponent(id));
|
|
668
|
+
const request = ++drawerRequest;
|
|
669
|
+
if (body) body.innerHTML = "<p class='has-text-grey'>Loading…</p>";
|
|
670
|
+
let html;
|
|
671
|
+
try {
|
|
672
|
+
const response = await fetch("/partial/tasks/" + encodeURIComponent(id));
|
|
673
|
+
html = response.ok ? await response.text() : "<p class='has-text-danger'>Failed to load task detail.</p>";
|
|
674
|
+
} catch (error) {
|
|
675
|
+
html = "<p class='has-text-danger'>Failed to load task detail.</p>";
|
|
676
|
+
}
|
|
677
|
+
if (request === drawerRequest && body) { body.innerHTML = html; body.scrollTop = 0; }
|
|
678
|
+
};
|
|
679
|
+
const closeTaskDrawer = (options = {}) => {
|
|
680
|
+
const el = drawerEl();
|
|
681
|
+
if (!el || el.hidden) return;
|
|
682
|
+
drawerRequest += 1;
|
|
683
|
+
el.classList.remove("is-open");
|
|
684
|
+
const finish = () => { if (!el.classList.contains("is-open") && !el.classList.contains("is-opening")) el.hidden = true; };
|
|
685
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) finish(); else setTimeout(finish, 200);
|
|
686
|
+
if (!document.querySelector(".modal.is-active")) document.documentElement.classList.remove("is-clipped");
|
|
687
|
+
// Replace (not push) the ?task entry so Back after closing does not reopen it.
|
|
688
|
+
if (options.push !== false && drawerTaskFromUrl()) history.replaceState({}, "", location.pathname);
|
|
689
|
+
if (drawerReturnFocus && typeof drawerReturnFocus.focus === "function") drawerReturnFocus.focus();
|
|
690
|
+
drawerReturnFocus = null;
|
|
691
|
+
};
|
|
692
|
+
window.addEventListener("popstate", () => {
|
|
693
|
+
const id = drawerTaskFromUrl();
|
|
694
|
+
if (id) openTaskDrawer(id, { push: false }); else closeTaskDrawer({ push: false });
|
|
695
|
+
});
|
|
696
|
+
const isTyping = (target) => !!target && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
|
|
697
|
+
const REQUIREMENT_LIMIT = 8192;
|
|
698
|
+
// Login link: \`aidlc-kit ui\` prints /#token=<token>. Store it and drop it from the address bar
|
|
699
|
+
// (a fragment is never sent to the server or in Referer).
|
|
700
|
+
(() => {
|
|
701
|
+
const match = /(?:^#|&)token=([^&]+)/.exec(location.hash);
|
|
702
|
+
if (!match) return;
|
|
703
|
+
try { setStoredToken(decodeURIComponent(match[1])); } catch (error) { /* malformed link */ }
|
|
704
|
+
history.replaceState(history.state, "", location.pathname + location.search);
|
|
705
|
+
})();
|
|
706
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
707
|
+
const tokenInput = document.getElementById("settings-token-input");
|
|
708
|
+
if (tokenInput) tokenInput.value = storedToken();
|
|
709
|
+
const tokenState = document.getElementById("settings-token-state");
|
|
710
|
+
const renderTokenState = () => { if (tokenState) tokenState.textContent = storedToken() ? "Token saved in this browser" : "No token saved"; };
|
|
711
|
+
renderTokenState();
|
|
712
|
+
document.querySelectorAll("[data-open]").forEach((el) => el.addEventListener("click", () => openModal(el.getAttribute("data-open"))));
|
|
713
|
+
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeAllModals()));
|
|
714
|
+
document.querySelectorAll("[data-drawer-close]").forEach((el) => el.addEventListener("click", () => closeTaskDrawer()));
|
|
715
|
+
document.addEventListener("keydown", (event) => {
|
|
716
|
+
if (event.key === "Escape") { if (document.querySelector(".modal.is-active")) closeAllModals(); else closeTaskDrawer(); return; }
|
|
717
|
+
if (event.key === "Tab" && drawerOpen() && !document.querySelector(".modal.is-active")) {
|
|
718
|
+
const panel = drawerEl().querySelector(".ad-drawer-panel");
|
|
719
|
+
const focusable = [...panel.querySelectorAll("a[href], button:not([disabled]), input, select, textarea, summary, [tabindex]:not([tabindex='-1'])")].filter((node) => node.offsetParent !== null);
|
|
720
|
+
if (!focusable.length) { event.preventDefault(); panel.focus(); return; }
|
|
721
|
+
const first = focusable[0], last = focusable[focusable.length - 1];
|
|
722
|
+
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus(); }
|
|
723
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (event.ctrlKey || event.metaKey || event.altKey || isTyping(event.target) || document.querySelector(".modal.is-active") || drawerOpen()) return;
|
|
727
|
+
if (event.key === "n" && document.getElementById("new-task-modal")) { event.preventDefault(); openModal("new-task-modal"); }
|
|
728
|
+
else if (event.key === "/") { const search = document.getElementById("task-search"); if (search) { event.preventDefault(); search.focus(); search.select(); } }
|
|
729
|
+
});
|
|
730
|
+
const saveBtn = document.getElementById("settings-token-save");
|
|
731
|
+
if (saveBtn) saveBtn.addEventListener("click", () => { if (tokenInput) setStoredToken(tokenInput.value.trim()); renderTokenState(); closeModal("settings-modal"); toast("Token saved", "success"); });
|
|
732
|
+
const clearBtn = document.getElementById("settings-token-clear");
|
|
733
|
+
if (clearBtn) clearBtn.addEventListener("click", () => { setStoredToken(""); if (tokenInput) tokenInput.value = ""; renderTokenState(); toast("Token cleared", "info"); });
|
|
734
|
+
|
|
735
|
+
const requirement = document.getElementById("new-task-requirement");
|
|
736
|
+
const counter = document.getElementById("new-task-count");
|
|
737
|
+
const startBtn = document.getElementById("new-task-submit");
|
|
738
|
+
const updateCounter = () => {
|
|
739
|
+
if (!requirement || !counter) return;
|
|
740
|
+
const used = new TextEncoder().encode(requirement.value).length;
|
|
741
|
+
counter.textContent = used + " / " + REQUIREMENT_LIMIT + " bytes";
|
|
742
|
+
counter.classList.toggle("is-over", used > REQUIREMENT_LIMIT);
|
|
743
|
+
};
|
|
744
|
+
if (requirement) {
|
|
745
|
+
requirement.addEventListener("input", updateCounter);
|
|
746
|
+
requirement.addEventListener("keydown", (event) => { if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && startBtn) { event.preventDefault(); startBtn.click(); } });
|
|
747
|
+
updateCounter();
|
|
748
|
+
}
|
|
749
|
+
if (startBtn) startBtn.addEventListener("click", () => withLoading(startBtn, async () => {
|
|
750
|
+
const value = requirement ? requirement.value : "";
|
|
751
|
+
if (!value.trim()) { toast("Describe the requirement first", "warning"); if (requirement) requirement.focus(); return; }
|
|
752
|
+
const res = await post("/api/loop/start", { requirement: value });
|
|
753
|
+
if (res.status >= 200 && res.status < 300) {
|
|
754
|
+
const daemon = res.body.daemon || {};
|
|
755
|
+
toast("Task " + res.body.taskId + " created", "success", daemon.started ? "Started the loop daemon (pid " + daemon.pid + ")" : "Picked up by the running daemon");
|
|
756
|
+
closeAllModals();
|
|
757
|
+
setTimeout(() => location.reload(), 1200);
|
|
758
|
+
} else failureToast(res, "Could not create the task");
|
|
759
|
+
}));
|
|
760
|
+
|
|
761
|
+
document.querySelectorAll("[data-task-link]").forEach((link) => {
|
|
762
|
+
link.addEventListener("click", (event) => {
|
|
763
|
+
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
|
764
|
+
event.preventDefault();
|
|
765
|
+
openTaskDrawer(link.getAttribute("data-task-link"));
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
const initialTask = drawerTaskFromUrl();
|
|
769
|
+
if (initialTask && drawerEl()) openTaskDrawer(initialTask, { push: false });
|
|
770
|
+
|
|
771
|
+
const viewKey = "aidlc-view";
|
|
772
|
+
const applyView = (view) => {
|
|
773
|
+
const kanban = document.getElementById("kanban-view");
|
|
774
|
+
const list = document.getElementById("list-view");
|
|
775
|
+
if (kanban) kanban.style.display = view === "kanban" ? "" : "none";
|
|
776
|
+
if (list) list.style.display = view === "list" ? "" : "none";
|
|
777
|
+
document.querySelectorAll("[data-view-toggle]").forEach((el) => {
|
|
778
|
+
const active = el.getAttribute("data-view-toggle") === view;
|
|
779
|
+
el.classList.toggle("is-active", active);
|
|
780
|
+
el.setAttribute("aria-pressed", String(active));
|
|
781
|
+
});
|
|
782
|
+
};
|
|
783
|
+
document.querySelectorAll("[data-view-toggle]").forEach((el) => el.addEventListener("click", () => { const v = el.getAttribute("data-view-toggle"); localStorage.setItem(viewKey, v); applyView(v); }));
|
|
784
|
+
applyView(localStorage.getItem(viewKey) || "kanban");
|
|
785
|
+
|
|
786
|
+
// Client-side filtering over server-rendered cards and rows (no extra API calls).
|
|
787
|
+
const search = document.getElementById("task-search");
|
|
788
|
+
const statusFilter = document.getElementById("filter-status");
|
|
789
|
+
const riskFilter = document.getElementById("filter-risk");
|
|
790
|
+
const resultCount = document.getElementById("result-count");
|
|
791
|
+
const applyFilter = () => {
|
|
792
|
+
const terms = (search ? search.value : "").toLowerCase().split(/\\s+/).filter(Boolean);
|
|
793
|
+
const status = statusFilter ? statusFilter.value : "";
|
|
794
|
+
const risk = riskFilter ? riskFilter.value : "";
|
|
795
|
+
const matches = (el) => (!status || el.getAttribute("data-status") === status) && (!risk || el.getAttribute("data-risk") === risk) && terms.every((term) => (el.getAttribute("data-search") || "").includes(term));
|
|
796
|
+
let visible = 0;
|
|
797
|
+
document.querySelectorAll("#kanban-view [data-task-item]").forEach((el) => { const show = matches(el); el.hidden = !show; if (show) visible += 1; });
|
|
798
|
+
document.querySelectorAll("#list-view [data-task-item]").forEach((el) => { el.hidden = !matches(el); });
|
|
799
|
+
document.querySelectorAll("[data-phase-column]").forEach((column) => {
|
|
800
|
+
const count = column.querySelectorAll("[data-task-item]:not([hidden])").length;
|
|
801
|
+
const badge = column.querySelector("[data-column-count]");
|
|
802
|
+
if (badge) badge.textContent = String(count);
|
|
803
|
+
const empty = column.querySelector("[data-column-empty]");
|
|
804
|
+
if (empty) empty.hidden = count > 0;
|
|
805
|
+
});
|
|
806
|
+
const total = document.querySelectorAll("#kanban-view [data-task-item]").length;
|
|
807
|
+
const filtered = terms.length || status || risk;
|
|
808
|
+
if (resultCount) resultCount.textContent = filtered ? visible + " of " + total + " tasks" : total + (total === 1 ? " task" : " tasks");
|
|
809
|
+
document.querySelectorAll("[data-no-match]").forEach((el) => el.classList.toggle("is-visible", !!filtered && visible === 0));
|
|
810
|
+
document.querySelectorAll("[data-stat-filter]").forEach((el) => el.setAttribute("aria-pressed", String(el.getAttribute("data-stat-filter") === status && !terms.length && !risk)));
|
|
811
|
+
};
|
|
812
|
+
[search, statusFilter, riskFilter].forEach((el) => { if (el) el.addEventListener("input", applyFilter); });
|
|
813
|
+
document.querySelectorAll("[data-stat-filter]").forEach((el) => el.addEventListener("click", () => {
|
|
814
|
+
if (!statusFilter) return;
|
|
815
|
+
const value = el.getAttribute("data-stat-filter") || "";
|
|
816
|
+
statusFilter.value = statusFilter.value === value ? "" : value;
|
|
817
|
+
if (search) search.value = "";
|
|
818
|
+
if (riskFilter) riskFilter.value = "";
|
|
819
|
+
applyFilter();
|
|
820
|
+
}));
|
|
821
|
+
const clearFilters = document.getElementById("filter-clear");
|
|
822
|
+
if (clearFilters) clearFilters.addEventListener("click", () => { if (search) search.value = ""; if (statusFilter) statusFilter.value = ""; if (riskFilter) riskFilter.value = ""; applyFilter(); });
|
|
823
|
+
if (search || statusFilter) applyFilter();
|
|
824
|
+
});
|
|
825
|
+
document.addEventListener("click", (event) => {
|
|
826
|
+
const button = event.target.closest("[data-resume]");
|
|
827
|
+
if (!button) return;
|
|
828
|
+
event.preventDefault();
|
|
829
|
+
const taskId = button.getAttribute("data-resume");
|
|
830
|
+
withLoading(button, async () => {
|
|
831
|
+
const res = await post("/api/loop/resume", { taskId });
|
|
832
|
+
if (res.status >= 200 && res.status < 300) { toast("Resumed " + taskId, "success", "The daemon will pick it up on its next poll"); setTimeout(() => location.reload(), 1200); }
|
|
833
|
+
else failureToast(res, "Could not resume " + taskId);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
</script>`;
|
|
837
|
+
/** Per-status action: a paused task can be re-activated for the daemon; a task
|
|
838
|
+
* waiting at a human gate links to the detail page that hosts gate approval. */
|
|
839
|
+
const taskAction = (task, handedOff = false) => {
|
|
840
|
+
if (handedOff)
|
|
841
|
+
return `<a class="button is-small is-danger ad-soft" href="/tasks/${encodeURIComponent(task.id)}" title="handed off: follow the handoff actions">handoff</a>`;
|
|
842
|
+
if (task.status === "paused")
|
|
843
|
+
return `<button class="button is-small is-link ad-soft" data-resume="${escapeHtml(task.id)}">resume</button>`;
|
|
844
|
+
if (task.status === "blocked_on_user")
|
|
845
|
+
return `<a class="button is-small is-warning" href="/tasks/${encodeURIComponent(task.id)}">approve</a>`;
|
|
846
|
+
return "";
|
|
847
|
+
};
|
|
848
|
+
const renderDaemonPill = (root) => {
|
|
849
|
+
const daemon = daemonStatus(root);
|
|
850
|
+
return daemon.running
|
|
851
|
+
? `<span class="ad-pill is-success is-live" title="started ${escapeHtml(daemon.startedAt ?? "")}"><span class="ad-dot"></span>daemon running · pid ${daemon.pid}</span>`
|
|
852
|
+
: `<span class="ad-pill is-ghost" title="start it with: aidlc-kit loop start (or New task)"><span class="ad-dot"></span>daemon stopped</span>`;
|
|
853
|
+
};
|
|
854
|
+
const renderHeader = (root, mode = "local") => `<header class="ad-header">
|
|
855
|
+
<div class="container is-fluid ad-header-inner">
|
|
856
|
+
<a class="ad-brand" href="/" title="Loop · AI-DLC dashboard"><span class="ad-logo" aria-hidden="true">L</span>Loop</a>
|
|
857
|
+
<span class="ad-pill ${mode === "fleet" ? "is-link" : "is-ghost"}" data-authority="${mode}" title="${mode === "fleet" ? "Canonical state lives in the coordination service" : "Canonical state lives in this checkout"}">authority · ${mode}</span>
|
|
858
|
+
${renderDaemonPill(root)}
|
|
859
|
+
<div class="ad-header-actions">
|
|
860
|
+
<button class="button is-small" data-open="settings-modal" title="Settings">⚙<span class="ad-hide-mobile ml-1">Settings</span></button>
|
|
861
|
+
<button class="button is-small is-primary" data-open="new-task-modal">+ New task<kbd class="ad-hide-mobile">N</kbd></button>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
</header>`;
|
|
865
|
+
/** Lower-cased haystack for client-side search (escaped when rendered). */
|
|
866
|
+
const searchText = (task) => [task.id, task.title, task.phase, task.gate, gateLabel(task.gate), task.status, STATUS_META[task.status]?.label ?? "", task.risk, ...task.areas].join(" ").toLowerCase();
|
|
867
|
+
const itemAttrs = (task, handedOff) => `data-task-item data-status="${escapeHtml(task.status)}" data-risk="${escapeHtml(task.risk)}"${handedOff ? ' data-handoff="true"' : ""} data-search="${escapeHtml(searchText(task))}"`;
|
|
868
|
+
const renderTaskCard = (task, handedOff = false) => {
|
|
869
|
+
const href = `/tasks/${encodeURIComponent(task.id)}`;
|
|
870
|
+
const attention = task.status === "blocked_on_user" && !handedOff;
|
|
871
|
+
return `<article class="ad-card${attention ? " is-attention" : ""}" ${itemAttrs(task, handedOff)}>
|
|
872
|
+
<div class="ad-card-top"><a class="ad-card-id" href="${href}" data-task-link="${escapeHtml(task.id)}" title="${escapeHtml(task.id)}">${escapeHtml(task.id)}</a>${statusPill(task.status, handedOff)}</div>
|
|
873
|
+
<div class="ad-card-title"><a href="${href}" data-task-link="${escapeHtml(task.id)}">${escapeHtml(task.title)}</a></div>
|
|
874
|
+
${phaseSteps(task.phase)}
|
|
875
|
+
<div class="ad-card-meta">${gatePill(task.gate)}${riskPill(task.risk)}</div>
|
|
876
|
+
<div class="ad-card-foot"><span class="ad-areas" title="areas: ${escapeHtml(task.areas.join(", "))}">${escapeHtml(task.areas.join(", "))}</span>${task.updatedAt ? `<span>· ${relativeTime(task.updatedAt)}</span>` : ""}${taskAction(task, handedOff)}</div>
|
|
877
|
+
</article>`;
|
|
878
|
+
};
|
|
879
|
+
const renderListRow = (task, handedOff) => `<tr ${itemAttrs(task, handedOff)}>
|
|
880
|
+
<td><a href="/tasks/${encodeURIComponent(task.id)}" data-task-link="${escapeHtml(task.id)}"><code>${escapeHtml(task.id)}</code></a></td>
|
|
881
|
+
<td class="has-text-weight-semibold">${escapeHtml(task.title)}</td>
|
|
882
|
+
<td><div class="is-size-7 mb-1">${escapeHtml(task.phase)}</div>${phaseSteps(task.phase)}</td>
|
|
883
|
+
<td>${gatePill(task.gate) || '<span class="has-text-grey">—</span>'}</td>
|
|
884
|
+
<td>${statusPill(task.status, handedOff)}</td>
|
|
885
|
+
<td>${riskPill(task.risk)}</td>
|
|
886
|
+
<td class="is-size-7">${escapeHtml(task.areas.join(", "))}</td>
|
|
887
|
+
<td class="is-size-7 has-text-grey">${relativeTime(task.updatedAt)}</td>
|
|
888
|
+
<td class="has-text-right">${taskAction(task, handedOff)}</td>
|
|
889
|
+
</tr>`;
|
|
890
|
+
const renderStats = (tasks, handedOff) => {
|
|
891
|
+
const count = (predicate) => tasks.filter(predicate).length;
|
|
892
|
+
const stat = (label, value, tone, filter) => `<button type="button" class="ad-stat ${tone}" data-stat-filter="${filter}" aria-pressed="false" title="${filter ? `Filter: ${escapeHtml(filter)}` : "Show all"}"><span class="ad-stat-label"><span class="ad-dot"></span>${escapeHtml(label)}</span><span class="ad-stat-value">${value}</span></button>`;
|
|
893
|
+
return `<div class="ad-stats">
|
|
894
|
+
${stat("Total", tasks.length, "is-primary", "")}
|
|
895
|
+
${stat("Active", count((t) => t.status === "active"), "is-info", "active")}
|
|
896
|
+
${stat("Needs review", count((t) => t.status === "blocked_on_user" && !handedOff.has(t.id)), "is-warning", "blocked_on_user")}
|
|
897
|
+
${stat("Paused", count((t) => t.status === "paused"), "", "paused")}
|
|
898
|
+
${stat("Done", count((t) => t.status === "done"), "is-success", "done")}
|
|
899
|
+
</div>`;
|
|
900
|
+
};
|
|
901
|
+
const renderReviewQueue = (tasks, handedOff) => {
|
|
902
|
+
const waiting = tasks.filter((task) => task.status === "blocked_on_user" && task.gate !== "none" && !handedOff.has(task.id));
|
|
903
|
+
if (!waiting.length)
|
|
904
|
+
return "";
|
|
905
|
+
return `<section class="ad-attention" aria-labelledby="review-queue-title">
|
|
906
|
+
<div class="ad-attention-head"><span aria-hidden="true">⏳</span><span id="review-queue-title">Needs your review</span><span class="ad-count">${waiting.length}</span></div>
|
|
907
|
+
<ul class="ad-attention-list">${waiting.map((task) => `<li class="ad-attention-item">
|
|
908
|
+
${pill(gateLabel(task.gate), "is-warning", `gate: ${task.gate}`, false)}
|
|
909
|
+
<code>${escapeHtml(task.id)}</code>
|
|
910
|
+
<span class="ad-title">${escapeHtml(task.title)}</span>
|
|
911
|
+
<span class="is-size-7 has-text-grey">${relativeTime(task.updatedAt)}</span>
|
|
912
|
+
<span class="ad-actions"><a class="button is-small is-warning ad-soft" href="/tasks/${encodeURIComponent(task.id)}">Open review →</a></span>
|
|
913
|
+
</li>`).join("")}</ul>
|
|
914
|
+
</section>`;
|
|
915
|
+
};
|
|
916
|
+
const renderToolbar = () => `<div class="ad-toolbar">
|
|
917
|
+
<div class="ad-search">
|
|
918
|
+
<svg class="ad-search-icon" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="8.5" cy="8.5" r="5.5"/><path d="m13 13 4 4"/></svg>
|
|
919
|
+
<input id="task-search" class="input" type="search" placeholder="Search id, title, area…" aria-label="Search tasks" autocomplete="off">
|
|
920
|
+
<kbd>/</kbd>
|
|
921
|
+
</div>
|
|
922
|
+
<div class="select"><select id="filter-status" aria-label="Filter by status">
|
|
923
|
+
<option value="">All statuses</option>
|
|
924
|
+
${Object.entries(STATUS_META).map(([value, meta]) => `<option value="${value}">${escapeHtml(meta.label)}</option>`).join("")}
|
|
925
|
+
</select></div>
|
|
926
|
+
<div class="select"><select id="filter-risk" aria-label="Filter by risk">
|
|
927
|
+
<option value="">Any risk</option>
|
|
928
|
+
${Object.keys(RISK_TONE).map((risk) => `<option value="${risk}">${risk}</option>`).join("")}
|
|
929
|
+
</select></div>
|
|
930
|
+
<div class="ad-toolbar-end">
|
|
931
|
+
<span id="result-count" class="ad-result-count" aria-live="polite"></span>
|
|
932
|
+
<div class="ad-segmented" role="group" aria-label="View">
|
|
933
|
+
<button type="button" data-view-toggle="kanban" aria-pressed="true">▦ Kanban</button>
|
|
934
|
+
<button type="button" data-view-toggle="list" aria-pressed="false">☰ List</button>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
</div>`;
|
|
938
|
+
const renderModals = () => `
|
|
939
|
+
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
|
940
|
+
<div class="modal-background" data-close></div>
|
|
941
|
+
<div class="modal-card">
|
|
942
|
+
<header class="modal-card-head">
|
|
943
|
+
<p class="modal-card-title" id="settings-title">Settings</p>
|
|
944
|
+
<button class="delete" aria-label="close" data-close></button>
|
|
945
|
+
</header>
|
|
946
|
+
<section class="modal-card-body">
|
|
947
|
+
<div class="field">
|
|
948
|
+
<label class="label" for="settings-token-input">Session token</label>
|
|
949
|
+
<div class="control"><input id="settings-token-input" class="input" type="password" placeholder="paste the token printed by aidlc-kit ui" autocomplete="off" data-autofocus></div>
|
|
950
|
+
<p class="help">Printed once in the terminal that started this server. Required for New task, resume, and gate approve. Stored only in this browser's localStorage.</p>
|
|
951
|
+
</div>
|
|
952
|
+
</section>
|
|
953
|
+
<footer class="modal-card-foot">
|
|
954
|
+
<span class="ad-foot-start" id="settings-token-state"></span>
|
|
955
|
+
<button id="settings-token-clear" class="button is-light">Clear</button>
|
|
956
|
+
<button id="settings-token-save" class="button is-primary">Save</button>
|
|
957
|
+
</footer>
|
|
958
|
+
</div>
|
|
959
|
+
</div>
|
|
960
|
+
<div id="new-task-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="new-task-title">
|
|
961
|
+
<div class="modal-background" data-close></div>
|
|
962
|
+
<div class="modal-card">
|
|
963
|
+
<header class="modal-card-head">
|
|
964
|
+
<p class="modal-card-title" id="new-task-title">Start a new task</p>
|
|
965
|
+
<button class="delete" aria-label="close" data-close></button>
|
|
966
|
+
</header>
|
|
967
|
+
<section class="modal-card-body">
|
|
968
|
+
<div class="field">
|
|
969
|
+
<label class="label" for="new-task-requirement">Requirement</label>
|
|
970
|
+
<div class="control"><textarea id="new-task-requirement" class="textarea" rows="6" placeholder="Describe the feature, bug fix, or change. The first line becomes the task title." data-autofocus></textarea></div>
|
|
971
|
+
<div class="ad-field-hint"><span>The loop creates the task and starts the daemon if it is not running.</span><span id="new-task-count">0 / 8192 bytes</span></div>
|
|
972
|
+
</div>
|
|
973
|
+
</section>
|
|
974
|
+
<footer class="modal-card-foot">
|
|
975
|
+
<span class="ad-foot-start"><kbd>Ctrl</kbd>+<kbd>Enter</kbd> to submit</span>
|
|
976
|
+
<button class="button" data-close>Cancel</button>
|
|
977
|
+
<button id="new-task-submit" class="button is-primary">Create task</button>
|
|
978
|
+
</footer>
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
<div id="task-drawer" class="ad-drawer" role="dialog" aria-modal="true" aria-labelledby="task-drawer-title" hidden>
|
|
982
|
+
<div class="ad-drawer-backdrop" data-drawer-close></div>
|
|
983
|
+
<aside class="ad-drawer-panel" tabindex="-1">
|
|
984
|
+
<header class="ad-drawer-head">
|
|
985
|
+
<code id="task-drawer-title" class="ad-drawer-id">Task</code>
|
|
986
|
+
<a id="task-drawer-open" class="button is-small" href="#" target="_blank" rel="noopener">Open full page ↗</a>
|
|
987
|
+
<button type="button" class="delete" aria-label="Close task panel" data-drawer-close></button>
|
|
988
|
+
</header>
|
|
989
|
+
<div class="ad-drawer-body" id="task-drawer-body"><p class="has-text-grey">Loading…</p></div>
|
|
990
|
+
</aside>
|
|
991
|
+
</div>
|
|
992
|
+
<div id="toasts" class="ad-toasts" aria-live="polite"></div>`;
|
|
993
|
+
const renderDashboard = async (options) => {
|
|
994
|
+
const state = await readUiState(options);
|
|
995
|
+
/* Terminal tasks (done/closed/superseded) are moved to the archive catalog on
|
|
996
|
+
* save; list them too so they survive a reload. Active tasks keep priority
|
|
997
|
+
* within the display limit; archived ones fill the remaining room. */
|
|
998
|
+
const active = listTaskSummaries(state, { limit: DASHBOARD_LIMIT }).items;
|
|
999
|
+
const archived = active.length < DASHBOARD_LIMIT
|
|
1000
|
+
? listTaskSummaries({ ...state, tasks: {} }, { includeArchive: true, limit: DASHBOARD_LIMIT - active.length }).items
|
|
1001
|
+
: [];
|
|
1002
|
+
const items = [...active, ...archived];
|
|
1003
|
+
const handedOff = new Set(Object.values(state.tasks).filter((task) => task.handoff).map((task) => task.id));
|
|
1004
|
+
const kanban = PHASES.map((phase, index) => {
|
|
1005
|
+
const tasks = items.filter((task) => task.phase === phase);
|
|
1006
|
+
const cards = tasks.map((task) => renderTaskCard(task, handedOff.has(task.id))).join("\n");
|
|
1007
|
+
return `<section class="ad-column" data-phase-column="${phase}">
|
|
1008
|
+
<header class="ad-column-head"><span class="ad-step-index">${index + 1}</span><span class="ad-column-title">${escapeHtml(phase)}</span><span class="ad-count" data-column-count>${tasks.length}</span></header>
|
|
1009
|
+
<div class="ad-column-body">${cards}<p class="ad-column-empty" data-column-empty${tasks.length ? " hidden" : ""}>No tasks</p></div>
|
|
1010
|
+
</section>`;
|
|
1011
|
+
}).join("\n");
|
|
1012
|
+
const rows = items.map((task) => renderListRow(task, handedOff.has(task.id))).join("\n");
|
|
1013
|
+
const board = items.length
|
|
1014
|
+
? `${renderToolbar()}
|
|
1015
|
+
<div id="kanban-view"><div class="ad-board">${kanban}</div></div>
|
|
1016
|
+
<div id="list-view" style="display:none">
|
|
1017
|
+
<div class="ad-table-wrap"><table class="table ad-table is-fullwidth">
|
|
1018
|
+
<thead><tr><th>id</th><th>title</th><th>phase</th><th>gate</th><th>status</th><th>risk</th><th>areas</th><th>updated</th><th></th></tr></thead>
|
|
1019
|
+
<tbody>${rows}</tbody>
|
|
1020
|
+
</table></div>
|
|
1021
|
+
</div>
|
|
1022
|
+
<p class="ad-no-match" data-no-match>No tasks match the current filters. <button type="button" id="filter-clear" class="button is-small is-text">Clear filters</button></p>`
|
|
1023
|
+
: `<div class="ad-empty">
|
|
1024
|
+
<div class="ad-empty-icon" aria-hidden="true">🌱</div>
|
|
1025
|
+
<h2>No tasks yet</h2>
|
|
1026
|
+
<p>Describe a requirement and the loop will create the task and drive it through clarify → plan → build → wrap.</p>
|
|
1027
|
+
<button class="button is-primary" data-open="new-task-modal">+ New task</button>
|
|
1028
|
+
</div>`;
|
|
1029
|
+
return `${pageHead("Loop")}
|
|
1030
|
+
${renderHeader(options.root, uiMode(options))}
|
|
1031
|
+
<main class="section ad-main">
|
|
1032
|
+
<div class="container is-fluid">
|
|
1033
|
+
${renderStats(items, handedOff)}
|
|
1034
|
+
${renderReviewQueue(items, handedOff)}
|
|
1035
|
+
${board}
|
|
1036
|
+
<p class="ad-help">Click a task to open it in the side panel; ctrl/cmd-click or middle-click opens the full detail page. "New task" creates a loop-owned task and starts the daemon if it is not running; "resume" re-activates a paused task. Gate approval stays available on the detail page and on the trusted terminal (<code>node .agents/aidlc/scripts/loop-gate.mjs <task-id></code>). Shortcuts: <kbd>N</kbd> new task · <kbd>/</kbd> search · <kbd>Esc</kbd> close panel or dialog.</p>
|
|
1037
|
+
</div>
|
|
1038
|
+
</main>
|
|
1039
|
+
${renderModals()}
|
|
1040
|
+
${sharedScript}
|
|
1041
|
+
</body>
|
|
1042
|
+
</html>`;
|
|
1043
|
+
};
|
|
1044
|
+
/* ---------------------------------------------------------------------------
|
|
1045
|
+
* Task detail (page and side-panel fragment share the same body).
|
|
1046
|
+
* ------------------------------------------------------------------------- */
|
|
1047
|
+
/** The artifact a reviewer most likely needs at each gate opens by default. */
|
|
1048
|
+
const GATE_ARTIFACT = { G0_confirm: "intent", G1_review: "design", G2_codereview: "workplan" };
|
|
1049
|
+
const panel = (title, body, extra = "", flush = false) => `<section class="ad-panel"><header class="ad-panel-head"><h2>${escapeHtml(title)}</h2>${extra}</header><div class="ad-panel-body${flush ? " is-flush" : ""}">${body}</div></section>`;
|
|
1050
|
+
const renderTaskDetail = async (options, id, fragment = false) => {
|
|
1051
|
+
const state = await readUiState(options);
|
|
1052
|
+
const task = readUiTask(options, id, state);
|
|
1053
|
+
const fleet = uiMode(options) === "fleet";
|
|
1054
|
+
if (!task) {
|
|
1055
|
+
const body = `<p class="has-text-danger">Task not found: ${escapeHtml(id)}</p>`;
|
|
1056
|
+
return fragment ? body : `${pageHead("Task not found · Loop")}${renderHeader(options.root, uiMode(options))}<main class="section ad-main"><div class="container is-fluid"><div class="ad-empty"><div class="ad-empty-icon" aria-hidden="true">🔍</div><h2>Task not found</h2><p><code>${escapeHtml(id)}</code> is not in the ${uiMode(options) === "fleet" ? "fleet" : "local"} canonical state.</p><a class="button" href="/">← Back to dashboard</a></div></div></main>${renderModals()}${sharedScript}</body></html>`;
|
|
1057
|
+
}
|
|
1058
|
+
const waiting = task.status === "blocked_on_user" && task.gate !== "none";
|
|
1059
|
+
// A gate whose structural checks now fail (e.g. an artifact edited after the
|
|
1060
|
+
// wait was set) makes gateView throw; show the diagnostics instead of a 500.
|
|
1061
|
+
let gateBlock = "";
|
|
1062
|
+
if (waiting) {
|
|
1063
|
+
try {
|
|
1064
|
+
const gate = await taskGatePayload(options, id);
|
|
1065
|
+
if (gate.ok && gate.view)
|
|
1066
|
+
gateBlock = renderGateViewHtml(gate.view);
|
|
1067
|
+
else if (gate.ok && gate.snapshot)
|
|
1068
|
+
gateBlock = `<section class="ad-panel is-warning"><header class="ad-panel-head"><h2>${escapeHtml(gateLabel(task.gate))} · review snapshot</h2><span class="ad-pill is-ghost ad-panel-tools">${escapeHtml(gate.snapshot.id ?? "")}</span></header><div class="ad-panel-body"><div class="content artifact">${markdownToHtml(gate.snapshot.reviewText ?? "")}</div></div></section>`;
|
|
1069
|
+
else if (fleet)
|
|
1070
|
+
gateBlock = `<section class="ad-panel is-warning"><header class="ad-panel-head"><h2>${escapeHtml(gateLabel(task.gate))}</h2></header><div class="ad-panel-body"><p>${escapeHtml(gate.error ?? "No review snapshot yet")}</p></div></section>`;
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
const problems = (error instanceof Error ? error.message : String(error)).split("; ").filter(Boolean);
|
|
1074
|
+
gateBlock = `<section class="ad-panel is-danger">
|
|
1075
|
+
<header class="ad-panel-head"><span aria-hidden="true">⚠</span><h2>${escapeHtml(gateLabel(task.gate))} · gate check failing</h2><span class="ad-pill is-danger ad-panel-tools">${escapeHtml(task.gate)}</span></header>
|
|
1076
|
+
<div class="ad-panel-body">
|
|
1077
|
+
<p class="mb-3">The task waits at this gate, but its structural checks no longer pass. Approval will be rejected until these are fixed:</p>
|
|
1078
|
+
<ul class="ad-plain-list">${problems.map((problem) => `<li>${pill("ERROR", "is-danger")} ${escapeHtml(problem)}</li>`).join("")}</ul>
|
|
1079
|
+
<p class="is-size-7 has-text-grey mt-3">Re-check with <code>node .agents/aidlc/scripts/gate-check.mjs ${escapeHtml(task.id)} --gate ${escapeHtml(task.gate)}</code>.</p>
|
|
1080
|
+
</div>
|
|
1081
|
+
</section>`;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const approveBlock = !fragment && waiting && fleet && !options.reviewerToken
|
|
1085
|
+
? `<section class="ad-panel" id="approve-panel">
|
|
1086
|
+
<header class="ad-panel-head"><h2>Approve · ${escapeHtml(gateLabel(task.gate))}</h2><span class="ad-pill is-ghost ad-panel-tools">${escapeHtml(task.gate)}</span></header>
|
|
1087
|
+
<div class="ad-panel-body">
|
|
1088
|
+
<p class="mb-2">This dashboard has no fleet reviewer credential. Approve from a reviewer terminal, or restart <code>aidlc-kit ui</code> with <code>AIDLC_FLEET_REVIEWER_TOKEN</code> or <code>--reviewer-token-file</code>.</p>
|
|
1089
|
+
<pre class="is-size-7">node .agents/aidlc/scripts/remote-state.mjs gate inbox
|
|
1090
|
+
node .agents/aidlc/scripts/remote-state.mjs gate approve ${escapeHtml(task.id)} --snapshot <snapshot-id> --source "<explicit approval>"</pre>
|
|
1091
|
+
</div>
|
|
1092
|
+
</section>`
|
|
1093
|
+
: !fragment && waiting
|
|
1094
|
+
? `<section class="ad-panel is-success" id="approve-panel">
|
|
1095
|
+
<header class="ad-panel-head"><h2>Approve · ${escapeHtml(gateLabel(task.gate))}</h2><span class="ad-pill is-ghost ad-panel-tools">${escapeHtml(task.gate)}</span></header>
|
|
1096
|
+
<div class="ad-panel-body">
|
|
1097
|
+
<div class="field">
|
|
1098
|
+
<label class="label is-small" for="source">Approval note <span class="has-text-grey has-text-weight-normal">(required, recorded as provenance)</span></label>
|
|
1099
|
+
<div class="control"><textarea id="source" class="textarea" rows="2" placeholder="e.g. reviewed intent and scope — ok"></textarea></div>
|
|
1100
|
+
</div>
|
|
1101
|
+
<div class="is-flex is-align-items-center" style="gap:0.75rem">
|
|
1102
|
+
<button id="approve-button" class="button is-success">✓ Approve</button>
|
|
1103
|
+
<span id="approve-result" class="help m-0" role="status"></span>
|
|
1104
|
+
</div>
|
|
1105
|
+
</div>
|
|
1106
|
+
</section>`
|
|
1107
|
+
: "";
|
|
1108
|
+
const openArtifact = waiting ? GATE_ARTIFACT[task.gate] : undefined;
|
|
1109
|
+
const artifacts = ARTIFACT_NAMES.map((name) => {
|
|
1110
|
+
const artifactPath = task.artifacts?.[name];
|
|
1111
|
+
if (!artifactPath)
|
|
1112
|
+
return "";
|
|
1113
|
+
const payload = artifactPayload(options.root, id, name, task);
|
|
1114
|
+
const content = payload.ok && payload.content !== undefined
|
|
1115
|
+
? `<div class="content artifact">${markdownToHtml(payload.content)}</div>`
|
|
1116
|
+
: `<p class="help is-danger px-5 pb-4">${escapeHtml(payload.error ?? "unavailable")}</p>`;
|
|
1117
|
+
return `<details class="ad-artifact"${name === openArtifact ? " open" : ""}><summary>${escapeHtml(name)}<code>${escapeHtml(artifactPath)}</code></summary>${content}</details>`;
|
|
1118
|
+
}).join("\n");
|
|
1119
|
+
const table = (head, rows, empty) => rows.length
|
|
1120
|
+
? `<table class="table ad-table is-fullwidth"><thead><tr>${head.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead><tbody>${rows.join("\n")}</tbody></table>`
|
|
1121
|
+
: `<p class="ad-empty-line">${escapeHtml(empty)}</p>`;
|
|
1122
|
+
const decisions = table(["decision", "status", "resolution"], task.decisions.map((decision) => `<tr><td><code>${escapeHtml(decision.id)}</code><div class="is-size-7 has-text-grey mt-1">${escapeHtml(decision.label ?? "")}</div></td><td>${pill(decision.status, DECISION_TONE[decision.status] ?? "")}</td><td class="is-size-7">${escapeHtml(decision.resolution ?? "")}</td></tr>`), "No decisions recorded");
|
|
1123
|
+
const doneItems = task.tasks.filter((item) => item.status === "done").length;
|
|
1124
|
+
const workItems = table(["item", "status", "label"], task.tasks.map((item) => `<tr><td><code>${escapeHtml(item.id)}</code></td><td>${pill(item.status.replace("_", " "), ITEM_TONE[item.status] ?? "")}</td><td class="is-size-7">${escapeHtml(item.label ?? "")}</td></tr>`), "No work items yet");
|
|
1125
|
+
const workProgress = task.tasks.length
|
|
1126
|
+
? `<div class="px-5 pt-4 pb-1"><progress class="progress is-small is-primary mb-0" value="${doneItems}" max="${task.tasks.length}" aria-label="work items done">${doneItems}/${task.tasks.length}</progress></div>`
|
|
1127
|
+
: "";
|
|
1128
|
+
const evidence = table(["kind", "result", "gate", "source", "recorded"], task.evidence.map((entry) => `<tr><td><strong>${escapeHtml(entry.kind)}</strong>${entry.area ? `<div class="is-size-7 has-text-grey">${escapeHtml(entry.area)}</div>` : ""}</td><td>${pill(entry.result, RESULT_TONE[entry.result] ?? "")}</td><td class="is-size-7">${escapeHtml(entry.gate ?? "")}</td><td class="is-size-7">${escapeHtml(entry.source ?? "")}${entry.detail ? `<div class="has-text-grey mt-1">${escapeHtml(entry.detail)}</div>` : ""}</td><td class="is-size-7 has-text-grey">${relativeTime(entry.recordedAt)}</td></tr>`), "No verification evidence yet");
|
|
1129
|
+
const handoff = task.handoff
|
|
1130
|
+
? `<section class="ad-panel" style="box-shadow:0 0 0 1px var(--bulma-danger)"><header class="ad-panel-head"><h2>Handed off · ${escapeHtml(task.handoff.kind)}</h2></header><div class="ad-panel-body"><p>${escapeHtml(task.handoff.reason)}</p><p class="is-size-7 has-text-grey mt-2">Follow the handoff actions from <code>node .agents/aidlc/scripts/state.mjs task next ${escapeHtml(task.id)}</code>.</p></div></section>`
|
|
1131
|
+
: "";
|
|
1132
|
+
const meta = `<dl class="ad-meta">
|
|
1133
|
+
<dt>Status</dt><dd>${statusPill(task.status, Boolean(task.handoff))}</dd>
|
|
1134
|
+
<dt>Gate</dt><dd>${task.gate === "none" ? '<span class="has-text-grey">none</span>' : `${escapeHtml(gateLabel(task.gate))}<div class="is-size-7 has-text-grey">${escapeHtml(task.gate)}</div>`}</dd>
|
|
1135
|
+
<dt>Risk</dt><dd>${riskPill(task.risk)}</dd>
|
|
1136
|
+
<dt>Type</dt><dd>${escapeHtml(task.type)}</dd>
|
|
1137
|
+
<dt>Language</dt><dd>${escapeHtml(task.language)}</dd>
|
|
1138
|
+
<dt>Areas</dt><dd>${escapeHtml(task.areas.join(", "))}</dd>
|
|
1139
|
+
<dt>Branch</dt><dd><code>${escapeHtml(task.branch)}</code></dd>
|
|
1140
|
+
<dt>Created</dt><dd>${relativeTime(task.createdAt)}</dd>
|
|
1141
|
+
<dt>Updated</dt><dd>${relativeTime(task.updatedAt)}</dd>
|
|
1142
|
+
</dl>`;
|
|
1143
|
+
const liveLog = fragment ? "" : `<section class="ad-terminal">
|
|
1144
|
+
<div class="ad-terminal-head"><span class="ad-lights" aria-hidden="true"><span></span><span></span><span></span></span><strong>Live log</strong><span id="log-state">connecting…</span>
|
|
1145
|
+
<div class="ad-terminal-tools"><button type="button" id="log-follow" aria-pressed="true">Follow</button><button type="button" id="log-clear">Clear</button></div>
|
|
1146
|
+
</div>
|
|
1147
|
+
<div id="log" class="log-panel" role="log" aria-live="off"><span class="ad-log-muted">connecting…</span></div>
|
|
1148
|
+
</section>`;
|
|
1149
|
+
const body = `<h1 class="ad-page-title">${escapeHtml(task.title)}</h1>
|
|
1150
|
+
<div class="ad-page-sub"><code>${escapeHtml(task.id)}</code>${statusPill(task.status, Boolean(task.handoff))}${gatePill(task.gate)}${riskPill(task.risk)}</div>
|
|
1151
|
+
${phaseStepper(task.phase, task.gate, task.status)}
|
|
1152
|
+
<div class="columns is-desktop">
|
|
1153
|
+
<div class="column is-8-desktop">
|
|
1154
|
+
${handoff}
|
|
1155
|
+
${gateBlock}
|
|
1156
|
+
${approveBlock}
|
|
1157
|
+
${panel("Artifacts", artifacts || '<p class="ad-empty-line">No artifacts recorded</p>', `<span class="ad-count">${ARTIFACT_NAMES.filter((name) => task.artifacts?.[name]).length}</span>`, true)}
|
|
1158
|
+
${panel("Decisions", decisions, `<span class="ad-count">${task.decisions.length}</span>`, true)}
|
|
1159
|
+
${panel("Work items", workProgress + workItems, `<span class="ad-count">${doneItems}/${task.tasks.length}</span>`, true)}
|
|
1160
|
+
${panel("Evidence", evidence, `<span class="ad-count">${task.evidence.length}</span>`, true)}
|
|
1161
|
+
${liveLog}
|
|
1162
|
+
</div>
|
|
1163
|
+
<aside class="column is-4-desktop">
|
|
1164
|
+
<div class="ad-sticky">
|
|
1165
|
+
${panel("Details", meta)}
|
|
1166
|
+
</div>
|
|
1167
|
+
</aside>
|
|
1168
|
+
</div>`;
|
|
1169
|
+
if (fragment)
|
|
1170
|
+
return body;
|
|
1171
|
+
const detailScript = `<script>
|
|
1172
|
+
document.getElementById("approve-button")?.addEventListener("click", (event) => {
|
|
1173
|
+
const button = event.currentTarget;
|
|
1174
|
+
const note = document.getElementById("source");
|
|
1175
|
+
const result = document.getElementById("approve-result");
|
|
1176
|
+
if (result) result.textContent = "";
|
|
1177
|
+
if (!note.value.trim()) { toast("Add an approval note first", "warning", "It is recorded as the approval source."); note.focus(); return; }
|
|
1178
|
+
withLoading(button, async () => {
|
|
1179
|
+
const res = await post("/api/gate/approve", { taskId: ${JSON.stringify(id)}, gate: ${JSON.stringify(task.gate)}, source: note.value });
|
|
1180
|
+
if (res.status >= 200 && res.status < 300) {
|
|
1181
|
+
const next = res.body && res.body.result && res.body.result.nextAction;
|
|
1182
|
+
toast(${JSON.stringify(`Approved ${gateLabel(task.gate)}`)}, "success", next && next.classification ? "Next: " + next.classification + (next.phase ? " · " + next.phase : "") : "");
|
|
1183
|
+
if (result) result.textContent = "Approved — reloading…";
|
|
1184
|
+
setTimeout(() => location.reload(), 1500);
|
|
1185
|
+
} else failureToast(res, "Approval failed");
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
(() => {
|
|
1189
|
+
const logEl = document.getElementById("log");
|
|
1190
|
+
const stateEl = document.getElementById("log-state");
|
|
1191
|
+
const followBtn = document.getElementById("log-follow");
|
|
1192
|
+
const clearBtn = document.getElementById("log-clear");
|
|
1193
|
+
if (!logEl) return;
|
|
1194
|
+
let follow = true;
|
|
1195
|
+
let started = false;
|
|
1196
|
+
const MAX_LINES = 2000;
|
|
1197
|
+
followBtn?.addEventListener("click", () => { follow = !follow; followBtn.setAttribute("aria-pressed", String(follow)); if (follow) logEl.scrollTop = logEl.scrollHeight; });
|
|
1198
|
+
clearBtn?.addEventListener("click", () => { logEl.textContent = ""; });
|
|
1199
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
1200
|
+
const clock = (iso) => { const d = new Date(iso); return isNaN(d) ? "--:--:--" : two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds()); };
|
|
1201
|
+
const describe = (e) => {
|
|
1202
|
+
switch (e.type) {
|
|
1203
|
+
case "turn_start": return "turn " + (Number(e.iteration || 0) + 1) + " · phase " + (e.phase || "?") + " · agent " + (e.agent || "?");
|
|
1204
|
+
case "turn_end": return "turn done";
|
|
1205
|
+
case "agent_text": return e.text || "";
|
|
1206
|
+
case "agent_tool": return "tool: " + (e.tool || "?");
|
|
1207
|
+
case "lifecycle_command": return "command " + (e.status || "?") + " (exit " + (e.exitCode ?? "?") + ")";
|
|
1208
|
+
case "daemon_turn_error": return (e.message || "error") + " (retry in " + Math.round(Number(e.retryInMs || 0) / 1000) + "s)";
|
|
1209
|
+
case "loop_stopped": return "stopped: " + (e.reason || "");
|
|
1210
|
+
case "gate": return "gate " + (e.gate || "?") + " reached — review and approve on this page";
|
|
1211
|
+
case "waiting": return "waiting at " + (e.gate || "?");
|
|
1212
|
+
default: {
|
|
1213
|
+
if (e.message) return e.message;
|
|
1214
|
+
const rest = Object.assign({}, e); delete rest.at; delete rest.type; delete rest.taskId;
|
|
1215
|
+
const text = JSON.stringify(rest);
|
|
1216
|
+
return text === "{}" ? "" : text;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
const tone = (type) => /error|fail/.test(type) ? "is-error" : /stop|wait|gate|blocked/.test(type) ? "is-warn" : /integrated|done|complete|end/.test(type) ? "is-ok" : /^agent/.test(type) ? "is-agent" : "";
|
|
1221
|
+
const append = (raw) => {
|
|
1222
|
+
if (!started) { logEl.textContent = ""; started = true; if (stateEl) stateEl.textContent = "streaming"; }
|
|
1223
|
+
const line = document.createElement("div");
|
|
1224
|
+
line.className = "ad-log-line";
|
|
1225
|
+
let event;
|
|
1226
|
+
try { event = JSON.parse(raw); } catch (error) { event = null; }
|
|
1227
|
+
if (event && typeof event === "object" && typeof event.type === "string") {
|
|
1228
|
+
const cls = tone(event.type);
|
|
1229
|
+
if (cls) line.classList.add(cls);
|
|
1230
|
+
const time = document.createElement("span"); time.className = "ad-log-time"; time.textContent = clock(event.at);
|
|
1231
|
+
const type = document.createElement("span"); type.className = "ad-log-type"; type.textContent = event.type;
|
|
1232
|
+
const text = document.createElement("span"); text.className = "ad-log-text"; text.textContent = describe(event);
|
|
1233
|
+
line.append(time, type, text);
|
|
1234
|
+
} else {
|
|
1235
|
+
line.textContent = raw;
|
|
1236
|
+
}
|
|
1237
|
+
logEl.appendChild(line);
|
|
1238
|
+
while (logEl.childElementCount > MAX_LINES) logEl.firstElementChild.remove();
|
|
1239
|
+
if (follow) logEl.scrollTop = logEl.scrollHeight;
|
|
1240
|
+
};
|
|
1241
|
+
const events = new EventSource("/api/events?task-id=" + encodeURIComponent(${JSON.stringify(id)}));
|
|
1242
|
+
events.onopen = () => { if (!started && stateEl) stateEl.textContent = "connected · waiting for events"; if (!started) logEl.innerHTML = "<span class='ad-log-muted'>No events for this task yet.</span>"; };
|
|
1243
|
+
events.onmessage = (message) => append(message.data);
|
|
1244
|
+
events.onerror = () => { if (stateEl) stateEl.textContent = started ? "reconnecting…" : "unavailable"; if (!started) logEl.innerHTML = "<span class='ad-log-muted'>Log stream unavailable (loop not running or sidecar missing).</span>"; };
|
|
1245
|
+
})();
|
|
1246
|
+
</script>`;
|
|
1247
|
+
return `${pageHead(`${task.title} · Loop`)}
|
|
1248
|
+
${renderHeader(options.root, uiMode(options))}
|
|
1249
|
+
<main class="section ad-main">
|
|
1250
|
+
<div class="container is-fluid">
|
|
1251
|
+
<a class="ad-breadcrumb" href="/">← Dashboard</a>
|
|
1252
|
+
${body}
|
|
1253
|
+
</div>
|
|
1254
|
+
</main>
|
|
1255
|
+
${renderModals()}
|
|
1256
|
+
${sharedScript}
|
|
1257
|
+
${detailScript}
|
|
1258
|
+
</body>
|
|
1259
|
+
</html>`;
|
|
1260
|
+
};
|
|
1261
|
+
/** On a fresh connection the SSE stream replays at most this many lines. */
|
|
1262
|
+
export const SSE_REPLAY_LINES = 200;
|
|
1263
|
+
const SSE_REPLAY_BYTES = 256 * 1024;
|
|
1264
|
+
const SSE_CHUNK_BYTES = 1024 * 1024;
|
|
1265
|
+
const readRange = (path, start, end) => {
|
|
1266
|
+
const fd = openSync(path, "r");
|
|
1267
|
+
try {
|
|
1268
|
+
const buffer = Buffer.alloc(Math.max(0, end - start));
|
|
1269
|
+
let read = 0;
|
|
1270
|
+
while (read < buffer.length) {
|
|
1271
|
+
const count = readSync(fd, buffer, read, buffer.length - read, start + read);
|
|
1272
|
+
if (!count)
|
|
1273
|
+
break;
|
|
1274
|
+
read += count;
|
|
1275
|
+
}
|
|
1276
|
+
return buffer.subarray(0, read);
|
|
1277
|
+
}
|
|
1278
|
+
finally {
|
|
1279
|
+
closeSync(fd);
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
/**
|
|
1283
|
+
* Streams the JSONL event-log sidecar as SSE. Tracks a byte offset (no full
|
|
1284
|
+
* re-read per tick), emits only newline-terminated lines (a half-written line
|
|
1285
|
+
* waits for the next tick), restarts at 0 when the file is truncated or rotated
|
|
1286
|
+
* (inode change), and replays only the last SSE_REPLAY_LINES on a fresh
|
|
1287
|
+
* connection. `?task-id=<id>` keeps only events stamped with that task id.
|
|
1288
|
+
* Event ids are `<inode>:<offset>` so EventSource reconnects resume exactly.
|
|
1289
|
+
*/
|
|
1290
|
+
const streamEvents = (options, res, req, url) => {
|
|
1291
|
+
res.writeHead(200, {
|
|
1292
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1293
|
+
"cache-control": "no-store",
|
|
1294
|
+
"connection": "keep-alive",
|
|
1295
|
+
});
|
|
1296
|
+
// Flush headers immediately (an empty log would otherwise leave the client
|
|
1297
|
+
// waiting for the first event) and keep idle connections alive.
|
|
1298
|
+
res.write(": connected\n\n");
|
|
1299
|
+
let idleTicks = 0;
|
|
1300
|
+
const path = eventsLogPath(options.root);
|
|
1301
|
+
const taskFilter = url.searchParams.get("task-id") ?? "";
|
|
1302
|
+
const resume = /^(\d+):(\d+)$/.exec(String(req.headers["last-event-id"] ?? ""));
|
|
1303
|
+
let inode = resume ? Number(resume[1]) : null;
|
|
1304
|
+
let position = resume ? Number(resume[2]) : -1;
|
|
1305
|
+
let firstTick = true;
|
|
1306
|
+
let discarding = false; // inside an oversized line that was skipped
|
|
1307
|
+
const matches = (line) => {
|
|
1308
|
+
if (!taskFilter)
|
|
1309
|
+
return true;
|
|
1310
|
+
try {
|
|
1311
|
+
return JSON.parse(line).taskId === taskFilter;
|
|
1312
|
+
}
|
|
1313
|
+
catch {
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
const send = (line, id) => { res.write(`id: ${id}\ndata: ${line}\n\n`); };
|
|
1318
|
+
const emit = () => {
|
|
1319
|
+
try {
|
|
1320
|
+
let stat;
|
|
1321
|
+
try {
|
|
1322
|
+
stat = statSync(path);
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
// A stale Last-Event-ID from a rotated-away file replays the tail like a
|
|
1328
|
+
// fresh connection; a rotation seen while streaming follows the new file.
|
|
1329
|
+
if (inode !== null && stat.ino !== inode) {
|
|
1330
|
+
position = firstTick ? -1 : 0;
|
|
1331
|
+
discarding = false;
|
|
1332
|
+
}
|
|
1333
|
+
firstTick = false;
|
|
1334
|
+
inode = stat.ino;
|
|
1335
|
+
if (position < 0) {
|
|
1336
|
+
const start = Math.max(0, stat.size - SSE_REPLAY_BYTES);
|
|
1337
|
+
const buffer = readRange(path, start, stat.size);
|
|
1338
|
+
const last = buffer.lastIndexOf(0x0a);
|
|
1339
|
+
if (last < 0) {
|
|
1340
|
+
position = start;
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
position = start + last + 1;
|
|
1344
|
+
let lines = buffer.subarray(0, last).toString("utf8").split("\n");
|
|
1345
|
+
if (start > 0)
|
|
1346
|
+
lines = lines.slice(1);
|
|
1347
|
+
for (const line of lines.filter((entry) => entry.length > 0 && matches(entry)).slice(-SSE_REPLAY_LINES))
|
|
1348
|
+
send(line, `${inode}:${position}`);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (stat.size < position) {
|
|
1352
|
+
position = 0;
|
|
1353
|
+
discarding = false;
|
|
1354
|
+
}
|
|
1355
|
+
if (stat.size === position)
|
|
1356
|
+
return;
|
|
1357
|
+
const end = Math.min(stat.size, position + SSE_CHUNK_BYTES);
|
|
1358
|
+
let buffer = readRange(path, position, end);
|
|
1359
|
+
if (discarding) {
|
|
1360
|
+
// Drop the rest of an oversized line up to and including its newline.
|
|
1361
|
+
const newline = buffer.indexOf(0x0a);
|
|
1362
|
+
if (newline < 0) {
|
|
1363
|
+
position = end;
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
position += newline + 1;
|
|
1367
|
+
buffer = buffer.subarray(newline + 1);
|
|
1368
|
+
discarding = false;
|
|
1369
|
+
}
|
|
1370
|
+
const last = buffer.lastIndexOf(0x0a);
|
|
1371
|
+
if (last < 0) {
|
|
1372
|
+
if (buffer.length >= SSE_CHUNK_BYTES) {
|
|
1373
|
+
position = end;
|
|
1374
|
+
discarding = true;
|
|
1375
|
+
}
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
let cursor = position;
|
|
1379
|
+
for (const line of buffer.subarray(0, last).toString("utf8").split("\n")) {
|
|
1380
|
+
cursor += Buffer.byteLength(line) + 1;
|
|
1381
|
+
if (line.length > 0 && matches(line))
|
|
1382
|
+
send(line, `${inode}:${cursor}`);
|
|
1383
|
+
}
|
|
1384
|
+
position += last + 1;
|
|
1385
|
+
}
|
|
1386
|
+
catch { /* transient read errors are ignored until the next poll */ }
|
|
1387
|
+
};
|
|
1388
|
+
emit();
|
|
1389
|
+
const timer = setInterval(() => {
|
|
1390
|
+
emit();
|
|
1391
|
+
idleTicks += 1;
|
|
1392
|
+
if (idleTicks >= 30) {
|
|
1393
|
+
idleTicks = 0;
|
|
1394
|
+
res.write(": keep-alive\n\n");
|
|
1395
|
+
}
|
|
1396
|
+
}, 500);
|
|
1397
|
+
const cleanup = () => clearInterval(timer);
|
|
1398
|
+
req.on("close", cleanup);
|
|
1399
|
+
res.on("close", cleanup);
|
|
1400
|
+
};
|
|
1401
|
+
/** Builds the request handler for the UI server (exported for unit tests). */
|
|
1402
|
+
export const createUiHandler = (options) => {
|
|
1403
|
+
return (req, res) => {
|
|
1404
|
+
const url = new URL(req.url ?? "/", `http://${options.host || "localhost"}`);
|
|
1405
|
+
const method = req.method ?? "GET";
|
|
1406
|
+
const path = url.pathname;
|
|
1407
|
+
const guard = requestAllowed(options, req);
|
|
1408
|
+
if (!guard.ok)
|
|
1409
|
+
return sendJson(res, 403, { ok: false, error: guard.error });
|
|
1410
|
+
if (method === "GET" && path === "/api/events")
|
|
1411
|
+
return streamEvents(options, res, req, url);
|
|
1412
|
+
void (async () => {
|
|
1413
|
+
try {
|
|
1414
|
+
if (method === "GET" && path.startsWith("/assets/")) {
|
|
1415
|
+
const css = stylesheet(path);
|
|
1416
|
+
if (css !== undefined)
|
|
1417
|
+
return sendCss(res, css);
|
|
1418
|
+
}
|
|
1419
|
+
if (method === "GET" && path === "/")
|
|
1420
|
+
return sendHtml(res, await renderDashboard(options));
|
|
1421
|
+
if (method === "GET" && path.startsWith("/partial/tasks/")) {
|
|
1422
|
+
const id = decodeURIComponent(path.slice("/partial/tasks/".length));
|
|
1423
|
+
if (!id)
|
|
1424
|
+
return sendJson(res, 404, { ok: false, error: "Not found" });
|
|
1425
|
+
return sendHtml(res, await renderTaskDetail(options, id, true));
|
|
1426
|
+
}
|
|
1427
|
+
if (method === "GET" && path.startsWith("/tasks/")) {
|
|
1428
|
+
const id = decodeURIComponent(path.slice("/tasks/".length));
|
|
1429
|
+
if (!id)
|
|
1430
|
+
return sendJson(res, 404, { ok: false, error: "Not found" });
|
|
1431
|
+
return sendHtml(res, await renderTaskDetail(options, id));
|
|
1432
|
+
}
|
|
1433
|
+
if (method === "GET" && path === "/api/tasks") {
|
|
1434
|
+
const query = url.searchParams;
|
|
1435
|
+
const summary = listTaskSummaries(await readUiState(options), {
|
|
1436
|
+
includeArchive: query.get("archive") === "1",
|
|
1437
|
+
statuses: query.get("statuses") ? query.get("statuses").split(",").filter(Boolean) : undefined,
|
|
1438
|
+
query: query.get("query") ?? undefined,
|
|
1439
|
+
limit: query.get("limit") ? Number(query.get("limit")) : undefined,
|
|
1440
|
+
cursor: query.get("cursor") ? Number(query.get("cursor")) : undefined,
|
|
1441
|
+
});
|
|
1442
|
+
return sendJson(res, 200, summary);
|
|
1443
|
+
}
|
|
1444
|
+
if (method === "GET" && path.startsWith("/api/tasks/")) {
|
|
1445
|
+
const id = decodeURIComponent(path.slice("/api/tasks/".length));
|
|
1446
|
+
if (path.endsWith("/gate")) {
|
|
1447
|
+
const taskId = id.slice(0, -"/gate".length);
|
|
1448
|
+
const payload = await taskGatePayload(options, taskId);
|
|
1449
|
+
return sendJson(res, payload.ok ? 200 : 409, payload);
|
|
1450
|
+
}
|
|
1451
|
+
const artifactMatch = /^(.+)\/artifact\/(intent|design|workplan)$/.exec(id);
|
|
1452
|
+
if (artifactMatch) {
|
|
1453
|
+
const taskId = artifactMatch[1] ?? "";
|
|
1454
|
+
const payload = artifactPayload(options.root, taskId, artifactMatch[2] ?? "", readUiTask(options, taskId, await readUiState(options)));
|
|
1455
|
+
return sendJson(res, payload.ok ? 200 : 404, payload);
|
|
1456
|
+
}
|
|
1457
|
+
const task = readUiTask(options, id, await readUiState(options));
|
|
1458
|
+
if (!task)
|
|
1459
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${id}` });
|
|
1460
|
+
return sendJson(res, 200, { ok: true, task });
|
|
1461
|
+
}
|
|
1462
|
+
if (method === "GET" && path === "/api/loop/status") {
|
|
1463
|
+
const taskId = url.searchParams.get("task-id") ?? "";
|
|
1464
|
+
if (!taskId)
|
|
1465
|
+
return sendJson(res, 400, { ok: false, error: "task-id query parameter is required" });
|
|
1466
|
+
try {
|
|
1467
|
+
return sendJson(res, 200, { ok: true, result: await loopStatusJson(options.root, taskId) });
|
|
1468
|
+
}
|
|
1469
|
+
catch (error) {
|
|
1470
|
+
return sendJson(res, 502, { ok: false, error: childError(error) });
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (method === "POST" && path === "/api/loop/start") {
|
|
1474
|
+
if (!tokenMatches(options.token ?? "", suppliedToken(req)))
|
|
1475
|
+
return sendJson(res, 401, { ok: false, error: "Invalid or missing approve token" });
|
|
1476
|
+
const body = await readBody(req);
|
|
1477
|
+
const fields = readFields(body, req.headers["content-type"]);
|
|
1478
|
+
const requirement = fields["requirement"] ?? "";
|
|
1479
|
+
if (!requirement.trim())
|
|
1480
|
+
return sendJson(res, 400, { ok: false, error: "requirement is required" });
|
|
1481
|
+
if (Buffer.byteLength(requirement) > 8192)
|
|
1482
|
+
return sendJson(res, 400, { ok: false, error: "requirement exceeds 8192 bytes" });
|
|
1483
|
+
let created;
|
|
1484
|
+
try {
|
|
1485
|
+
created = await intakeTask(options.root, requirement);
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
return sendJson(res, 409, { ok: false, error: childError(error) });
|
|
1489
|
+
}
|
|
1490
|
+
return sendJson(res, 201, { ok: true, ...created, daemon: ensureDaemon(options.root) });
|
|
1491
|
+
}
|
|
1492
|
+
if (method === "POST" && path === "/api/loop/resume") {
|
|
1493
|
+
if (!tokenMatches(options.token ?? "", suppliedToken(req)))
|
|
1494
|
+
return sendJson(res, 401, { ok: false, error: "Invalid or missing approve token" });
|
|
1495
|
+
const body = await readBody(req);
|
|
1496
|
+
const fields = readFields(body, req.headers["content-type"]);
|
|
1497
|
+
const taskId = fields["taskId"] ?? "";
|
|
1498
|
+
if (!taskId.trim())
|
|
1499
|
+
return sendJson(res, 400, { ok: false, error: "taskId is required" });
|
|
1500
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
1501
|
+
if (!task)
|
|
1502
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${taskId}` });
|
|
1503
|
+
if (task.handoff)
|
|
1504
|
+
return sendJson(res, 409, { ok: false, error: "Task is handed off; follow its handoff actions (state.mjs task next) instead of resuming" });
|
|
1505
|
+
if (task.status === "blocked_on_user")
|
|
1506
|
+
return sendJson(res, 409, { ok: false, error: "Task is waiting at a human gate; approve it on the task detail page" });
|
|
1507
|
+
if (task.status !== "paused")
|
|
1508
|
+
return sendJson(res, 409, { ok: false, error: `Only a paused task can be resumed (status: ${task.status})` });
|
|
1509
|
+
try {
|
|
1510
|
+
const result = uiMode(options) === "fleet" ? JSON.parse(await runNode(options.root, loopArgs(options.root, "resume-task", taskId), { timeout: 30000 })) : await activateTask(options.root, taskId);
|
|
1511
|
+
return sendJson(res, 200, { ok: true, result, daemon: daemonStatus(options.root) });
|
|
1512
|
+
}
|
|
1513
|
+
catch (error) {
|
|
1514
|
+
return sendJson(res, 502, { ok: false, error: childError(error) });
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (method === "POST" && path === "/api/gate/approve") {
|
|
1518
|
+
if (!tokenMatches(options.token ?? "", suppliedToken(req)))
|
|
1519
|
+
return sendJson(res, 401, { ok: false, error: "Invalid or missing approve token" });
|
|
1520
|
+
const body = await readBody(req);
|
|
1521
|
+
const fields = readFields(body, req.headers["content-type"]);
|
|
1522
|
+
const taskId = fields["taskId"] ?? "";
|
|
1523
|
+
const source = fields["source"] ?? "";
|
|
1524
|
+
if (!taskId.trim())
|
|
1525
|
+
return sendJson(res, 400, { ok: false, error: "taskId is required" });
|
|
1526
|
+
if (!source.trim())
|
|
1527
|
+
return sendJson(res, 400, { ok: false, error: "source (approval note) is required" });
|
|
1528
|
+
const task = readUiTask(options, taskId, await readUiState(options));
|
|
1529
|
+
if (!task)
|
|
1530
|
+
return sendJson(res, 404, { ok: false, error: `Task not found: ${taskId}` });
|
|
1531
|
+
if (task.status !== "blocked_on_user" || task.gate === "none")
|
|
1532
|
+
return sendJson(res, 409, { ok: false, error: "Task is not waiting at a human gate" });
|
|
1533
|
+
if (uiMode(options) === "fleet" && !options.reviewerToken)
|
|
1534
|
+
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" });
|
|
1535
|
+
try {
|
|
1536
|
+
// Fleet: the reviewer credential reaches only this single approve child process.
|
|
1537
|
+
const result = uiMode(options) === "fleet"
|
|
1538
|
+
? JSON.parse(await runNode(options.root, loopArgs(options.root, "approve", taskId), { input: source, timeout: 30000, env: { AIDLC_FLEET_REVIEWER_TOKEN: options.reviewerToken ?? "" } }))
|
|
1539
|
+
: await approveGate(options.root, taskId, task.gate, source);
|
|
1540
|
+
return sendJson(res, 200, { ok: true, result });
|
|
1541
|
+
}
|
|
1542
|
+
catch (error) {
|
|
1543
|
+
return sendJson(res, 502, { ok: false, error: childError(error) });
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return sendJson(res, 404, { ok: false, error: `Not found: ${method} ${path}` });
|
|
1547
|
+
}
|
|
1548
|
+
catch (error) {
|
|
1549
|
+
if (res.headersSent || res.destroyed)
|
|
1550
|
+
return;
|
|
1551
|
+
if (error instanceof BadRequest || error instanceof URIError)
|
|
1552
|
+
return sendJson(res, 400, { ok: false, error: error.message });
|
|
1553
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1554
|
+
}
|
|
1555
|
+
})();
|
|
1556
|
+
};
|
|
1557
|
+
};
|
|
1558
|
+
/** Starts the UI HTTP server and resolves once it is listening. */
|
|
1559
|
+
export const startUiServer = (options) => new Promise((resolveServer, reject) => {
|
|
1560
|
+
const token = options.token ?? randomUUID();
|
|
1561
|
+
const mode = options.mode ?? resolveUiMode(options.root);
|
|
1562
|
+
const reviewerToken = options.reviewerToken ?? (mode === "fleet" ? process.env["AIDLC_FLEET_REVIEWER_TOKEN"] || undefined : undefined);
|
|
1563
|
+
const server = createServer(createUiHandler({ ...options, token, mode, ...(reviewerToken ? { reviewerToken } : {}) }));
|
|
1564
|
+
server.once("error", reject);
|
|
1565
|
+
server.listen(options.port, options.host, () => {
|
|
1566
|
+
const address = server.address();
|
|
1567
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
1568
|
+
resolveServer({ server, port, token });
|
|
1569
|
+
});
|
|
1570
|
+
});
|