@felixdotgo/aidlc-kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -0,0 +1,77 @@
1
+ /*
2
+ * Loop dashboard page: kanban/list view toggle, client-side filters, and the
3
+ * collapsible done column. Loaded after common.js (uses its `live` hooks).
4
+ */
5
+ /* ---- Dashboard view state (re-applied after every live update) ---- */
6
+ const viewKey = "aidlc-view";
7
+ const applyView = (view) => {
8
+ document.documentElement.setAttribute("data-view", view === "list" ? "list" : "kanban");
9
+ document.querySelectorAll("[data-view-toggle]").forEach((el) => {
10
+ const active = el.getAttribute("data-view-toggle") === view;
11
+ el.classList.toggle("is-active", active);
12
+ el.setAttribute("aria-pressed", String(active));
13
+ });
14
+ };
15
+ const currentView = () => (localStorage.getItem(viewKey) === "list" ? "list" : "kanban");
16
+ const expandedColumns = new Set();
17
+ // Client-side filtering over server-rendered cards and rows (no extra API calls).
18
+ // Columns with data-collapse show that many cards unless expanded or filtering.
19
+ const applyFilter = () => {
20
+ const search = document.getElementById("task-search");
21
+ const statusFilter = document.getElementById("filter-status");
22
+ const riskFilter = document.getElementById("filter-risk");
23
+ const resultCount = document.getElementById("result-count");
24
+ const terms = (search ? search.value : "").toLowerCase().split(/\s+/).filter(Boolean);
25
+ const status = statusFilter ? statusFilter.value : "";
26
+ const risk = riskFilter ? riskFilter.value : "";
27
+ const filtered = !!(terms.length || status || risk);
28
+ const matches = (el) => (!status || el.getAttribute("data-status") === status) && (!risk || el.getAttribute("data-risk") === risk) && terms.every((term) => (el.getAttribute("data-search") || "").includes(term));
29
+ let visible = 0;
30
+ document.querySelectorAll("#kanban-view [data-task-item]").forEach((el) => { const show = matches(el); el.hidden = !show; if (show) visible += 1; });
31
+ document.querySelectorAll("#list-view [data-task-item]").forEach((el) => { el.hidden = !matches(el); });
32
+ document.querySelectorAll("[data-phase-column]").forEach((column) => {
33
+ const shown = [...column.querySelectorAll("[data-task-item]")].filter((el) => !el.hidden);
34
+ const limit = Number(column.getAttribute("data-collapse") || 0);
35
+ const expanded = expandedColumns.has(column.getAttribute("data-phase-column"));
36
+ const extra = limit && !filtered ? Math.max(0, shown.length - limit) : 0;
37
+ if (extra && !expanded) shown.slice(limit).forEach((el) => { el.hidden = true; });
38
+ const more = column.querySelector("[data-column-more]");
39
+ if (more) { more.hidden = extra === 0; more.textContent = expanded ? "Show less" : "Show " + extra + " more"; more.setAttribute("aria-expanded", String(expanded)); }
40
+ const badge = column.querySelector("[data-column-count]");
41
+ if (badge) badge.textContent = String(shown.length);
42
+ const empty = column.querySelector("[data-column-empty]");
43
+ if (empty) empty.hidden = shown.length > 0;
44
+ });
45
+ const total = document.querySelectorAll("#kanban-view [data-task-item]").length;
46
+ if (resultCount) resultCount.textContent = filtered ? visible + " of " + total + " tasks" : total + (total === 1 ? " task" : " tasks");
47
+ document.querySelectorAll("[data-no-match]").forEach((el) => el.classList.toggle("is-visible", filtered && visible === 0));
48
+ };
49
+ const clearFilters = () => {
50
+ ["task-search", "filter-status", "filter-risk"].forEach((id) => { const el = document.getElementById(id); if (el) el.value = ""; });
51
+ applyFilter();
52
+ };
53
+ live.hooks.push(() => { applyView(currentView()); applyFilter(); });
54
+
55
+ document.addEventListener("click", (event) => {
56
+ const target = event.target instanceof Element ? event.target : null;
57
+ if (!target) return;
58
+ const viewToggle = target.closest("[data-view-toggle]");
59
+ if (viewToggle) { const view = viewToggle.getAttribute("data-view-toggle"); localStorage.setItem(viewKey, view); applyView(view); return; }
60
+ const columnMore = target.closest("[data-column-more]");
61
+ if (columnMore) {
62
+ const column = columnMore.closest("[data-phase-column]");
63
+ const name = column ? column.getAttribute("data-phase-column") : "";
64
+ if (expandedColumns.has(name)) expandedColumns.delete(name); else expandedColumns.add(name);
65
+ applyFilter();
66
+ return;
67
+ }
68
+ if (target.closest("#filter-clear")) { clearFilters(); return; }
69
+ });
70
+ document.addEventListener("input", (event) => {
71
+ const target = event.target instanceof Element ? event.target : null;
72
+ if (target && target.matches("#task-search, #filter-status, #filter-risk")) applyFilter();
73
+ });
74
+ document.addEventListener("DOMContentLoaded", () => {
75
+ applyView(currentView());
76
+ applyFilter();
77
+ });
@@ -0,0 +1,159 @@
1
+ /*
2
+ * Task detail page: gate feedback and approval. Handlers are delegated (the
3
+ * controls are re-rendered by live updates) and read the task and gate from the
4
+ * current DOM, never from values baked in at page load. Loaded after common.js.
5
+ */
6
+ const detailTask = () => document.body.getAttribute("data-task") || "";
7
+ // Feedback: keeps the task at this gate; the lead revises and re-presents it.
8
+ const onFeedbackSend = (button) => {
9
+ const panel = document.getElementById("feedback-panel");
10
+ const field = document.getElementById("feedback");
11
+ const result = document.getElementById("feedback-result");
12
+ if (!panel || !field) return;
13
+ if (result) result.textContent = "";
14
+ if (!field.value.trim()) { toast("Write the feedback first", "warning", "It is sent to the loop lead as guidance; the task stays at this gate."); field.focus(); return; }
15
+ const label = panel.getAttribute("data-gate-label") || "";
16
+ withLoading(button, async () => {
17
+ const res = await post("/api/gate/feedback", { taskId: detailTask(), feedback: field.value });
18
+ if (res.status >= 200 && res.status < 300) {
19
+ field.value = "";
20
+ toast("Feedback sent — task stays at " + label, "success", "The lead will revise the review artifact and re-present this gate.");
21
+ await liveRefresh();
22
+ } else failureToast(res, "Feedback failed");
23
+ });
24
+ };
25
+ // Approve: the button only opens the confirmation dialog; "Confirm approve" sends.
26
+ const onApproveOpen = () => {
27
+ const note = document.getElementById("source");
28
+ const result = document.getElementById("approve-result");
29
+ if (!note) return;
30
+ if (result) result.textContent = "";
31
+ if (!note.value.trim()) { toast("Add an approval note first", "warning", "It is recorded as the approval source. To request changes, use Feedback instead."); note.focus(); return; }
32
+ const quote = document.getElementById("approve-modal-note");
33
+ if (quote) quote.textContent = note.value.trim();
34
+ openModal("approve-modal");
35
+ };
36
+ const onApproveConfirm = (button) => {
37
+ const panel = document.getElementById("approve-panel");
38
+ const note = document.getElementById("source");
39
+ if (!panel || !note) return;
40
+ const gate = panel.getAttribute("data-gate") || "";
41
+ const label = panel.getAttribute("data-gate-label") || gate;
42
+ withLoading(button, async () => {
43
+ // The gate shown is sent along; the server refuses if the task has moved on since.
44
+ const res = await post("/api/gate/approve", { taskId: detailTask(), gate, source: note.value });
45
+ closeAllModals();
46
+ if (res.status >= 200 && res.status < 300) {
47
+ const next = res.body && res.body.result && res.body.result.nextAction;
48
+ note.value = "";
49
+ toast("Approved " + label, "success", next && next.classification ? "Next: " + next.classification + (next.phase ? " · " + next.phase : "") : "");
50
+ await liveRefresh();
51
+ } else failureToast(res, "Approval failed");
52
+ });
53
+ };
54
+ /* ---- Container log viewer (Containers panel) -------------------------------
55
+ * The viewer (#container-log, [data-live-ignore]) is client-owned: a Log button
56
+ * fetches the raw tail of that assignment's log and, while Follow is on and the row
57
+ * still reports the container as running, re-fetches at the Settings live interval
58
+ * (fallback 1.5 s; never while polling is paused). The running state is read from
59
+ * the row's button on every tick, which live updates keep current. */
60
+ const containerLog = { assignment: "", timer: 0, inflight: null, follow: true };
61
+ const containerViewer = () => document.getElementById("container-log");
62
+ const containerSettings = () => (window.aidlcSettings && window.aidlcSettings.get()) || {};
63
+ const containerInterval = () => { const value = Number(containerSettings().livePoll); return Number.isFinite(value) ? value : 1500; };
64
+ const containerButton = () => {
65
+ if (!containerLog.assignment) return null;
66
+ for (const button of document.querySelectorAll("[data-container-log]")) if (button.getAttribute("data-container-log") === containerLog.assignment) return button;
67
+ return null;
68
+ };
69
+ const containerRunning = () => { const button = containerButton(); return !!button && button.getAttribute("data-container-running") === "1"; };
70
+ const fetchContainerLog = async () => {
71
+ const el = containerViewer();
72
+ if (!el || !containerLog.assignment) return;
73
+ if (containerLog.inflight) return containerLog.inflight;
74
+ const assignment = containerLog.assignment;
75
+ containerLog.inflight = (async () => {
76
+ const body = el.querySelector("[data-container-log-body]");
77
+ const state = el.querySelector("[data-container-log-state]");
78
+ try {
79
+ const kb = Number(containerSettings().logTailKb) || 64;
80
+ const url = "/api/tasks/" + encodeURIComponent(detailTask()) + "/containers/" + encodeURIComponent(assignment) + "/log?tail=" + (kb * 1024);
81
+ const response = await fetch(url, { cache: "no-store" });
82
+ const text = await response.text();
83
+ if (assignment !== containerLog.assignment) return;
84
+ if (!response.ok) { if (body) body.textContent = text || ("HTTP " + response.status); if (state) state.textContent = "unavailable"; return; }
85
+ const atBottom = body ? body.scrollHeight - body.scrollTop - body.clientHeight < 8 : false;
86
+ if (body) body.textContent = text || "(no output yet)";
87
+ const size = Number(response.headers.get("x-aidlc-log-size")) || 0;
88
+ if (state) state.textContent = (size >= 1024 ? Math.round(size / 1024) + " KiB" : size + " B") + (response.headers.get("x-aidlc-log-truncated") === "1" ? " · tail" : "") + (containerRunning() ? " · running" : " · ended");
89
+ if (body && (containerLog.follow || atBottom)) body.scrollTop = body.scrollHeight;
90
+ } catch (error) {
91
+ if (state) state.textContent = "unreachable";
92
+ } finally { containerLog.inflight = null; }
93
+ })();
94
+ return containerLog.inflight;
95
+ };
96
+ const scheduleContainerLog = () => {
97
+ clearTimeout(containerLog.timer);
98
+ containerLog.timer = 0;
99
+ const el = containerViewer();
100
+ if (!el || el.hidden || !containerLog.assignment || !containerLog.follow) return;
101
+ const interval = containerInterval();
102
+ if (!interval) return; // live updates paused in Settings
103
+ if (!containerRunning()) return; // the container ended: the last fetch was final
104
+ containerLog.timer = setTimeout(async () => { await fetchContainerLog(); scheduleContainerLog(); }, interval);
105
+ };
106
+ const openContainerLog = (button) => {
107
+ const el = containerViewer();
108
+ if (!el) return;
109
+ containerLog.assignment = button.getAttribute("data-container-log") || "";
110
+ containerLog.follow = true;
111
+ const name = el.querySelector("[data-container-log-name]");
112
+ if (name) name.textContent = (button.getAttribute("data-container-name") || containerLog.assignment) + " · " + containerLog.assignment;
113
+ const follow = el.querySelector("[data-container-follow]");
114
+ if (follow) follow.setAttribute("aria-pressed", "true");
115
+ const body = el.querySelector("[data-container-log-body]");
116
+ if (body) body.textContent = "loading…";
117
+ el.hidden = false;
118
+ el.scrollIntoView({ block: "nearest" });
119
+ fetchContainerLog().then(scheduleContainerLog);
120
+ };
121
+ const closeContainerLog = () => {
122
+ const el = containerViewer();
123
+ clearTimeout(containerLog.timer);
124
+ containerLog.timer = 0;
125
+ containerLog.assignment = "";
126
+ if (el) el.hidden = true;
127
+ };
128
+ const toggleContainerFollow = (button) => {
129
+ containerLog.follow = button.getAttribute("aria-pressed") !== "true";
130
+ button.setAttribute("aria-pressed", containerLog.follow ? "true" : "false");
131
+ if (containerLog.follow) fetchContainerLog().then(scheduleContainerLog); else scheduleContainerLog();
132
+ };
133
+ if (window.aidlcSettings) window.aidlcSettings.onChange((settings, changed) => { if (changed.includes("livePoll")) scheduleContainerLog(); });
134
+ // A live update that re-renders the row (container ended) stops the follow loop on the next tick.
135
+ document.addEventListener("keydown", (event) => {
136
+ if (event.key !== "Escape") return;
137
+ const el = containerViewer();
138
+ if (!el || el.hidden) return;
139
+ // common.js answers Escape for the notifications dropdown, modals, and the side panel first.
140
+ if (typeof notifyOpen === "function" && notifyOpen()) return;
141
+ if (typeof modalOpen === "function" && modalOpen()) return;
142
+ if (typeof drawerOpen === "function" && drawerOpen()) return;
143
+ closeContainerLog();
144
+ });
145
+
146
+ document.addEventListener("click", (event) => {
147
+ const target = event.target instanceof Element ? event.target : null;
148
+ if (!target) return;
149
+ const feedback = target.closest("#feedback-button");
150
+ if (feedback) { onFeedbackSend(feedback); return; }
151
+ if (target.closest("#approve-button")) { onApproveOpen(); return; }
152
+ const confirm = target.closest("#approve-confirm");
153
+ if (confirm) { onApproveConfirm(confirm); return; }
154
+ const log = target.closest("[data-container-log]");
155
+ if (log && !log.disabled) { openContainerLog(log); return; }
156
+ const follow = target.closest("[data-container-follow]");
157
+ if (follow) { toggleContainerFollow(follow); return; }
158
+ if (target.closest("[data-container-close]")) closeContainerLog();
159
+ });
@@ -0,0 +1,332 @@
1
+ /*
2
+ * Live log: streams /api/events?task-id=<task> (SSE) into every [data-live-log]
3
+ * element. The task page shows the full log (data-log-mode="full": lines grouped
4
+ * under turn headers, an Important/All filter, a Raw JSON switch); the task side
5
+ * panel shows a compact one (data-log-mode="compact": the last data-log-limit
6
+ * lines, Important only). Log elements are client-owned ([data-live-ignore]):
7
+ * live morphs never touch them. A MutationObserver starts logs that appear later
8
+ * (the side panel is injected and re-rendered), stops the stream of a log that is
9
+ * removed or hidden (closed side panel), and restarts it when shown again.
10
+ * Event data only ever reaches the DOM through textContent.
11
+ * Loaded after common.js (uses its `liveRefresh` when present).
12
+ */
13
+ (() => {
14
+ /* ---- Formatting: event → human sentence (pure; exposed for tests) ---- */
15
+ const DEFAULT_GATES = { G0_confirm: "Confirm requirements", G1_review: "Approve plan", G2_codereview: "Review implementation" };
16
+ const str = (value) => typeof value === "string" ? value : "";
17
+ const two = (n) => String(n).padStart(2, "0");
18
+ const clock = (iso) => { const d = new Date(iso); return isNaN(d) ? "--:--:--" : two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds()); };
19
+ const firstLine = (text) => (text.split("\n").find((line) => line.trim()) || "").trim();
20
+ const shortSha = (sha) => str(sha).slice(0, 7);
21
+ const duration = (ms) => { const s = Math.max(0, Math.round(Number(ms || 0) / 1000)); return s < 60 ? s + "s" : Math.floor(s / 60) + "m" + (s % 60 ? " " + (s % 60) + "s" : ""); };
22
+ const code = (value) => ({ code: value });
23
+ // Driver events without an actor (older logs) come from the lead's turn.
24
+ const LEAD_TYPES = /^(agent_|turn_|lifecycle_command$)/;
25
+ const actorOf = (e) => str(e.actor) || (LEAD_TYPES.test(e.type) ? "lead" : "loop");
26
+ // Hidden in Important mode: bookkeeping that does not tell the reader what happened.
27
+ const NOISE = new Set(["turn_completed", "daemon_poll", "agent_event", "resume", "context_changed", "jobs_active", "daemon_start", "resync"]);
28
+ // Outcome and loop events stand on their own instead of nesting under a turn.
29
+ const SYSTEM = new Set(["gate", "waiting", "daemon_turn", "daemon_turn_error", "loop_stopped", "job_integrated", "job_scheduled", "build_deferred", "diagnostic", "daemon_start", "daemon_poll", "daemon_poll_error", "worker_image_build", "resume", "context_changed", "jobs_active", "release_failed", "resync"]);
30
+ const TOOL_VERBS = [
31
+ [/^(bash|shell|command|command_execution|exec|run|local_shell)/i, "ran"],
32
+ [/^(edit|multiedit|write|apply_patch|patch|notebookedit|file_change|str_replace)/i, "edited"],
33
+ [/^(read|view|cat)/i, "read"],
34
+ [/^(grep|glob|search|find|ls|list|websearch|webfetch|fetch)/i, "searched"],
35
+ ];
36
+ const toolSentence = (e) => {
37
+ const tool = str(e.tool) || "tool";
38
+ const summary = str(e.summary);
39
+ if (!summary) return ["used tool ", code(tool)];
40
+ const verb = (TOOL_VERBS.find(([pattern]) => pattern.test(tool)) || [])[1];
41
+ return verb ? [verb + " ", code(summary)] : ["used " + tool + " ", code(summary)];
42
+ };
43
+ const toneOfStatus = (status) => /fail|error/.test(status) ? "error" : /pause|block|stop|stall|gate|handoff/.test(status) ? "warn" : /done|complete|success/.test(status) ? "ok" : "muted";
44
+
45
+ /**
46
+ * Describes one event: `parts` is the sentence (strings, `{code}` spans, `{link, href}`),
47
+ * `tone` one of error|warn|ok|muted|"", `full` the untruncated text when `parts` shows
48
+ * only its first line, `turn` the header label of a turn start, `noise` hides it in
49
+ * Important mode, `system` keeps it out of turn groups.
50
+ */
51
+ const describe = (e, ctx = {}) => {
52
+ const gates = ctx.gates || DEFAULT_GATES;
53
+ const gateName = (gate) => gates[gate] || str(gate) || "a gate";
54
+ const taskId = str(e.taskId) || str(ctx.taskId);
55
+ const actor = actorOf(e);
56
+ const info = { actor, parts: [], tone: "", noise: NOISE.has(e.type), system: SYSTEM.has(e.type) };
57
+ const turnLabel = (agent) => ["Turn " + (Number(e.iteration || 0) + 1), str(e.phase), actor + (agent ? "/" + agent : ""), e.itemId ? "item " + e.itemId : ""].filter(Boolean).join(" · ");
58
+ switch (e.type) {
59
+ case "turn_start":
60
+ info.turn = turnLabel(str(e.agent));
61
+ info.parts = ["started turn " + (Number(e.iteration || 0) + 1) + (e.phase ? " · " + e.phase : "") + (e.agent ? " · " + e.agent : "")];
62
+ break;
63
+ case "worker_turn_start":
64
+ info.turn = turnLabel(str(e.agent));
65
+ info.parts = [e.summary ? "worker " + e.summary : "worker started turn " + (Number(e.iteration || 0) + 1)];
66
+ break;
67
+ case "turn_end": info.parts = ["turn " + (Number(e.iteration || 0) + 1) + " finished"]; info.tone = "ok"; break;
68
+ case "turn_completed": info.parts = ["agent finished its reply"]; info.tone = "muted"; break;
69
+ case "agent_text": {
70
+ const text = str(e.text).trim();
71
+ info.parts = [firstLine(text)];
72
+ if (text !== firstLine(text)) info.full = text;
73
+ break;
74
+ }
75
+ case "agent_tool": info.parts = toolSentence(e); break;
76
+ case "lifecycle_command": {
77
+ const exit = e.exitCode;
78
+ const failed = (Number.isInteger(exit) && exit !== 0) || e.status === "failed";
79
+ const outcome = Number.isInteger(exit) ? " → exit " + exit : e.status === "in_progress" ? " → running" : " → " + (str(e.status) || "unknown");
80
+ info.parts = e.summary ? ["command ", code(str(e.summary)), outcome] : ["command" + outcome];
81
+ info.tone = failed ? "error" : Number.isInteger(exit) ? "ok" : "";
82
+ break;
83
+ }
84
+ case "gate":
85
+ case "waiting":
86
+ info.parts = ["waiting for your review at " + gateName(e.gate) + " · ", { link: "open review", href: "/tasks/" + encodeURIComponent(taskId) + "#approve-panel" }];
87
+ info.tone = "warn";
88
+ break;
89
+ case "daemon_turn": {
90
+ const status = str(e.status) || "unknown";
91
+ info.parts = status === "gate" && e.gate ? ["loop paused at " + gateName(e.gate)] : ["loop run ended: " + status + (e.reason ? " · " + e.reason : "")];
92
+ info.tone = toneOfStatus(status);
93
+ break;
94
+ }
95
+ case "daemon_turn_error":
96
+ info.parts = ["loop turn failed: " + (str(e.message) || "error") + " — retry" + (e.failures ? " #" + e.failures : "") + " in " + duration(e.retryInMs)];
97
+ info.tone = "error";
98
+ break;
99
+ case "daemon_poll_error": info.parts = ["daemon poll failed: " + (str(e.message) || "error")]; info.tone = "error"; break;
100
+ case "loop_stopped": info.parts = ["loop stopped" + (e.reason ? ": " + e.reason : "")]; info.tone = "warn"; break;
101
+ case "build_deferred": info.parts = ["deferred item ", code(str(e.itemId) || "?"), e.reason ? ": " + e.reason : ""]; info.tone = "warn"; break;
102
+ case "worker_image_build": info.parts = ["building worker image ", code(str(e.image) || "?"), e.provider ? " (" + e.provider + ")" : ""]; info.tone = "muted"; break;
103
+ case "worker_reported": {
104
+ const count = Array.isArray(e.changedPaths) ? e.changedPaths.length : 0;
105
+ info.parts = ["worker reported " + count + " file" + (count === 1 ? "" : "s") + (e.commit ? " (" + shortSha(e.commit) + ")" : "")];
106
+ if (count) info.title = e.changedPaths.filter((path) => typeof path === "string").join("\n");
107
+ info.tone = "ok";
108
+ break;
109
+ }
110
+ case "job_integrated": {
111
+ const sha = shortSha(e.commit || e.commitSha);
112
+ info.parts = ["integrated item ", code(str(e.itemId) || "?"), sha ? " (" + sha + ")" : ""];
113
+ info.tone = "ok";
114
+ break;
115
+ }
116
+ case "job_scheduled": info.parts = ["scheduled item ", code(str(e.itemId) || "?")]; info.tone = "muted"; break;
117
+ case "diagnostic": info.parts = [str(e.message) || str(e.summary) || "diagnostic"]; info.tone = "warn"; break;
118
+ case "daemon_start": info.parts = ["daemon started" + (e.pid ? " (pid " + e.pid + ")" : "")]; info.tone = "muted"; break;
119
+ case "daemon_poll": info.parts = ["daemon poll · " + (Array.isArray(e.actionable) ? e.actionable.length : 0) + " actionable"]; info.tone = "muted"; break;
120
+ case "resume": info.parts = ["resumed the loop"]; info.tone = "muted"; break;
121
+ case "context_changed": info.parts = [str(e.message) || "task context changed"]; info.tone = "muted"; break;
122
+ case "agent_event": info.parts = ["provider event"]; info.tone = "muted"; break;
123
+ default:
124
+ info.parts = [str(e.message) || str(e.summary) || String(e.type)];
125
+ info.tone = /error|fail/.test(e.type) ? "error" : "";
126
+ }
127
+ return info;
128
+ };
129
+ window.aidlcLiveLog = { describe, clock };
130
+
131
+ /* ---- Rendering ---- */
132
+ // Page-level filter/raw choice; until the user picks one here, the Settings defaults
133
+ // (`logFilter`, `logRaw` in window.aidlcSettings) apply.
134
+ const PREFS_KEY = "aidlc-live-log";
135
+ const settingsDefaults = () => {
136
+ const settings = window.aidlcSettings && typeof window.aidlcSettings.get === "function" ? window.aidlcSettings.get() : {};
137
+ return { filter: settings.logFilter === "all" ? "all" : "important", raw: settings.logRaw === true };
138
+ };
139
+ const readPrefs = () => {
140
+ const fallback = settingsDefaults();
141
+ try {
142
+ const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
143
+ return {
144
+ filter: saved.filter === "all" || saved.filter === "important" ? saved.filter : fallback.filter,
145
+ raw: typeof saved.raw === "boolean" ? saved.raw : fallback.raw,
146
+ };
147
+ } catch (error) { return fallback; }
148
+ };
149
+ const prefs = readPrefs();
150
+ const savePrefs = () => { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch (error) { /* storage disabled */ } };
151
+ const node = (tag, className, text) => { const el = document.createElement(tag); if (className) el.className = className; if (text !== undefined) el.textContent = text; return el; };
152
+ const appendParts = (target, parts) => {
153
+ for (const part of parts) {
154
+ if (typeof part === "string") { if (part) target.append(part); }
155
+ else if (part && part.code !== undefined) target.append(node("code", "ad-log-code", part.code));
156
+ else if (part && part.link) { const a = node("a", "ad-log-link", part.link); a.href = part.href; target.append(a); }
157
+ }
158
+ };
159
+ const actorClass = (actor) => {
160
+ if (actor === "lead") return "is-lead";
161
+ if (actor === "loop") return "is-loop";
162
+ let hash = 0;
163
+ for (const char of actor) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
164
+ return "is-worker-" + (hash % 4);
165
+ };
166
+ // Events that usually mean the canonical task changed: refresh the page regions
167
+ // now (debounced) instead of waiting for the next poll.
168
+ const STATE_EVENTS = new Set(["turn_end", "lifecycle_command", "gate", "waiting", "daemon_turn", "daemon_turn_error", "loop_stopped", "job_integrated"]);
169
+ let refreshTimer = 0;
170
+ const refreshSoon = () => {
171
+ if (typeof liveRefresh !== "function") return;
172
+ clearTimeout(refreshTimer);
173
+ refreshTimer = setTimeout(() => liveRefresh(), 150);
174
+ };
175
+
176
+ const instances = new Map();
177
+ const createLog = (root) => {
178
+ const compact = root.getAttribute("data-log-mode") === "compact";
179
+ const limit = Math.max(1, Math.min(2000, Number(root.getAttribute("data-log-limit")) || (compact ? 20 : 2000)));
180
+ const taskId = root.getAttribute("data-task-id") || document.body.getAttribute("data-task") || "";
181
+ let gates = DEFAULT_GATES;
182
+ try { gates = Object.assign({}, DEFAULT_GATES, JSON.parse(root.getAttribute("data-gate-labels") || "{}")); } catch (error) { /* defaults */ }
183
+ const body = root.querySelector("[data-log-body]");
184
+ const stateEl = root.querySelector("[data-log-state]");
185
+ const followBtn = root.querySelector("[data-log-follow]");
186
+ let source = null;
187
+ let follow = true;
188
+ let entries = [];
189
+ let block = null; // current turn group: { actor, body }
190
+ const turns = new Map(); // actor -> label of its latest turn
191
+ const view = () => compact ? { filter: "important", raw: false } : prefs;
192
+ const setState = (text) => { if (stateEl) stateEl.textContent = text; };
193
+ const placeholder = (text) => { body.textContent = ""; body.append(node("span", "ad-log-muted", text)); };
194
+ const scroll = () => { if (follow) body.scrollTop = body.scrollHeight; };
195
+
196
+ const lineFor = (entry, current) => {
197
+ const line = node("div", "ad-log-line");
198
+ if (!entry.event) { line.classList.add("is-unparsed"); line.textContent = entry.raw; return line; }
199
+ const info = entry.info;
200
+ if (info.tone) line.classList.add("is-" + info.tone);
201
+ line.setAttribute("data-type", entry.event.type);
202
+ const actor = node("span", "ad-log-actor " + actorClass(info.actor), info.actor);
203
+ actor.title = "actor: " + info.actor;
204
+ const text = node("span", "ad-log-text");
205
+ if (current.raw) { text.classList.add("is-json"); text.textContent = entry.raw; }
206
+ else if (info.full && current.filter === "all") text.textContent = info.full;
207
+ else if (info.full && !compact) {
208
+ const more = node("details", "ad-log-more");
209
+ const summary = node("summary");
210
+ appendParts(summary, info.parts);
211
+ more.append(summary, node("div", "ad-log-full", info.full));
212
+ text.append(more);
213
+ } else appendParts(text, info.parts);
214
+ if (info.title) text.title = info.title;
215
+ line.append(node("span", "ad-log-time", clock(entry.event.at)), actor, text);
216
+ return line;
217
+ };
218
+ const openGroup = (actor, label, entry) => {
219
+ const group = node("div", "ad-log-turn");
220
+ const head = node("div", "ad-log-turn-head");
221
+ head.append(node("span", "ad-log-time", entry ? clock(entry.event.at) : ""), node("span", "ad-log-actor " + actorClass(actor), actor), node("span", "ad-log-turn-label", label));
222
+ const groupBody = node("div", "ad-log-turn-body");
223
+ group.append(head, groupBody);
224
+ body.append(group);
225
+ block = { actor, body: groupBody };
226
+ return head;
227
+ };
228
+ // Appends one entry to the DOM (when the current view shows it); returns its node.
229
+ const render = (entry) => {
230
+ const current = view();
231
+ const info = entry.info;
232
+ if (info && info.noise && current.filter === "important") return null;
233
+ if (compact || current.raw || !info) { const line = lineFor(entry, current); body.append(line); return line; }
234
+ if (info.turn) { turns.set(info.actor, info.turn); return openGroup(info.actor, info.turn, entry); }
235
+ if (info.system) { block = null; const line = lineFor(entry, current); body.append(line); return line; }
236
+ if (!block || block.actor !== info.actor) openGroup(info.actor, (turns.get(info.actor) || info.actor) + " · continued", null);
237
+ const line = lineFor(entry, current);
238
+ block.body.append(line);
239
+ if (entry.event.type === "turn_end") block = null;
240
+ return line;
241
+ };
242
+ const drop = (entry) => {
243
+ const el = entry.node;
244
+ if (!el) return;
245
+ const group = el.closest(".ad-log-turn");
246
+ el.remove();
247
+ if (group && !group.querySelector(".ad-log-line, .ad-log-turn-head")) group.remove();
248
+ };
249
+ const rerender = () => {
250
+ body.textContent = "";
251
+ block = null;
252
+ turns.clear();
253
+ for (const entry of entries) entry.node = render(entry);
254
+ if (!entries.length) placeholder("No events for this task yet.");
255
+ if (compact) trimCompact();
256
+ scroll();
257
+ };
258
+ // Compact: only the last `limit` shown lines stay in the DOM.
259
+ const trimCompact = () => { while (body.querySelectorAll(".ad-log-line").length > limit) body.querySelector(".ad-log-line").remove(); };
260
+ const append = (raw) => {
261
+ if (!entries.length) body.textContent = "";
262
+ setState("streaming");
263
+ let event = null;
264
+ try { event = JSON.parse(raw); } catch (error) { event = null; }
265
+ if (!event || typeof event !== "object" || typeof event.type !== "string") event = null;
266
+ const entry = { raw, event, info: event ? describe(event, { taskId, gates }) : null, node: null };
267
+ entries.push(entry);
268
+ entry.node = render(entry);
269
+ const cap = compact ? Math.max(limit * 10, 200) : limit;
270
+ while (entries.length > cap) { const old = entries.shift(); if (!compact) drop(old); }
271
+ if (compact) trimCompact();
272
+ if (event && STATE_EVENTS.has(event.type)) refreshSoon();
273
+ scroll();
274
+ };
275
+ const clear = () => { entries = []; block = null; turns.clear(); placeholder("No events for this task yet."); };
276
+
277
+ const syncControls = () => {
278
+ root.querySelectorAll("[data-log-filter]").forEach((button) => button.setAttribute("aria-pressed", String(button.getAttribute("data-log-filter") === prefs.filter)));
279
+ root.querySelectorAll("[data-log-raw]").forEach((button) => button.setAttribute("aria-pressed", String(prefs.raw)));
280
+ };
281
+ root.addEventListener("click", (event) => {
282
+ const target = event.target instanceof Element ? event.target : null;
283
+ if (!target) return;
284
+ const filter = target.closest("[data-log-filter]");
285
+ if (filter) { prefs.filter = filter.getAttribute("data-log-filter") === "all" ? "all" : "important"; savePrefs(); instances.forEach((log) => log.refreshView()); return; }
286
+ if (target.closest("[data-log-raw]")) { prefs.raw = !prefs.raw; savePrefs(); instances.forEach((log) => log.refreshView()); return; }
287
+ if (target.closest("[data-log-follow]")) { follow = !follow; followBtn?.setAttribute("aria-pressed", String(follow)); scroll(); return; }
288
+ if (target.closest("[data-log-clear]")) { clear(); }
289
+ });
290
+ syncControls();
291
+
292
+ return {
293
+ refreshView: () => { syncControls(); if (!compact) rerender(); },
294
+ running: () => source !== null,
295
+ start: () => {
296
+ if (source || !body) return;
297
+ clear();
298
+ placeholder("connecting…");
299
+ setState("connecting…");
300
+ source = new EventSource("/api/events?task-id=" + encodeURIComponent(taskId));
301
+ source.onopen = () => { if (!entries.length) { setState("connected · waiting for events"); placeholder("No events for this task yet."); } };
302
+ source.onmessage = (message) => append(message.data);
303
+ // The server replays the log from the start of its window: drop what is shown.
304
+ source.addEventListener("reset", () => clear());
305
+ source.onerror = () => {
306
+ const closed = source && source.readyState === 2;
307
+ setState(closed ? "unavailable" : entries.length ? "reconnecting…" : "unavailable");
308
+ if (!entries.length) placeholder("Log stream unavailable (loop not running or log missing).");
309
+ };
310
+ },
311
+ stop: () => { if (source) { source.close(); source = null; setState("paused"); } },
312
+ };
313
+ };
314
+
315
+ // Starts logs that are attached and visible; stops removed or hidden ones.
316
+ const scan = () => {
317
+ document.querySelectorAll("[data-live-log]").forEach((root) => { if (!instances.has(root)) instances.set(root, createLog(root)); });
318
+ for (const [root, log] of instances) {
319
+ if (!root.isConnected) { log.stop(); instances.delete(root); continue; }
320
+ if (root.closest("[hidden]")) log.stop(); else log.start();
321
+ }
322
+ };
323
+ let scheduled = false;
324
+ const scheduleScan = () => { if (scheduled) return; scheduled = true; queueMicrotask(() => { scheduled = false; scan(); }); };
325
+ // Mutations inside a log (its lines, its state text) never add or remove logs.
326
+ const insideLog = (record) => { const el = record.target.nodeType === 1 ? record.target : record.target.parentElement; return !!el && !!el.closest("[data-live-log]"); };
327
+ new MutationObserver((records) => { if (records.some((record) => !insideLog(record))) scheduleScan(); })
328
+ .observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden"] });
329
+ window.addEventListener("pagehide", () => instances.forEach((log) => log.stop()));
330
+ window.addEventListener("pageshow", (event) => { if (event.persisted) scan(); });
331
+ scan();
332
+ })();
@@ -1,10 +1,18 @@
1
- FROM node:24-bookworm-slim
1
+ FROM node:24-bookworm-slim AS build
2
2
  WORKDIR /app
