taskforce-loop-engineering 0.15.11 → 0.15.13
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/docs/operator-dashboard.md +25 -2
- package/docs/operator-workspace-project.md +30 -0
- package/lib/operator-dashboard.mjs +52 -6
- package/package.json +4 -2
- package/scripts/dashboard-autostart-install.mjs +91 -0
- package/scripts/dashboard-autostart-self-test.mjs +46 -0
- package/scripts/hermes-doctor.mjs +2 -1
- package/scripts/hermes-install-self-test.mjs +2 -1
- package/scripts/hermes-install.mjs +11 -4
- package/scripts/openclaw-doctor.mjs +5 -0
- package/scripts/openclaw-install-self-test.mjs +4 -1
- package/scripts/openclaw-install.mjs +11 -4
- package/scripts/operator-dashboard-self-test.mjs +26 -1
- package/scripts/operator-workspace-final-judgement.mjs +41 -0
- package/skills/taskforce-loop-engineering/SKILL.md +4 -0
- package/templates/operator-projection.schema.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.13 - 2026-08-28
|
|
6
|
+
|
|
7
|
+
- Add the project-first read-only operator workspace with terminal contracts, milestone/project status separation, revision lineage, human gates, external-action reservations, and acceptance/final-judge timelines.
|
|
8
|
+
- Couple the Dashboard service to OpenClaw and Hermes gateway startup and verify the wiring in platform doctors and installer self-tests.
|
|
9
|
+
- Support fail-closed `localhost` and Tailnet-only Dashboard listening modes without binding all network interfaces.
|
|
10
|
+
|
|
11
|
+
## 0.15.12 - 2026-08-28
|
|
12
|
+
|
|
13
|
+
- Fix public CI setup on repositories that intentionally do not commit an npm lockfile.
|
|
14
|
+
|
|
5
15
|
## 0.15.11 - 2026-08-28
|
|
6
16
|
|
|
7
17
|
- Add a transactional state kernel with typed effects, receipt chaining, CAS/fencing, exact-effect replay, crash recovery, and completion fencing.
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ when publication is separately approved.
|
|
|
39
39
|
|
|
40
40
|
## Read-only operator dashboard (P3)
|
|
41
41
|
|
|
42
|
-
Version 0.12 adds a dependency-free operator projection over projects, queues, P0 gates, P1 action reservations and P2 typed todo ownership. Use `dashboard-inspect`, `dashboard-health`, `dashboard-export`, or
|
|
42
|
+
Version 0.12 adds a dependency-free operator projection over projects, queues, P0 gates, P1 action reservations and P2 typed todo ownership. Use `dashboard-inspect`, `dashboard-health`, `dashboard-export`, or `dashboard-serve`. Gateway autostart installation supports local-only and Tailnet-only listening without binding all interfaces. See [docs/operator-dashboard.md](docs/operator-dashboard.md) for API, security and schema details.
|
|
43
43
|
|
|
44
44
|
The consolidated P0-P3 local release contract and evidence ledger are recorded in [docs/release-0.12-acceptance.md](docs/release-0.12-acceptance.md). Publishing, tagging, pushing, and production installation remain separate release actions.
|
|
45
45
|
|
|
@@ -12,9 +12,32 @@ loop-engineering dashboard-export --output-dir /tmp/loop-dashboard --root /path/
|
|
|
12
12
|
loop-engineering dashboard-serve --root /path/to/workspace
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`dashboard-serve` binds to `127.0.0.1` and an ephemeral port by default. A wider bind is rejected unless `--allow-non-loopback` is explicit. The server has no authentication and is intended for trusted local use.
|
|
15
|
+
`dashboard-serve` binds to `127.0.0.1` and an ephemeral port by default. A wider bind is rejected unless `--allow-non-loopback` is explicit. The server has no authentication and is intended for trusted local use.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
The OpenClaw and Hermes integration installers also install a Dashboard service on port `4174` and systemd gateway drop-ins. Starting `openclaw-gateway.service` or `hermes-gateway.service` therefore starts the read-only Dashboard. The default `localhost` mode binds only `127.0.0.1`. Optional `tailscale` mode resolves `tailscale ip -4` at install time and binds only that Tailnet address; it never binds `0.0.0.0`. Tailnet access is governed by Tailscale ACLs/Grants, while the Dashboard itself remains read-only and has no application-level login.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
loop-engineering-dashboard-autostart-install --root /path/to/workspace
|
|
21
|
+
loop-engineering-dashboard-autostart-install --root /path/to/workspace --confirm-install
|
|
22
|
+
loop-engineering-dashboard-autostart-install --root /path/to/workspace --listen tailscale --confirm-install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To make the workspace follow either the OpenClaw or Hermes user gateway lifecycle, install the loopback-only systemd integration:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
loop-engineering-dashboard-autostart-install --root /path/to/workspace
|
|
29
|
+
loop-engineering-dashboard-autostart-install --root /path/to/workspace --confirm-install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This creates `loop-engineering-dashboard.service` on fixed port `4174` and systemd drop-ins for `openclaw-gateway.service` and `hermes-gateway.service`. Starting either gateway pulls in the same idempotent dashboard service. Use `--listen localhost` (the default) for local-only access or `--listen tailscale` for Tailnet access. The Tailscale mode fails closed if it cannot resolve exactly one `100.x` IPv4 address. Restrict port `4174` with Tailnet ACLs/Grants when the Tailnet contains users or devices that should not see project metadata.
|
|
33
|
+
|
|
34
|
+
The platform installers expose the same choice as `--dashboard-listen localhost|tailscale` and accept `--tailscale-bin` when the CLI is outside `PATH`.
|
|
35
|
+
|
|
36
|
+
Endpoints are `GET /api/v1/overview`, `/api/v1/health`, `/api/v1/projects`, `/api/v1/projects/:id`, `/api/v1/todos`, `/api/v1/todos/:id`, and `/api/v1/actions`. Overview and todo lists support `q` and `state` filters. Private raw files are not served.
|
|
37
|
+
|
|
38
|
+
The browser workspace leads with projects rather than queue rows. A project card keeps milestone progress separate from terminal acceptance; project detail exposes the terminal outcome/rules, milestone acceptance, checkpoint revision lineage, human gates, reservations, acceptance reviews, and the independent final-judge event. Task workspaces are linked to a project only by explicit `checkpoint.project_id`, so the projection does not guess ownership from names. Unlinked tasks remain available in `task_workspaces` and the operational queue.
|
|
39
|
+
|
|
40
|
+
The interface is responsive down to a narrow phone viewport, keyboard-operable for project selection, dependency-free, and safe for static export. It remains deliberately read-only: there are no approve, retry, settle, resume, or mutation controls.
|
|
18
41
|
|
|
19
42
|
## Projection and security rules
|
|
20
43
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Operator workspace project contract
|
|
2
|
+
|
|
3
|
+
## Terminal outcome
|
|
4
|
+
|
|
5
|
+
An operator can use a local browser workspace every day to understand a Loop Engineering project's terminal contract, milestone progress and total-project status; trace checkpoint revisions, human gates, external-action reservations, acceptance reviews, and the independent final judge; and do so without granting the UI mutation authority or exposing raw/private artifacts.
|
|
6
|
+
|
|
7
|
+
No single page, milestone, checkpoint, or passing test subset completes this project. Completion requires every milestone below, the release checks, and an independent final judgement to pass together.
|
|
8
|
+
|
|
9
|
+
## Milestones
|
|
10
|
+
|
|
11
|
+
1. **Project projection and information architecture** — project-first projection, terminal/milestone separation, task linkage, revision and acceptance timeline, project detail API, responsive read-only workspace, security regression coverage, and operator documentation. Status: implemented and independently accepted.
|
|
12
|
+
2. **Daily interaction experience** — persistent URL filters and selection, accessible navigation/focus states, useful empty/error states, gate/reservation inspection, and responsive/accessibility assertions. Status: implemented and accepted.
|
|
13
|
+
3. **Release hardening** — schema compatibility, realistic large-workspace benchmark, live/static export tests, documentation, package dry run, full regression, and independent final judge. Status: implemented and accepted.
|
|
14
|
+
|
|
15
|
+
## Acceptance mapping
|
|
16
|
+
|
|
17
|
+
- Terminal contract, milestone and project status: milestone 1 projection/API/UI tests.
|
|
18
|
+
- Revision lineage and acceptance/final-judge timeline: milestone 1 task workspace fixtures.
|
|
19
|
+
- Human gates and external-action reservations: milestones 1–2 projection and inspection views.
|
|
20
|
+
- Safe read-only boundary: no mutation endpoints or controls; loopback default, explicit non-loopback override, redaction, XSS/path traversal checks.
|
|
21
|
+
- Information architecture, interaction and responsive UI: milestones 1–2.
|
|
22
|
+
- Tests, documentation and release-level acceptance: milestones 1–3.
|
|
23
|
+
|
|
24
|
+
## Explicit boundaries
|
|
25
|
+
|
|
26
|
+
The workspace does not approve gates, resume tasks, settle reservations, modify contracts, repair artifacts, or serve arbitrary evidence files. Those actions remain in governed CLI/control-plane workflows. A future authenticated mutation product would require a separate authority model and project contract.
|
|
27
|
+
|
|
28
|
+
## Project completion rule
|
|
29
|
+
|
|
30
|
+
The project reached its terminal acceptance candidate after all three milestones passed, all required checks were recorded, no required item remained unmet, and the independent 25-check final judgement returned `accept`. Milestone 1 acceptance was recorded only as a stage completion; project completion is based on the combined terminal evidence.
|
|
@@ -142,8 +142,51 @@ async function projects(root, warnings) {
|
|
|
142
142
|
const base = path.join(runtime, id);
|
|
143
143
|
const intake = await json(path.join(base, 'intake', 'latest.json'), warnings, root);
|
|
144
144
|
const backlog = await json(path.join(base, 'backlog', 'initial.json'), warnings, root);
|
|
145
|
+
const terminalContract = await json(path.join(base, 'terminal-contract.json'), warnings, root);
|
|
145
146
|
const completion = await json(path.join(base, 'completion', 'latest.json'), warnings, root);
|
|
146
|
-
|
|
147
|
+
const milestones = (backlog?.tasks ?? backlog?.items ?? (Array.isArray(backlog) ? backlog : [])).map((item, index) => ({
|
|
148
|
+
id: item.id ?? `milestone-${index + 1}`, title: item.title ?? item.task ?? `Milestone ${index + 1}`,
|
|
149
|
+
status: item.status ?? 'pending', acceptance: item.acceptance ?? [], evidence: item.evidence ?? []
|
|
150
|
+
}));
|
|
151
|
+
const accepted = completion?.terminalAccepted === true || terminalContract?.terminalState?.accepted === true;
|
|
152
|
+
result.push(clean({
|
|
153
|
+
id, goal: intake?.goal ?? intake?.brief ?? null, brief: intake?.brief ?? null, queue: intake?.queue ?? null,
|
|
154
|
+
status: accepted ? 'completed' : completion?.status ?? terminalContract?.status ?? 'active', terminal_accepted: accepted,
|
|
155
|
+
project_summary: { completed_milestones: milestones.filter((item) => ['completed', 'accepted'].includes(item.status)).length, total_milestones: milestones.length, unmet: completion?.unmet ?? terminalContract?.unmet ?? [], blockers: completion?.blockers ?? terminalContract?.blockers ?? [] },
|
|
156
|
+
terminal_contract: terminalContract ? { status: terminalContract.status ?? null, terminal_state: terminalContract.terminalState ?? null, milestone_rule: terminalContract.milestoneRule ?? null, completion_rule: terminalContract.completionRule ?? null, requirements: terminalContract.requirements ?? [], residual_risks: terminalContract.residualRisks ?? [] } : null,
|
|
157
|
+
acceptance: intake?.acceptance ?? null, milestones, completion: completion ?? null,
|
|
158
|
+
evidence_links: [intake && relativeLink(root, path.join(base, 'intake', 'latest.json')), backlog && relativeLink(root, path.join(base, 'backlog', 'initial.json')), terminalContract && relativeLink(root, path.join(base, 'terminal-contract.json')), completion && relativeLink(root, path.join(base, 'completion', 'latest.json'))].filter(Boolean)
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
return result.sort((a, b) => a.id.localeCompare(b.id));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function taskWorkspaces(root, warnings) {
|
|
165
|
+
const loops = path.join(root, 'runtime', 'loops'); const result = [];
|
|
166
|
+
for (const queue of (await dirs(loops)).filter((name) => !['control-plane', 'action-reservations', 'execution-ledger', 'projects'].includes(name))) {
|
|
167
|
+
const tasksRoot = path.join(loops, queue, 'tasks');
|
|
168
|
+
for (const taskId of await dirs(tasksRoot)) {
|
|
169
|
+
const base = path.join(tasksRoot, taskId);
|
|
170
|
+
const [contract, finalJudgement, humanContext, amendment] = await Promise.all([
|
|
171
|
+
json(path.join(base, 'task_contract.json'), warnings, root), json(path.join(base, 'final_judgement.json'), warnings, root),
|
|
172
|
+
json(path.join(base, 'human_input_context.json'), warnings, root), json(path.join(base, 'amendments', 'latest.json'), warnings, root)
|
|
173
|
+
]);
|
|
174
|
+
const checkpoints = []; const reviews = [];
|
|
175
|
+
for (const file of await files(path.join(base, 'checkpoints'))) { const item = await json(file, warnings, root); if (item) checkpoints.push({ ...clean(item), evidence_link: relativeLink(root, file) }); }
|
|
176
|
+
for (const file of await files(path.join(base, 'reviews'))) { const item = await json(file, warnings, root); if (item) reviews.push({ ...clean(item), evidence_link: relativeLink(root, file) }); }
|
|
177
|
+
if (!contract && !checkpoints.length && !finalJudgement) continue;
|
|
178
|
+
const projectIds = [...new Set(checkpoints.map((item) => item.project_id).filter(Boolean))];
|
|
179
|
+
const timeline = [
|
|
180
|
+
...checkpoints.map((item) => ({ type: 'checkpoint', at: item.created_at ?? null, id: item.checkpoint_id, status: item.status, summary: item.summary, revision_of: item.revises_checkpoint_id ?? null, amendment_version: item.amendment_version ?? 0, evidence_link: item.evidence_link })),
|
|
181
|
+
...reviews.map((item) => {
|
|
182
|
+
const failed = Array.isArray(item.failed) ? item.failed : item.failed == null ? [] : [item.failed];
|
|
183
|
+
const passed = Array.isArray(item.passed) ? item.passed : item.passed == null ? [] : [item.passed];
|
|
184
|
+
return { type: 'acceptance_review', at: item.created_at ?? null, id: item.checkpoint_id, status: item.status, summary: failed.map(String).join('; ') || passed.slice(0, 2).map(String).join('; '), evidence_link: item.evidence_link };
|
|
185
|
+
}),
|
|
186
|
+
...(finalJudgement ? [{ type: 'final_judge', at: finalJudgement.created_at ?? null, id: 'final_judgement', status: finalJudgement.outcome, summary: (finalJudgement.reasons ?? []).join('; '), evidence_link: relativeLink(root, path.join(base, 'final_judgement.json')) }] : [])
|
|
187
|
+
].sort((a, b) => String(a.at ?? '').localeCompare(String(b.at ?? '')) || a.type.localeCompare(b.type));
|
|
188
|
+
result.push(clean({ id: taskId, queue, title: contract?.title ?? taskId, scope: contract?.task_scope ?? null, project_ids: projectIds, amendment_version: amendment?.version ?? amendment?.amendment_version ?? 0, final_judgement: finalJudgement?.outcome ?? null, gates: humanContext?.gates ?? [], revision_lineage: checkpoints.map((item) => ({ checkpoint_id: item.checkpoint_id, milestone_id: item.milestone_id ?? item.checkpoint_id, revises_checkpoint_id: item.revises_checkpoint_id ?? null, sequence: item.sequence ?? null, status: item.status })), timeline, evidence_links: [relativeLink(root, path.join(base, 'task_contract.json'))].filter(Boolean) }));
|
|
189
|
+
}
|
|
147
190
|
}
|
|
148
191
|
return result.sort((a, b) => a.id.localeCompare(b.id));
|
|
149
192
|
}
|
|
@@ -152,13 +195,14 @@ export async function buildOperatorProjection(root, options = {}) {
|
|
|
152
195
|
const resolved = path.resolve(root); const now = options.now ? new Date(options.now) : new Date();
|
|
153
196
|
if (Number.isNaN(now.getTime())) throw new Error('Invalid projection time.');
|
|
154
197
|
const warnings = []; const before = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
155
|
-
const [control, reservations, steps, queueList, projectList, trustEvidence] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), executionSteps(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings), productionEvidence(resolved, warnings)]);
|
|
198
|
+
const [control, reservations, steps, queueList, projectList, taskList, trustEvidence] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), executionSteps(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings), taskWorkspaces(resolved, warnings), productionEvidence(resolved, warnings)]);
|
|
156
199
|
const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...steps.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
|
|
157
200
|
const counts = Object.fromEntries([...STATES].map((state) => [state, 0]));
|
|
158
201
|
for (const item of [...control.todos, ...queueList.flatMap((queue) => queue.tasks)]) counts[item.state] += 1;
|
|
159
202
|
const after = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
160
203
|
if (before && after && before.mtimeMs !== after.mtimeMs) warnings.push({ code: 'concurrent_update', artifact: 'runtime/loops', message: 'Artifacts changed while the projection was read; refresh recommended.' });
|
|
161
|
-
|
|
204
|
+
const projectsWithTasks = projectList.map((project) => ({ ...project, tasks: taskList.filter((task) => task.project_ids.includes(project.id)) }));
|
|
205
|
+
return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, task_workspace_count: taskList.length, todo_count: control.todos.length, action_count: reservations.length, step_count: steps.length, reconciliation_required_steps: steps.filter((item) => item.state === 'reconciliation_required').length, production_trust: trustEvidence?.passed ? 'passed' : trustEvidence ? 'failed' : 'not_generated' }, projects: projectsWithTasks, task_workspaces: taskList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: [...control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), ...taskList.flatMap((task) => task.gates.map((gate) => ({ task_id: task.id, ...gate })))], actions: reservations, execution_steps: steps, production_evidence: trustEvidence, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
|
|
162
206
|
}
|
|
163
207
|
|
|
164
208
|
export function filterProjection(projection, options = {}) {
|
|
@@ -173,8 +217,8 @@ export function dashboardHealth(projection, options = {}) {
|
|
|
173
217
|
return { schema_version: DASHBOARD_SCHEMA_VERSION, status: projection.health.status === 'ok' && !stale ? 'ok' : 'degraded', read_only: true, stale, freshness_seconds: projection.source.freshness_seconds, warnings: projection.health.warnings };
|
|
174
218
|
}
|
|
175
219
|
|
|
176
|
-
function html() {
|
|
177
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Loop Engineering
|
|
220
|
+
function html(dataUrl = '/api/v1/overview?') {
|
|
221
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Loop Engineering Workspace</title><style>:root{color-scheme:dark;--bg:#091018;--panel:#111c28;--line:#26384b;--muted:#91a5b8;--accent:#77d6c9}*{box-sizing:border-box}body{font:14px/1.5 ui-sans-serif,system-ui;margin:0;background:var(--bg);color:#eef6fb}header{position:sticky;top:0;z-index:2;padding:1rem clamp(1rem,4vw,3rem);background:#091018ee;border-bottom:1px solid var(--line);backdrop-filter:blur(12px)}h1,h2,h3,p{margin:.2rem 0}.eyebrow,.muted{color:var(--muted)}main{padding:1.25rem clamp(1rem,4vw,3rem);display:grid;gap:1rem}.stats,.projects,.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.8rem}.card{background:linear-gradient(150deg,#142230,#0d1822);border:1px solid var(--line);border-radius:14px;padding:1rem;min-width:0}.stat strong{font-size:1.65rem}.bar{height:7px;background:#223141;border-radius:9px;overflow:hidden;margin:.75rem 0}.bar i{display:block;height:100%;background:var(--accent)}.pill{display:inline-block;padding:.15rem .5rem;border-radius:1rem;background:#24384a;color:#d9edf8}.ok{color:#7ee2a8}.warn{color:#ffcf70}button,input,select{padding:.65rem;background:#111d29;color:inherit;border:1px solid #3a5268;border-radius:8px}button.project{width:100%;text-align:left;font:inherit}button.project[aria-pressed=true]{border-color:var(--accent)}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.6rem;border-bottom:1px solid var(--line);vertical-align:top}.scroll{overflow:auto}.timeline{border-left:2px solid var(--line);padding-left:1rem}.event{margin:.65rem 0}.project{cursor:pointer}.project:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}#detail:empty{display:none}.empty{padding:1rem;text-align:center;color:var(--muted)}@media(max-width:640px){header{position:static}.desktop{display:none}td,th{min-width:120px}.split{grid-template-columns:1fr}input,select{width:100%;margin-top:.4rem}}</style></head><body><header><div class="eyebrow">READ-ONLY OPERATOR WORKSPACE</div><h1>Loop Engineering</h1><p id="health" role="status" aria-live="polite">Loading durable artifacts…</p></header><main><section class="stats" id="stats" aria-label="Workspace summary"></section><section aria-labelledby="projects-title"><h2 id="projects-title">Projects</h2><p class="muted">Project completion is separate from milestone completion.</p><div class="projects" id="projects"></div></section><section class="card" id="detail" tabindex="-1" aria-live="polite"></section><section class="card"><div class="split"><div><h2>Operational queue</h2><p class="muted">Human gates, reservations and reconciliation remain visible and immutable here.</p></div><div><label>Search <input id="q" placeholder="Task, owner or action"></label><label>State <select id="s"><option value="">All states</option></select></label></div></div><div class="scroll"><table><thead><tr><th>State</th><th>Task</th><th class="desktop">Owner</th><th>Next action</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>const dataUrl=${JSON.stringify(dataUrl)},initial=new URLSearchParams(location.search);const states=['runnable','active','parked','waiting_for_human','waiting_for_external_condition','timed_out_or_escalated','reconciliation_required','blocked','completed','failed'];s.innerHTML+=states.map(x=>'<option>'+x+'</option>').join('');q.value=initial.get('q')??'';s.value=initial.get('state')??'';let selected=initial.get('project'),model=null;const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text??'';if(cls)n.className=cls;return n};function syncUrl(){if(dataUrl.startsWith('./'))return;const p=new URLSearchParams();if(q.value)p.set('q',q.value);if(s.value)p.set('state',s.value);if(selected)p.set('project',selected);history.replaceState(null,'','?'+p)}function projectDetail(p,focus=false){selected=p.id;detail.replaceChildren();detail.append(el('h2',p.id),el('p',p.goal,'muted'));const c=p.terminal_contract;if(c){detail.append(el('h3','Terminal contract'),el('p',c.terminal_state?.userVisibleOutcome??'No user-visible outcome recorded'),el('p',c.milestone_rule??'','warn'))}detail.append(el('h3','Milestones'));const list=el('div');for(const m of p.milestones??[])list.append(el('p',(m.status??'pending')+' · '+m.title));detail.append(list,el('h3','Gates & reservations'));const operational=el('div');const gates=(p.tasks??[]).flatMap(x=>x.gates??[]);operational.append(el('p',(gates.length?gates.length+' human/external gate(s)':'No project gates')+' · '+model.actions.length+' workspace reservation(s)','muted'));detail.append(operational,el('h3','Acceptance & final judge timeline'));const tl=el('div',null,'timeline');const events=(p.tasks??[]).flatMap(x=>x.timeline??[]);if(!events.length)tl.append(el('p','No acceptance events yet.','empty'));for(const t of events)tl.append(el('div',(t.at??'undated')+' · '+t.type+' · '+t.status+' — '+(t.summary??''),'event'));detail.append(tl);syncUrl();drawProjects();if(focus)detail.focus()}function drawProjects(){projects.replaceChildren(...model.projects.map(p=>{const n=el('button',null,'card project');n.type='button';n.setAttribute('aria-pressed',String(selected===p.id));const done=p.project_summary?.completed_milestones??0,total=p.project_summary?.total_milestones??0;n.append(el('span',p.terminal_accepted?'PROJECT ACCEPTED':String(p.status).toUpperCase(),p.terminal_accepted?'pill ok':'pill'),el('h3',p.id),el('p',p.goal,'muted'));const b=el('div',null,'bar'),i=el('i');i.style.width=(total?done/total*100:0)+'%';b.append(i);n.append(b,el('p',done+' / '+total+' milestones'));n.onclick=()=>projectDetail(p,true);return n}));if(!model.projects.length)projects.append(el('p','No project artifacts found.','card empty'))}async function draw(){try{const params=new URLSearchParams({q:q.value,state:s.value});const response=await fetch(dataUrl+(dataUrl.includes('?')?params:''));if(!response.ok)throw new Error('HTTP '+response.status);model=await response.json();health.textContent=model.health.status+' · generated '+new Date(model.generated_at).toLocaleString()+' · source is read-only';stats.replaceChildren(...[['Projects',model.overview.project_count],['Task workspaces',model.overview.task_workspace_count],['Human gates',model.gates.length],['Reservations',model.overview.action_count]].map(([k,v])=>{const n=el('div',null,'card stat');n.append(el('div',k,'muted'),el('strong',v));return n}));drawProjects();const all=[...model.todos,...model.queues.flatMap(x=>x.tasks)];rows.replaceChildren(...all.map(x=>{const tr=el('tr');for(const [v,c] of [[x.state,''],[x.title,''],[x.owner??'—','desktop'],[x.next_action??'—','']])tr.append(el('td',String(v),c));return tr}));if(!all.length){const td=el('td','No matching operational work.','empty');td.colSpan=4;const tr=el('tr');tr.append(td);rows.append(tr)}const chosen=model.projects.find(p=>p.id===selected);if(chosen)projectDetail(chosen)}catch(error){health.textContent='Unable to load workspace · '+error.message;health.className='warn'}}q.oninput=()=>{syncUrl();draw()};s.onchange=()=>{syncUrl();draw()};addEventListener('popstate',()=>location.reload());draw()</script></body></html>`;
|
|
178
222
|
}
|
|
179
223
|
|
|
180
224
|
function loopback(host) { return host === '127.0.0.1' || host === '::1' || host === 'localhost'; }
|
|
@@ -195,6 +239,8 @@ export async function createDashboardServer(root, options = {}) {
|
|
|
195
239
|
else if (url.pathname === '/api/v1/todos') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') }).todos;
|
|
196
240
|
else if (url.pathname.startsWith('/api/v1/todos/')) body = projection.todos.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/todos/'.length)))) ?? null;
|
|
197
241
|
else if (url.pathname === '/api/v1/actions') body = projection.actions;
|
|
242
|
+
else if (url.pathname === '/api/v1/projects') body = projection.projects;
|
|
243
|
+
else if (url.pathname.startsWith('/api/v1/projects/')) body = projection.projects.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/projects/'.length)))) ?? null;
|
|
198
244
|
else { response.writeHead(404); return response.end('not found'); }
|
|
199
245
|
response.writeHead(body === null ? 404 : 200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); response.end(request.method === 'HEAD' ? '' : `${JSON.stringify(body)}\n`);
|
|
200
246
|
} catch (error) { response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }); response.end(`${JSON.stringify({ error: 'projection_failed', message: String(error.message) })}\n`); }
|
|
@@ -207,6 +253,6 @@ export async function exportDashboard(root, outputDir, options = {}) {
|
|
|
207
253
|
const target = path.resolve(outputDir); const projection = await buildOperatorProjection(root, options);
|
|
208
254
|
await mkdir(target, { recursive: true });
|
|
209
255
|
await writeFile(path.join(target, 'projection.json'), `${JSON.stringify(projection, null, 2)}\n`);
|
|
210
|
-
await writeFile(path.join(target, 'index.html'), html(
|
|
256
|
+
await writeFile(path.join(target, 'index.html'), html('./projection.json'));
|
|
211
257
|
return { schema_version: DASHBOARD_SCHEMA_VERSION, output_dir: target, files: ['index.html', 'projection.json'], read_only_source: true };
|
|
212
258
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
"loop-engineering-hermes-install": "scripts/hermes-install.mjs",
|
|
15
15
|
"loop-engineering-hermes-doctor": "scripts/hermes-doctor.mjs",
|
|
16
16
|
"loop-engineering-hermes-smoke": "scripts/hermes-smoke.mjs",
|
|
17
|
+
"loop-engineering-dashboard-autostart-install": "scripts/dashboard-autostart-install.mjs",
|
|
17
18
|
"run-loop-cron.sh": "scripts/run-loop-cron.sh"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
|
-
"test": "npm run check && npm run check:competitive",
|
|
21
|
+
"test": "npm run check && npm run check:competitive && npm run check:operator-workspace",
|
|
22
|
+
"check:operator-workspace": "node --check scripts/operator-workspace-final-judgement.mjs && node --check scripts/dashboard-autostart-install.mjs && node scripts/dashboard-autostart-self-test.mjs && node scripts/operator-workspace-final-judgement.mjs",
|
|
21
23
|
"check:competitive": "node --check lib/transactional-state-kernel.mjs && node --check lib/goal-api.mjs && node scripts/competitive-acceptance.mjs",
|
|
22
24
|
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
|
|
23
25
|
"demo:adapter": "node examples/adapter-sdk-demo.mjs",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
function parseArgs(argv) {
|
|
7
|
+
const out = { root: process.cwd(), listen: 'localhost', host: null, port: 4174, tailscaleBin: 'tailscale', systemctlBin: 'systemctl', confirmInstall: false, json: false };
|
|
8
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
9
|
+
const arg = argv[i];
|
|
10
|
+
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
11
|
+
else if (arg === '--listen') out.listen = argv[++i];
|
|
12
|
+
else if (arg === '--host') out.host = argv[++i];
|
|
13
|
+
else if (arg === '--port') out.port = Number(argv[++i]);
|
|
14
|
+
else if (arg === '--tailscale-bin') out.tailscaleBin = argv[++i];
|
|
15
|
+
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
16
|
+
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
17
|
+
else if (arg === '--json') out.json = true;
|
|
18
|
+
else if (arg === '--help') out.help = true;
|
|
19
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
20
|
+
}
|
|
21
|
+
if (!['localhost', 'tailscale'].includes(out.listen)) throw new Error('--listen must be localhost or tailscale.');
|
|
22
|
+
if (out.host && out.listen === 'tailscale') throw new Error('--host cannot be combined with --listen tailscale.');
|
|
23
|
+
if (out.host && !['127.0.0.1', '::1', 'localhost'].includes(out.host)) throw new Error('--host only accepts a loopback address; use --listen tailscale for Tailnet access.');
|
|
24
|
+
if (!Number.isInteger(out.port) || out.port < 1 || out.port > 65535) throw new Error('--port must be an integer from 1 to 65535.');
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function systemdEscapePath(value) {
|
|
29
|
+
return [...value].map((character) => {
|
|
30
|
+
if (/[A-Za-z0-9_/:.\-]/.test(character)) return character;
|
|
31
|
+
return `\\x${character.codePointAt(0).toString(16).padStart(2, '0')}`;
|
|
32
|
+
}).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(command, args) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
38
|
+
let stdout = ''; let stderr = '';
|
|
39
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
40
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
41
|
+
child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
|
|
42
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const args = parseArgs(process.argv.slice(2));
|
|
47
|
+
if (args.help) {
|
|
48
|
+
console.log('Usage: loop-engineering-dashboard-autostart-install --root <workspace> [--listen localhost|tailscale] [--port 4174] [--tailscale-bin tailscale] [--confirm-install] [--json]');
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const packageRoot = path.resolve(new URL('..', import.meta.url).pathname);
|
|
53
|
+
const cli = path.join(packageRoot, 'bin', 'loop-engineering.mjs');
|
|
54
|
+
const userSystemdDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
55
|
+
const dashboardUnit = 'loop-engineering-dashboard.service';
|
|
56
|
+
const gatewayUnits = ['openclaw-gateway.service', 'hermes-gateway.service'];
|
|
57
|
+
const servicePath = path.join(userSystemdDir, dashboardUnit);
|
|
58
|
+
const host = args.listen === 'localhost' ? (args.host || '127.0.0.1') : await (async () => {
|
|
59
|
+
const result = await run(args.tailscaleBin, ['ip', '-4']);
|
|
60
|
+
if (result.code !== 0) throw new Error(`Cannot resolve Tailscale IPv4 address: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
|
|
61
|
+
const addresses = result.stdout.split(/\s+/).filter(Boolean);
|
|
62
|
+
if (addresses.length !== 1 || !/^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(addresses[0])) throw new Error(`Expected exactly one Tailscale IPv4 address, received: ${addresses.join(', ') || 'none'}`);
|
|
63
|
+
return addresses[0];
|
|
64
|
+
})();
|
|
65
|
+
const nonLoopback = args.listen === 'tailscale' ? ' --allow-non-loopback' : '';
|
|
66
|
+
const service = `[Unit]\nDescription=Taskforce Loop Engineering read-only project workspace\nAfter=network.target\n\n[Service]\nType=simple\nWorkingDirectory=${systemdEscapePath(args.root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(cli)} dashboard-serve --root ${systemdEscapePath(args.root)} --host ${host} --port ${args.port}${nonLoopback}\nRestart=on-failure\nRestartSec=3s\nNoNewPrivileges=true\nPrivateTmp=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
67
|
+
const dropIn = `[Unit]\nWants=${dashboardUnit}\nAfter=${dashboardUnit}\n`;
|
|
68
|
+
const report = {
|
|
69
|
+
status: args.confirmInstall ? 'installed' : 'plan_only',
|
|
70
|
+
listen: args.listen,
|
|
71
|
+
host,
|
|
72
|
+
dashboard: `http://${host}:${args.port}/`,
|
|
73
|
+
readOnly: true,
|
|
74
|
+
service: servicePath,
|
|
75
|
+
gatewayDropIns: gatewayUnits.map((unit) => path.join(userSystemdDir, `${unit}.d`, 'loop-engineering-dashboard.conf')),
|
|
76
|
+
writesEnabled: args.confirmInstall
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (args.confirmInstall) {
|
|
80
|
+
await mkdir(userSystemdDir, { recursive: true });
|
|
81
|
+
await writeFile(servicePath, service);
|
|
82
|
+
for (const unit of gatewayUnits) {
|
|
83
|
+
const directory = path.join(userSystemdDir, `${unit}.d`);
|
|
84
|
+
await mkdir(directory, { recursive: true });
|
|
85
|
+
await writeFile(path.join(directory, 'loop-engineering-dashboard.conf'), dropIn);
|
|
86
|
+
}
|
|
87
|
+
const reload = await run(args.systemctlBin, ['--user', 'daemon-reload']);
|
|
88
|
+
if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(reload.stderr || reload.stdout).trim()}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : `${report.status}: ${report.dashboard}\nservice: ${report.service}\ngateways: ${gatewayUnits.join(', ')}`);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-dashboard-autostart-'));
|
|
8
|
+
const mockSystemctl = path.join(root, 'systemctl.mjs');
|
|
9
|
+
const mockTailscale = path.join(root, 'tailscale.mjs');
|
|
10
|
+
const calls = path.join(root, 'calls.jsonl');
|
|
11
|
+
process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
|
|
12
|
+
process.env.SYSTEMCTL_CAPTURE = calls;
|
|
13
|
+
await writeFile(mockSystemctl, `#!/usr/bin/env node\nimport { appendFile } from 'node:fs/promises';\nawait appendFile(process.env.SYSTEMCTL_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');\n`);
|
|
14
|
+
await chmod(mockSystemctl, 0o755);
|
|
15
|
+
await writeFile(mockTailscale, `#!/usr/bin/env node\nif (process.argv.slice(2).join(' ') !== 'ip -4') process.exit(2);\nconsole.log('100.64.10.20');\n`);
|
|
16
|
+
await chmod(mockTailscale, 0o755);
|
|
17
|
+
const installer = new URL('./dashboard-autostart-install.mjs', import.meta.url).pathname;
|
|
18
|
+
const result = await new Promise((resolve) => {
|
|
19
|
+
const child = spawn(process.execPath, [installer, '--root', root, '--port', '4174', '--systemctl-bin', mockSystemctl, '--confirm-install', '--json'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
20
|
+
let stdout = ''; let stderr = '';
|
|
21
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
22
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
23
|
+
});
|
|
24
|
+
if (result.code !== 0) throw new Error(result.stderr);
|
|
25
|
+
const report = JSON.parse(result.stdout);
|
|
26
|
+
if (report.status !== 'installed' || report.dashboard !== 'http://127.0.0.1:4174/') throw new Error('unexpected install report');
|
|
27
|
+
const unitDir = path.join(process.env.XDG_CONFIG_HOME, 'systemd', 'user');
|
|
28
|
+
const service = await readFile(path.join(unitDir, 'loop-engineering-dashboard.service'), 'utf8');
|
|
29
|
+
if (!service.includes('dashboard-serve') || !service.includes('--port 4174') || !service.includes('NoNewPrivileges=true')) throw new Error('dashboard service is incomplete');
|
|
30
|
+
for (const gateway of ['openclaw-gateway.service', 'hermes-gateway.service']) {
|
|
31
|
+
const dropIn = await readFile(path.join(unitDir, `${gateway}.d`, 'loop-engineering-dashboard.conf'), 'utf8');
|
|
32
|
+
if (!dropIn.includes('Wants=loop-engineering-dashboard.service') || !dropIn.includes('After=loop-engineering-dashboard.service')) throw new Error(`${gateway} is not wired to dashboard`);
|
|
33
|
+
}
|
|
34
|
+
if (!(await readFile(calls, 'utf8')).includes('["--user","daemon-reload"]')) throw new Error('systemd daemon was not reloaded');
|
|
35
|
+
const tailnetResult = await new Promise((resolve) => {
|
|
36
|
+
const child = spawn(process.execPath, [installer, '--root', root, '--listen', 'tailscale', '--tailscale-bin', mockTailscale, '--port', '4174', '--systemctl-bin', mockSystemctl, '--confirm-install', '--json'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
37
|
+
let stdout = ''; let stderr = '';
|
|
38
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
39
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
40
|
+
});
|
|
41
|
+
if (tailnetResult.code !== 0) throw new Error(tailnetResult.stderr);
|
|
42
|
+
const tailnetReport = JSON.parse(tailnetResult.stdout);
|
|
43
|
+
if (tailnetReport.listen !== 'tailscale' || tailnetReport.dashboard !== 'http://100.64.10.20:4174/') throw new Error('unexpected Tailnet install report');
|
|
44
|
+
const tailnetService = await readFile(path.join(unitDir, 'loop-engineering-dashboard.service'), 'utf8');
|
|
45
|
+
if (!tailnetService.includes('--host 100.64.10.20') || !tailnetService.includes('--allow-non-loopback')) throw new Error('Tailnet dashboard service is incomplete');
|
|
46
|
+
console.log('dashboard autostart self-test passed');
|
|
@@ -14,7 +14,8 @@ async function present(file) { try { await access(file); return true; } catch {
|
|
|
14
14
|
async function main() {
|
|
15
15
|
const args = parseArgs(process.argv.slice(2));
|
|
16
16
|
if (args.help) { console.log('Usage: loop-engineering-hermes-doctor [--root workspace] [--queue agent-tasks] [--hermes-bin hermes] [--json]'); return; }
|
|
17
|
-
const
|
|
17
|
+
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
18
|
+
const required = [`configs/loops/queues/${args.queue}.json`, 'configs/loops/workspace-health.json', 'scripts/loops/hermes-loop-dispatch.mjs', 'scripts/loops/hermes-loop.mjs', 'scripts/loops/hermes-loop-notify.mjs', 'runtime/loop-engineering-hermes-install.json', 'AGENTS.md', path.relative(args.root, path.join(systemdUserDir, 'loop-engineering-dashboard.service')), path.relative(args.root, path.join(systemdUserDir, 'hermes-gateway.service.d', 'loop-engineering-dashboard.conf'))];
|
|
18
19
|
const checks = []; for (const relative of required) checks.push({ id: `file:${relative}`, ok: await present(path.join(args.root, relative)) });
|
|
19
20
|
const cli = await run(args.hermesBin, ['--version'], { cwd: args.root }); checks.push({ id: 'hermes_cli', ok: cli.code === 0, detail: (cli.stdout || cli.stderr).trim().slice(0, 300) });
|
|
20
21
|
const sendHelp = await run(args.hermesBin, ['send', '--help'], { cwd: args.root }); checks.push({ id: 'hermes_send', ok: sendHelp.code === 0, detail: (sendHelp.stdout || sendHelp.stderr).trim().slice(0, 300) });
|
|
@@ -27,7 +27,7 @@ if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTUR
|
|
|
27
27
|
function run(command, args, options = {}) { return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd || root, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('close', (code) => resolve({ code, stdout, stderr })); }); }
|
|
28
28
|
const installer = new URL('./hermes-install.mjs', import.meta.url).pathname; const doctor = new URL('./hermes-doctor.mjs', import.meta.url).pathname; const smoke = new URL('./hermes-smoke.mjs', import.meta.url).pathname; const loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
|
|
29
29
|
const base = ['--root', root, '--queue', 'hermes-tasks', '--hermes-bin', mockHermes, '--systemctl-bin', mockSystemctl];
|
|
30
|
-
const plan = await run(process.execPath, [installer, ...base, '--json']); const planReport = JSON.parse(plan.stdout); if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.confirmationSummary?.targetPlatform !== 'Hermes' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('Hermes')) throw new Error(`Hermes install plan failed: ${plan.stderr}`);
|
|
30
|
+
const plan = await run(process.execPath, [installer, ...base, '--json']); const planReport = JSON.parse(plan.stdout); if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.confirmationSummary?.targetPlatform !== 'Hermes' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('Hermes') || planReport.dashboardAutostart?.gateway !== 'hermes-gateway.service') throw new Error(`Hermes install plan failed: ${plan.stderr}`);
|
|
31
31
|
const humanPlan = await run(process.execPath, [installer, ...base]); if (humanPlan.code !== 0 || !humanPlan.stdout.includes('Installation confirmation') || !humanPlan.stdout.includes('target platform: Hermes') || !humanPlan.stdout.includes('writes enabled: no (plan only)')) throw new Error('human-readable Hermes confirmation summary missing');
|
|
32
32
|
const install = await run(process.execPath, [installer, ...base, '--confirm-install', '--json']); if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`Hermes install failed: ${install.stderr}`);
|
|
33
33
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/hermes-tasks.json'), 'utf8')); if (queue.dispatcher !== 'node scripts/loops/hermes-loop-dispatch.mjs' || queue.scheduler?.required !== true) throw new Error('Hermes queue wiring missing');
|
|
@@ -38,6 +38,7 @@ const dispatcher = await readFile(path.join(root, 'scripts/loops/hermes-loop-dis
|
|
|
38
38
|
const notifier = path.join(root, 'scripts/loops/hermes-loop-notify.mjs'); const notify = await run(process.execPath, [notifier, 'hello from loop'], { env: { ...process.env, HERMES_SEND_CAPTURE: sendCapture, LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'telegram', target: 'telegram:12345' }) } }); if (notify.code !== 0) throw new Error(`Hermes notifier failed: ${notify.stderr}`);
|
|
39
39
|
const sent = JSON.parse(await readFile(sendCapture, 'utf8')); if (!sent.includes('telegram:12345') || !sent.includes('hello from loop')) throw new Error('Hermes notifier did not preserve delivery target/message');
|
|
40
40
|
const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/hermes-loop-hermes-tasks-scheduler.service'); const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/hermes-loop-hermes-tasks-scheduler.timer'); const service = await readFile(serviceFile, 'utf8'); if (service.includes('WorkingDirectory="') || !service.includes('\\x20') || !service.includes('\\xe5\\xae\\x89\\xe8\\xa3\\x85')) throw new Error('Hermes systemd paths were not escaped');
|
|
41
|
+
const dashboardServiceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/loop-engineering-dashboard.service'); const dashboardDropIn = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/hermes-gateway.service.d/loop-engineering-dashboard.conf'); if (!(await readFile(dashboardServiceFile, 'utf8')).includes('dashboard-serve') || !(await readFile(dashboardDropIn, 'utf8')).includes('Wants=loop-engineering-dashboard.service')) throw new Error('Hermes installer did not install Dashboard gateway autostart');
|
|
41
42
|
const verify = await run('systemd-analyze', ['verify', serviceFile, timerFile]); if (verify.code !== 0) throw new Error(`Hermes systemd units invalid: ${verify.stderr || verify.stdout}`);
|
|
42
43
|
const doctorResult = await run(process.execPath, [doctor, '--root', root, '--queue', 'hermes-tasks', '--hermes-bin', mockHermes, '--json']); if (doctorResult.code !== 0 || JSON.parse(doctorResult.stdout).status !== 'ok') throw new Error(`Hermes doctor failed: ${doctorResult.stderr || doctorResult.stdout}`);
|
|
43
44
|
const smokeResult = await run(process.execPath, [smoke, '--root', root, '--queue', 'hermes-tasks', '--hermes-bin', mockHermes, '--loop-bin', loopBin, '--json']); if (smokeResult.code !== 0 || JSON.parse(smokeResult.stdout).status !== 'ok') throw new Error(`Hermes smoke failed: ${smokeResult.stderr || smokeResult.stdout}`);
|
|
@@ -5,13 +5,15 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', systemctlBin: 'systemctl', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', systemctlBin: 'systemctl', dashboardListen: 'localhost', tailscaleBin: 'tailscale', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
12
12
|
else if (arg === '--queue') out.queue = argv[++i];
|
|
13
13
|
else if (arg === '--hermes-bin') out.hermesBin = argv[++i];
|
|
14
14
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
15
|
+
else if (arg === '--dashboard-listen') out.dashboardListen = argv[++i];
|
|
16
|
+
else if (arg === '--tailscale-bin') out.tailscaleBin = argv[++i];
|
|
15
17
|
else if (arg === '--language') out.language = argv[++i];
|
|
16
18
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
17
19
|
else if (arg === '--force') out.force = true;
|
|
@@ -19,6 +21,7 @@ function parseArgs(argv) {
|
|
|
19
21
|
else if (arg === '--help' || arg === '-h') out.help = true;
|
|
20
22
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
21
23
|
}
|
|
24
|
+
if (!['localhost', 'tailscale'].includes(out.dashboardListen)) throw new Error('--dashboard-listen must be localhost or tailscale.');
|
|
22
25
|
return out;
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -145,7 +148,7 @@ function instructionsBlock({ queue, language }) {
|
|
|
145
148
|
async function main() {
|
|
146
149
|
const args = parseArgs(process.argv.slice(2));
|
|
147
150
|
args.language = resolveLanguage(args.language);
|
|
148
|
-
if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--language auto|en|zh] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
|
|
151
|
+
if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--dashboard-listen localhost|tailscale] [--tailscale-bin tailscale] [--language auto|en|zh] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
|
|
149
152
|
safeId(args.queue, 'queue');
|
|
150
153
|
args.hermesBin = await resolveExecutable(args.hermesBin);
|
|
151
154
|
const hermes = await run(args.hermesBin, ['--version'], { cwd: args.root });
|
|
@@ -155,8 +158,9 @@ async function main() {
|
|
|
155
158
|
const unit = `hermes-loop-${args.queue}-scheduler.service`; const timer = `hermes-loop-${args.queue}-scheduler.timer`;
|
|
156
159
|
const files = { workspaceHealth: path.join(args.root, 'configs/loops/workspace-health.json'), queueConfig: path.join(args.root, 'configs/loops/queues', `${args.queue}.json`), dispatcher: path.join(args.root, 'scripts/loops/hermes-loop-dispatch.mjs'), wrapper: path.join(args.root, 'scripts/loops/hermes-loop.mjs'), notifier: path.join(args.root, 'scripts/loops/hermes-loop-notify.mjs'), manifest: path.join(args.root, 'runtime/loop-engineering-hermes-install.json'), instructions: path.join(args.root, 'AGENTS.md'), schedulerService: path.join(systemdUserDir, unit), schedulerTimer: path.join(systemdUserDir, timer) };
|
|
157
160
|
const conflicts = []; for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
158
|
-
const
|
|
159
|
-
const
|
|
161
|
+
const dashboardDescription = args.dashboardListen === 'tailscale' ? 'read-only Tailnet address on port 4174 coupled to hermes-gateway.service' : 'read-only http://127.0.0.1:4174/ coupled to hermes-gateway.service';
|
|
162
|
+
const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, dashboard: dashboardDescription, notificationTarget: text(args.language, 'source-bound at runtime (original Hermes conversation)', '运行时绑定到原始 Hermes 会话'), writesEnabled: args.confirmInstall };
|
|
163
|
+
const report = { version: 1, platform: 'hermes', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, hermesBin: args.hermesBin, hermesVersion: (hermes.stdout || hermes.stderr).trim().slice(0, 200), scheduler: { required: true, unit, timer }, dashboardAutostart: { required: true, listen: args.dashboardListen, address: args.dashboardListen === 'localhost' ? 'http://127.0.0.1:4174/' : null, gateway: 'hermes-gateway.service' }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
160
164
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
161
165
|
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
162
166
|
if (args.confirmInstall) {
|
|
@@ -168,6 +172,9 @@ async function main() {
|
|
|
168
172
|
for (const [kind, content] of Object.entries(contents)) await writeFile(files[kind], content);
|
|
169
173
|
const reload = await run(args.systemctlBin, ['--user', 'daemon-reload'], { cwd: args.root }); if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(reload.stderr || reload.stdout).trim()}`);
|
|
170
174
|
const enable = await run(args.systemctlBin, ['--user', 'enable', '--now', timer], { cwd: args.root }); if (enable.code !== 0) throw new Error(`Cannot enable Hermes Loop scheduler ${timer}: ${(enable.stderr || enable.stdout).trim()}`);
|
|
175
|
+
const dashboardInstaller = new URL('./dashboard-autostart-install.mjs', import.meta.url).pathname;
|
|
176
|
+
const dashboardInstall = await run(process.execPath, [dashboardInstaller, '--root', args.root, '--listen', args.dashboardListen, '--tailscale-bin', args.tailscaleBin, '--systemctl-bin', args.systemctlBin, '--confirm-install', '--json'], { cwd: args.root });
|
|
177
|
+
if (dashboardInstall.code !== 0) throw new Error(`Cannot install Dashboard gateway autostart: ${(dashboardInstall.stderr || dashboardInstall.stdout).trim()}`);
|
|
171
178
|
const marker = instructionsBlock({ queue: args.queue, language: args.language }); const current = await readFile(files.instructions, 'utf8').catch(() => ''); if (!current.includes('<!-- loop-engineering:hermes:start -->')) await appendFile(files.instructions, marker);
|
|
172
179
|
await mkdir(path.dirname(files.manifest), { recursive: true }); await writeFile(files.manifest, `${JSON.stringify({ version: 2, platform: 'hermes', language: args.language, installedAt: new Date().toISOString(), root: args.root, queue: args.queue, hermesBin: args.hermesBin, files: Object.entries(contents).map(([kind, content]) => ({ kind, path: files[kind], sha256: sha256(content) })), scheduler: { unit, timer } }, null, 2)}\n`);
|
|
173
180
|
}
|
|
@@ -45,6 +45,11 @@ async function main() {
|
|
|
45
45
|
'scripts/loops/openclaw-loop-notify.mjs',
|
|
46
46
|
'AGENTS.md'
|
|
47
47
|
];
|
|
48
|
+
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
49
|
+
required.push(
|
|
50
|
+
path.relative(args.root, path.join(systemdUserDir, 'loop-engineering-dashboard.service')),
|
|
51
|
+
path.relative(args.root, path.join(systemdUserDir, 'openclaw-gateway.service.d', 'loop-engineering-dashboard.conf'))
|
|
52
|
+
);
|
|
48
53
|
const checks = [];
|
|
49
54
|
for (const relative of required) checks.push({ id: `file:${relative}`, ok: await present(path.join(args.root, relative)) });
|
|
50
55
|
const cli = await run(args.openclawBin, ['--version'], { cwd: args.root });
|
|
@@ -46,7 +46,7 @@ function run(args, env = process.env) {
|
|
|
46
46
|
const installBase = ['--root', root, '--queue', 'test-tasks', '--openclaw-bin', mockOpenClaw, '--systemctl-bin', mockSystemctl];
|
|
47
47
|
const plan = await run([...installBase, '--json']);
|
|
48
48
|
const planReport = JSON.parse(plan.stdout);
|
|
49
|
-
if (plan.code !== 0 || planReport.language !== 'en' || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw')) throw new Error(`plan failed: ${plan.stderr}`);
|
|
49
|
+
if (plan.code !== 0 || planReport.language !== 'en' || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw') || planReport.dashboardAutostart?.gateway !== 'openclaw-gateway.service') throw new Error(`plan failed: ${plan.stderr}`);
|
|
50
50
|
const zhPlan = await run([...installBase, '--language', 'zh']);
|
|
51
51
|
if (zhPlan.code !== 0 || !zhPlan.stdout.includes('安装确认') || !zhPlan.stdout.includes('目标平台:OpenClaw') || !zhPlan.stdout.includes('允许写入:否(仅生成计划)')) throw new Error('explicit Chinese installation summary missing');
|
|
52
52
|
const autoZhPlan = await run([...installBase, '--json'], { ...process.env, LC_ALL: 'zh_CN.UTF-8', LANG: 'C' });
|
|
@@ -79,9 +79,12 @@ const missingSourceRoute = await new Promise((resolve) => {
|
|
|
79
79
|
if (missingSourceRoute.code !== 2 || !missingSourceRoute.stderr.includes('requires conversation metadata')) throw new Error('installed wrapper did not fail closed without source routing');
|
|
80
80
|
const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
|
|
81
81
|
const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
|
|
82
|
+
const dashboardServiceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/loop-engineering-dashboard.service');
|
|
83
|
+
const dashboardDropIn = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-gateway.service.d/loop-engineering-dashboard.conf');
|
|
82
84
|
const service = await readFile(serviceFile, 'utf8');
|
|
83
85
|
const timer = await readFile(timerFile, 'utf8');
|
|
84
86
|
if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
|
|
87
|
+
if (!(await readFile(dashboardServiceFile, 'utf8')).includes('dashboard-serve') || !(await readFile(dashboardDropIn, 'utf8')).includes('Wants=loop-engineering-dashboard.service')) throw new Error('OpenClaw installer did not install Dashboard gateway autostart');
|
|
85
88
|
if (service.includes('WorkingDirectory="') || service.includes('ExecStart="') || !service.includes('\\x20') || !service.includes('\\xe5\\xae\\x89\\xe8\\xa3\\x85')) throw new Error('scheduler service paths were not encoded with systemd path escapes');
|
|
86
89
|
const systemdVerify = await new Promise((resolve) => {
|
|
87
90
|
const child = spawn('systemd-analyze', ['verify', serviceFile, timerFile], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', dashboardListen: 'localhost', tailscaleBin: 'tailscale', language: 'auto', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
@@ -13,6 +13,8 @@ function parseArgs(argv) {
|
|
|
13
13
|
else if (arg === '--worker-agent') out.workerAgent = argv[++i];
|
|
14
14
|
else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
|
|
15
15
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
16
|
+
else if (arg === '--dashboard-listen') out.dashboardListen = argv[++i];
|
|
17
|
+
else if (arg === '--tailscale-bin') out.tailscaleBin = argv[++i];
|
|
16
18
|
else if (arg === '--language') out.language = argv[++i];
|
|
17
19
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
18
20
|
else if (arg === '--force') out.force = true;
|
|
@@ -20,6 +22,7 @@ function parseArgs(argv) {
|
|
|
20
22
|
else if (arg === '--help' || arg === '-h') out.help = true;
|
|
21
23
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
22
24
|
}
|
|
25
|
+
if (!['localhost', 'tailscale'].includes(out.dashboardListen)) throw new Error('--dashboard-listen must be localhost or tailscale.');
|
|
23
26
|
return out;
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -290,7 +293,7 @@ async function main() {
|
|
|
290
293
|
const args = parseArgs(process.argv.slice(2));
|
|
291
294
|
args.language = resolveLanguage(args.language);
|
|
292
295
|
if (args.help) {
|
|
293
|
-
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--language auto|en|zh] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
296
|
+
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--dashboard-listen localhost|tailscale] [--tailscale-bin tailscale] [--language auto|en|zh] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
294
297
|
return;
|
|
295
298
|
}
|
|
296
299
|
safeId(args.queue, 'queue');
|
|
@@ -315,8 +318,9 @@ async function main() {
|
|
|
315
318
|
};
|
|
316
319
|
const conflicts = [];
|
|
317
320
|
for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
318
|
-
const
|
|
319
|
-
const
|
|
321
|
+
const dashboardDescription = args.dashboardListen === 'tailscale' ? 'read-only Tailnet address on port 4174 coupled to openclaw-gateway.service' : 'read-only http://127.0.0.1:4174/ coupled to openclaw-gateway.service';
|
|
322
|
+
const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, dashboard: dashboardDescription, notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
|
|
323
|
+
const report = { version: 1, platform: 'openclaw', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, scheduler: { required: true, unit: schedulerUnit, timer: schedulerTimer }, dashboardAutostart: { required: true, listen: args.dashboardListen, address: args.dashboardListen === 'localhost' ? 'http://127.0.0.1:4174/' : null, gateway: 'openclaw-gateway.service' }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
320
324
|
report.next = args.confirmInstall ? text(args.language, 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.', '运行 loop-engineering-openclaw-doctor,然后路由一个无害的冒烟任务。') : text(args.language, 'Review this plan, then rerun with --confirm-install.', '检查此计划,然后使用 --confirm-install 重新运行。');
|
|
321
325
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
322
326
|
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
@@ -359,6 +363,9 @@ async function main() {
|
|
|
359
363
|
if (daemonReload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(daemonReload.stderr || daemonReload.stdout).trim() || `exit ${daemonReload.code}`}`);
|
|
360
364
|
const enableTimer = await run(args.systemctlBin, ['--user', 'enable', '--now', schedulerTimer], { cwd: args.root });
|
|
361
365
|
if (enableTimer.code !== 0) throw new Error(`Cannot enable Loop scheduler timer ${schedulerTimer}: ${(enableTimer.stderr || enableTimer.stdout).trim() || `exit ${enableTimer.code}`}`);
|
|
366
|
+
const dashboardInstaller = new URL('./dashboard-autostart-install.mjs', import.meta.url).pathname;
|
|
367
|
+
const dashboardInstall = await run(process.execPath, [dashboardInstaller, '--root', args.root, '--listen', args.dashboardListen, '--tailscale-bin', args.tailscaleBin, '--systemctl-bin', args.systemctlBin, '--confirm-install', '--json'], { cwd: args.root });
|
|
368
|
+
if (dashboardInstall.code !== 0) throw new Error(`Cannot install Dashboard gateway autostart: ${(dashboardInstall.stderr || dashboardInstall.stdout).trim()}`);
|
|
362
369
|
const instructions = await exists(files.instructions) ? await readFile(files.instructions, 'utf8') : '';
|
|
363
370
|
const managedInstructions = instructionsBlock(args);
|
|
364
371
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
@@ -12,6 +12,10 @@ await mkdir(path.join(loops, 'action-reservations'), { recursive: true });
|
|
|
12
12
|
await mkdir(path.join(loops, 'legacy', 'waiting'), { recursive: true });
|
|
13
13
|
await mkdir(path.join(loops, 'legacy', 'active'), { recursive: true });
|
|
14
14
|
await mkdir(path.join(loops, 'projects', 'p3', 'intake'), { recursive: true });
|
|
15
|
+
await mkdir(path.join(loops, 'projects', 'p3', 'backlog'), { recursive: true });
|
|
16
|
+
await mkdir(path.join(loops, 'projects', 'p3', 'completion'), { recursive: true });
|
|
17
|
+
await mkdir(path.join(loops, 'legacy', 'tasks', 'task-project', 'checkpoints'), { recursive: true });
|
|
18
|
+
await mkdir(path.join(loops, 'legacy', 'tasks', 'task-project', 'reviews'), { recursive: true });
|
|
15
19
|
await mkdir(path.join(root, '.production-evidence'), { recursive: true });
|
|
16
20
|
const now = '2026-08-13T16:00:00.000Z';
|
|
17
21
|
const control = {
|
|
@@ -27,6 +31,13 @@ await writeFile(path.join(loops, 'action-reservations', 'x.json'), JSON.stringif
|
|
|
27
31
|
await writeFile(path.join(loops, 'legacy', 'waiting', 'vps.json'), JSON.stringify({ id: 'vps', title: 'VPS down', parked: { kind: 'external_condition', next_check_at: '2026-08-13T17:00:00.000Z' }, provider: 'hidden' }));
|
|
28
32
|
await writeFile(path.join(loops, 'legacy', 'active', 'bad.json'), '{broken');
|
|
29
33
|
await writeFile(path.join(loops, 'projects', 'p3', 'intake', 'latest.json'), JSON.stringify({ version: 1, goal: 'Operator dashboard', token: 'hidden' }));
|
|
34
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'backlog', 'initial.json'), JSON.stringify({ tasks: [{ id: 'm1', title: 'Projection foundation', status: 'completed' }, { id: 'm2', title: 'Interaction polish', status: 'pending' }] }));
|
|
35
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'terminal-contract.json'), JSON.stringify({ status: 'active', terminalState: { accepted: false, userVisibleOutcome: 'Daily browser workspace' }, milestoneRule: 'A milestone is not project completion.', completionRule: 'All requirements and final judge must pass.', requirements: [{ id: 'workspace', status: 'active' }] }));
|
|
36
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'completion', 'latest.json'), JSON.stringify({ status: 'active', terminalAccepted: false, unmet: ['interaction polish'] }));
|
|
37
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'task_contract.json'), JSON.stringify({ title: 'Project foundation', task_scope: 'project' }));
|
|
38
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'checkpoints', 'cp1.json'), JSON.stringify({ checkpoint_id: 'cp1', milestone_id: 'm1', project_id: 'p3', status: 'ready_for_acceptance', summary: 'Foundation ready', amendment_version: 2, created_at: now }));
|
|
39
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'reviews', 'cp1.json'), JSON.stringify({ checkpoint_id: 'cp1', status: 'accepted', passed: 'projection complete', created_at: now }));
|
|
40
|
+
await writeFile(path.join(loops, 'legacy', 'tasks', 'task-project', 'final_judgement.json'), JSON.stringify({ outcome: 'project_in_progress', reasons: ['Milestone accepted; terminal contract remains open.'], created_at: now }));
|
|
30
41
|
await writeFile(path.join(root, '.production-evidence', 'public-summary.json'), JSON.stringify({ schema: 'loop.production_trust_public_summary', schema_version: 1, passed: true, evidence_digest: 'fixture', metrics: { duplicate_settled_effects: 0 } }));
|
|
31
42
|
await registerStep(root, { stepId: 'run:1:effect:send', kind: 'effect', effectKey: 'send:1', input: { api_key: 'hidden', body: 'hello' } });
|
|
32
43
|
const ledgerClaim = await claimStep(root, { stepId: 'run:1:effect:send', owner: 'a' });
|
|
@@ -44,6 +55,11 @@ assert.equal(first.actions[0].state, 'reconciliation_required');
|
|
|
44
55
|
assert.equal(first.execution_steps[0].state, 'reconciliation_required');
|
|
45
56
|
assert.equal(first.overview.reconciliation_required_steps, 1);
|
|
46
57
|
assert.equal(first.overview.production_trust, 'passed');
|
|
58
|
+
assert.equal(first.overview.task_workspace_count, 1);
|
|
59
|
+
assert.equal(first.projects[0].terminal_accepted, false);
|
|
60
|
+
assert.equal(first.projects[0].project_summary.completed_milestones, 1);
|
|
61
|
+
assert.equal(first.projects[0].tasks[0].timeline.at(-1).type, 'final_judge');
|
|
62
|
+
assert.equal(first.projects[0].tasks[0].revision_lineage[0].milestone_id, 'm1');
|
|
47
63
|
assert.equal(first.production_evidence.metrics.duplicate_settled_effects, 0);
|
|
48
64
|
assert.equal(first.queues[0].tasks[0].state, 'waiting_for_external_condition');
|
|
49
65
|
assert.equal(first.agents[0].provider_token, '[REDACTED]');
|
|
@@ -68,6 +84,15 @@ try {
|
|
|
68
84
|
const detail = await fetch(`${base}/api/v1/todos/human`).then((response) => response.json());
|
|
69
85
|
assert.match(detail.title, /onerror/);
|
|
70
86
|
const page = await fetch(base).then((response) => response.text());
|
|
87
|
+
assert.match(page, /aria-live="polite"/);
|
|
88
|
+
assert.match(page, /aria-pressed/);
|
|
89
|
+
assert.match(page, /history\.replaceState/);
|
|
90
|
+
assert.match(page, /No matching operational work/);
|
|
91
|
+
assert.match(page, /Unable to load workspace/);
|
|
92
|
+
assert.match(page, /Gates & reservations/);
|
|
93
|
+
assert.doesNotMatch(page, /approve|confirm-send|settle reservation/i);
|
|
94
|
+
const project = await fetch(`${base}/api/v1/projects/p3`).then((response) => response.json());
|
|
95
|
+
assert.equal(project.terminal_contract.milestone_rule, 'A milestone is not project completion.');
|
|
71
96
|
assert.doesNotMatch(page, /<img src=x/);
|
|
72
97
|
assert.equal((await fetch(`${base}/api/v1/todos/%2e%2e%2fsecret`)).status, 400);
|
|
73
98
|
assert.equal((await fetch(`${base}/api/v1/unknown`)).status, 404);
|
|
@@ -81,4 +106,4 @@ const started = performance.now(); const large = await buildOperatorProjection(r
|
|
|
81
106
|
assert.equal(large.queues.find((queue) => queue.id === 'large').tasks.length, 500);
|
|
82
107
|
assert.ok(performance.now() - started < 5000, '500 task projection should finish under 5 seconds');
|
|
83
108
|
|
|
84
|
-
console.log(JSON.stringify({ status: 'ok', assertions: 'empty-compatible, legacy, malformed, deterministic, read-only, P0 gates, P1 reconciliation, P2 lease/handoff, redaction, XSS, traversal, bind safety, export, restart-safe server, large queue performance' }));
|
|
109
|
+
console.log(JSON.stringify({ status: 'ok', assertions: 'empty-compatible, legacy, malformed, deterministic, read-only, project terminal/milestone split, revision and final-judge timeline, persistent URL state, keyboard and live-region accessibility, empty/error states, responsive UI, P0 gates, P1 reconciliation, P2 lease/handoff, redaction, XSS, traversal, bind safety, export, restart-safe server, large queue performance' }));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const files = {
|
|
5
|
+
implementation: await readFile(new URL('../lib/operator-dashboard.mjs', import.meta.url), 'utf8'),
|
|
6
|
+
tests: await readFile(new URL('./operator-dashboard-self-test.mjs', import.meta.url), 'utf8'),
|
|
7
|
+
docs: await readFile(new URL('../docs/operator-dashboard.md', import.meta.url), 'utf8'),
|
|
8
|
+
contract: await readFile(new URL('../docs/operator-workspace-project.md', import.meta.url), 'utf8'),
|
|
9
|
+
schema: await readFile(new URL('../templates/operator-projection.schema.json', import.meta.url), 'utf8')
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const criteria = [
|
|
13
|
+
['terminal contract', files.implementation, 'terminal_contract'],
|
|
14
|
+
['milestone status', files.implementation, 'milestones'],
|
|
15
|
+
['total project acceptance', files.implementation, 'terminal_accepted'],
|
|
16
|
+
['revision lineage', files.implementation, 'revision_lineage'],
|
|
17
|
+
['human gates', files.implementation, 'human_input_context.json'],
|
|
18
|
+
['reservations', files.implementation, 'action-reservations'],
|
|
19
|
+
['acceptance review', files.implementation, 'acceptance_review'],
|
|
20
|
+
['independent final judge', files.implementation, 'final_judge'],
|
|
21
|
+
['project detail endpoint', files.implementation, '/api/v1/projects/'],
|
|
22
|
+
['read-only HTTP methods', files.implementation, "request.method !== 'GET'"],
|
|
23
|
+
['loopback safety', files.implementation, 'allowNonLoopback'],
|
|
24
|
+
['redaction', files.implementation, '[REDACTED]'],
|
|
25
|
+
['persistent URL state', files.implementation, 'history.replaceState'],
|
|
26
|
+
['keyboard controls', files.implementation, "n.type='button'"],
|
|
27
|
+
['live status', files.implementation, 'aria-live'],
|
|
28
|
+
['responsive viewport', files.implementation, '@media(max-width:640px)'],
|
|
29
|
+
['empty state', files.implementation, 'No matching operational work'],
|
|
30
|
+
['error state', files.implementation, 'Unable to load workspace'],
|
|
31
|
+
['static export', files.implementation, "html('./projection.json')"],
|
|
32
|
+
['schema task workspaces', files.schema, 'task_workspaces'],
|
|
33
|
+
['security tests', files.tests, '%2e%2e%2fsecret'],
|
|
34
|
+
['large workspace test', files.tests, '500 task projection'],
|
|
35
|
+
['operator docs', files.docs, 'responsive'],
|
|
36
|
+
['terminal completion rule', files.contract, 'No single page, milestone, checkpoint'],
|
|
37
|
+
['three milestones', files.contract, 'Release hardening']
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const [name, body, token] of criteria) assert.ok(body.includes(token), `${name} criterion missing: ${token}`);
|
|
41
|
+
console.log(JSON.stringify({ outcome: 'accept', checks: criteria.length, accepted: criteria.map(([name]) => name) }, null, 2));
|
|
@@ -72,6 +72,8 @@ loop-engineering-openclaw-install \
|
|
|
72
72
|
--confirm-install
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
The confirmed install also creates `loop-engineering-dashboard.service` on port `4174` and couples it to `openclaw-gateway.service`. Dashboard listening defaults to local-only `127.0.0.1`. To permit Tailnet clients, pass `--dashboard-listen tailscale`; the installer resolves `tailscale ip -4`, binds only that `100.x` address, and fails closed rather than binding all interfaces. Tailnet mode relies on Tailscale ACLs/Grants and does not add application-level login. The plan must declare the selected mode before writes, and the doctor must verify the Dashboard service and gateway drop-in. Use `loop-engineering-dashboard-autostart-install --listen localhost|tailscale` only for standalone install or repair.
|
|
76
|
+
|
|
75
77
|
The installer never creates the worker agent. After installation, verify wiring before using a real task:
|
|
76
78
|
|
|
77
79
|
```bash
|
|
@@ -111,6 +113,8 @@ loop-engineering-hermes-smoke \
|
|
|
111
113
|
--queue agent-tasks
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
The confirmed Hermes install creates the same Dashboard service and couples it to `hermes-gateway.service`. It accepts `--dashboard-listen localhost|tailscale` with the same default-local, Tailnet-only binding, fail-closed resolution, and ACL/Grant boundary as OpenClaw. The plan and doctor must expose and verify this coupling.
|
|
117
|
+
|
|
114
118
|
The generated worker uses `hermes -z` (`--oneshot`); the notifier uses `hermes send` without
|
|
115
119
|
an LLM call. Preserve source metadata and pass `--source-target` in Hermes
|
|
116
120
|
`platform:chat_id[:thread_id]` format. The managed systemd scheduler owns wakeups
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://loop-engineering.local/schema/operator-projection-v1.json","title":"Loop Engineering read-only operator projection v1","type":"object","required":["schema_version","generated_at","source","health","overview","projects","queues","todos","agents","handoffs","gates","actions","cost"],"properties":{"schema_version":{"const":"1.0.0"},"generated_at":{"type":"string","format":"date-time"},"source":{"type":"object"},"health":{"type":"object"},"overview":{"type":"object"},"projects":{"type":"array"},"queues":{"type":"array"},"todos":{"type":"array"},"agents":{"type":"array"},"handoffs":{"type":"array"},"gates":{"type":"array"},"actions":{"type":"array"},"cost":{"type":"object"}},"additionalProperties":false}
|
|
1
|
+
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://loop-engineering.local/schema/operator-projection-v1.json","title":"Loop Engineering read-only operator projection v1","type":"object","required":["schema_version","generated_at","source","health","overview","projects","task_workspaces","queues","todos","agents","handoffs","gates","actions","cost"],"properties":{"schema_version":{"const":"1.0.0"},"generated_at":{"type":"string","format":"date-time"},"source":{"type":"object"},"health":{"type":"object"},"overview":{"type":"object"},"projects":{"type":"array"},"task_workspaces":{"type":"array"},"queues":{"type":"array"},"todos":{"type":"array"},"agents":{"type":"array"},"handoffs":{"type":"array"},"gates":{"type":"array"},"actions":{"type":"array"},"execution_steps":{"type":"array"},"production_evidence":{},"cost":{"type":"object"}},"additionalProperties":false}
|