3
3
  RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
4
4
  COPY package.json package-lock.json ./
5
5
  RUN npm ci --omit=dev
6
- COPY src ./src
7
- RUN mkdir -p /data
6
+
7
+ FROM node:24-bookworm-slim
8
+ WORKDIR /app
9
+ ENV NODE_ENV=production
8
10
  ENV MCP_STATE_HOST=0.0.0.0 MCP_STATE_PORT=8787 MCP_STATE_SQLITE_PATH=/data/mcp-state.db
11
+ COPY --from=build /app/node_modules ./node_modules
12
+ COPY package.json package-lock.json ./
13
+ COPY src ./src
14
+ RUN mkdir -p /data && chown -R node:node /data
15
+ USER node
9
16
  EXPOSE 8787
17
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD node -e "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
10
18
  CMD ["node", "src/server.mjs"]
@@ -9,6 +9,12 @@ services:
9
9
  MCP_STATE_DATABASE: sqlite
10
10
  MCP_STATE_SQLITE_PATH: /data/mcp-state.db
11
11
  MCP_STATE_AUTH_TOKEN: ${MCP_STATE_AUTH_TOKEN:?set MCP_STATE_AUTH_TOKEN in .env before starting the service}
12
+ healthcheck:
13
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
14
+ interval: 30s
15
+ timeout: 3s
16
+ start_period: 5s
17
+ retries: 3
12
18
 
13
19
  postgres:
14
20
  image: postgres:16-alpine
@@ -40,6 +46,12 @@ services:
40
46
  MCP_FLEET_LEAD_TOKEN: ${MCP_FLEET_LEAD_TOKEN:?set MCP_FLEET_LEAD_TOKEN in .env}
41
47
  MCP_FLEET_REVIEWER_TOKEN: ${MCP_FLEET_REVIEWER_TOKEN:?set MCP_FLEET_REVIEWER_TOKEN in .env}
42
48
  MCP_FLEET_WORKER_TOKENS: ${MCP_FLEET_WORKER_TOKENS:?set MCP_FLEET_WORKER_TOKENS JSON in .env}
49
+ healthcheck:
50
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
51
+ interval: 30s
52
+ timeout: 3s
53
+ start_period: 5s
54
+ retries: 3
43
55
 
44
56
  volumes:
45
57
  aidlc-coordination-data:
@@ -4,7 +4,9 @@
4
4
  "requires": true,
5
5
  "packages": {
6
6
  "": {
7
- "bin": { "aidlc-fleet": "src/fleet-run.mjs" },
7
+ "bin": {
8
+ "aidlc-fleet": "src/fleet-run.mjs"
9
+ },
8
10
  "dependencies": {
9
11
  "@modelcontextprotocol/express": "^2.0.0",
10
12
  "@modelcontextprotocol/node": "^2.0.0",