diff --git a/CHANGELOG.md b/CHANGELOG.md index 5485dfe..eb4e595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v2.4.0 + +The cockpit: a redesigned dashboard and one place for everything that waits on a human. + +- `src/inbox.ts` and `factory inbox [ [--text ]] [--json]`: what is waiting (plan to + approve, question, PR in review, parked, failed) derived from labels and the thread. Acting posts the same + `/factory` comment a human would type, so GitHub stays the only state. +- Dashboard redesign, no build step: machinist design tokens (light, dark, system), self-hosted Manrope + (OFL), a sidebar that becomes a bottom nav under 768px. Views: Inbox (conversation and composer), Line + (one row per issue, stations sized by duration), Runs, Analytics, and a run side sheet with stages, + artifact preview and log. +- New routes: `/api/inbox`, `/api/inbox/:n/act`, `/api/analytics`, `/api/runs/:id/stages`, + `/api/issues/:n/artifacts`, `/api/line`. Artifacts are text only, capped at 1 MiB, sandboxed headers. +- Dashboard auth: constant-time token compare, header or HttpOnly session cookie (`POST /api/session`), + no `?token=`, and a default-deny route allow-list proven by a test that walks every route. +- `factory logs N [--follow] [--json]`; terminal control sequences stripped from displayed text; revision + history keeps every earlier `/factory revise`. +- Ports from owainlewis/machinist and assembler, with their tests, recorded in `THIRD_PARTY_NOTICES.md` + and enforced by `tests/provenance.test.ts`. + +Not in this release: the merge dry-run inbox item (v2.9), the Agents page (v2.6). + ## v2.3.0 Honest foundation: what the README and labels promise now matches what the code does. diff --git a/Makefile b/Makefile index 664d3e0..aa965de 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ skills-check: up: @trap 'kill 0' EXIT INT TERM; \ bun bin/factory watch --repo-dir "$${REPO_DIR:-.}" & \ - bun bin/factory dashboard & \ + bun bin/factory dashboard --repo-dir "$${REPO_DIR:-.}" & \ wait watch: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..b7e6633 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,56 @@ +# Third-party notices + +Code ported from other projects. Each ported file starts with a `Ported from` header naming the +upstream repo, commit, path and lines; `tests/provenance.test.ts` keeps this file and those headers +in step. Only MIT-licensed code is copied. + +## owainlewis/machinist@3943516 + +Source: https://github.com/owainlewis/machinist (MIT, Copyright (c) 2026 Owain Lewis) + +- `dashboard/public/styles.css` from `internal/controlplane/web/src/styles.css` +- `dashboard/public/lib/run-metrics.js` from `internal/controlplane/web/src/run-metrics.js` +- `dashboard/public/lib/runs-board.js` from `internal/controlplane/web/src/runs-board.js` +- `dashboard/public/lib/status-loader.js` from `internal/controlplane/web/src/status-loader.js` +- `dashboard/public/lib/analytics-state.js` from `internal/controlplane/web/src/analytics-state.js` +- `dashboard/public/lib/task-presentation.js` from `internal/controlplane/web/src/task-presentation.js` +- `dashboard/public/lib/routes.js` from `internal/controlplane/web/src/routes.js` +- `src/revision.ts` from `internal/runner/revision.go` (shape from `internal/protocol/revision.go`) + +## owainlewis/assembler@7cac671 + +Source: https://github.com/owainlewis/assembler (MIT, Copyright (c) 2026 Owain Lewis) + +- `src/display.ts` from `src/display.ts` +- `src/logs.ts` from `src/runs.ts` + +## License text (both projects) + +MIT License + +Copyright (c) 2026 Owain Lewis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Manrope (font) + +`dashboard/public/fonts/manrope-latin.woff2` is the Latin subset of Manrope, copied from the build output +of owainlewis/machinist@3943516. Copyright 2018 The Manrope Project Authors +(https://github.com/sharanda/manrope), SIL Open Font License 1.1. The licence text is in +`dashboard/public/fonts/OFL.txt`, shipped beside the font. diff --git a/bin/factory b/bin/factory index 2695511..da0a916 100755 --- a/bin/factory +++ b/bin/factory @@ -14,6 +14,8 @@ import { EXIT, UsageError, failureJson, successJson } from "../src/cli-output"; import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } from "../src/watch"; import { ShellGateRunner } from "../src/gates"; import { scan } from "../src/scan"; +import { streamLogs } from "../src/logs"; +import { act, buildInbox, type InboxAction } from "../src/inbox"; import { reset, rebaseline } from "../src/reset"; import { runDoctor, fixDoctor } from "../src/doctor"; import { createDashboard } from "../dashboard/server"; @@ -147,6 +149,41 @@ async function cmdPark(): Promise { console.log(`factory park #${issueNumber}: needs-human — ${reason}`); } +async function cmdLogs(): Promise { + const issue = Number(args[1]); + if (!Number.isInteger(issue) || issue < 1) throw new UsageError("logs: an issue number is required, e.g. `factory logs 12 --follow`"); + const state = new FactoryState(flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH); + const repo = flag("repo") ?? process.env.FACTORY_REPO ?? state.listRuns().find((r) => r.issue === issue)?.repo ?? ""; + const controller = new AbortController(); + process.on("SIGINT", () => controller.abort()); + await streamLogs(state, repo, issue, { follow: has("follow"), stage: flag("stage"), json: has("json"), signal: controller.signal }); +} + +async function cmdInbox(): Promise { + const repo = flag("repo") ?? process.env.FACTORY_REPO; + if (!repo) throw new UsageError("inbox: --repo (or FACTORY_REPO) is required"); + const github = new GitHub(); + const items = buildInbox(await github.listOpenIssues(repo)); + const positional: string[] = []; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--json") continue; + if (args[i]!.startsWith("--")) i++; // a flag and its value + else positional.push(args[i]!); + } + const [issueArg, actionArg] = positional; + if (issueArg) { + const item = items.find((i) => i.issue === Number(issueArg)); + if (!item) throw new UsageError(`inbox: #${issueArg} is not waiting for you`); + if (!actionArg) throw new UsageError(`inbox: choose an action: ${item.actions.join(", ")}`); + const posted = await act(github, repo, item, actionArg as InboxAction, flag("text") ?? ""); + console.log(has("json") ? successJson({ issue: item.issue, posted }, true) : `#${item.issue}: posted "${posted}"`); + return; + } + if (has("json")) console.log(successJson({ items }, true)); + else if (!items.length) console.log("Nothing is waiting for you."); + else for (const i of items) console.log(`#${i.issue} ${i.kind} (${i.actions.join("/")}): ${i.title}`); +} + async function cmdScan(): Promise { const cloneDir = await resolveCloneDir(); const config = await loadConfig(cloneDir); @@ -265,7 +302,8 @@ function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoA async function cmdDashboard(): Promise { const dbPath = flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH; - const repo = flag("repo") ?? process.env.FACTORY_REPO ?? ""; + const repoDir = flag("repo-dir"); + const repo = flag("repo") ?? process.env.FACTORY_REPO ?? (repoDir ? (await loadConfig(resolve(repoDir))).repo : ""); const state = new FactoryState(dbPath); const github = new GitHub(); serveDashboard(state, github, repo); @@ -320,6 +358,10 @@ async function main(): Promise { return cmdPark(); case "dashboard": return cmdDashboard(); + case "logs": + return cmdLogs(); + case "inbox": + return cmdInbox(); case "scan": return cmdScan(); case "reset": diff --git a/dashboard/analytics.ts b/dashboard/analytics.ts new file mode 100644 index 0000000..4579ad7 --- /dev/null +++ b/dashboard/analytics.ts @@ -0,0 +1,60 @@ +// What the Analytics view shows, computed from stage_runs and runs. The +// server sends numbers; the page only draws them. + +import type { Run, StageRun } from "../src/state"; + +export interface Bucket { + readonly key: string; + readonly attempts: number; + readonly failures: number; + readonly costUsd: number; + readonly tokensIn: number; + readonly tokensOut: number; + readonly avgDurationMs: number; +} + +export interface Analytics { + readonly runs: number; + readonly shipped: number; + // shipped / finished runs (shipped, failed, rejected, cancelled, needs-human); null before any finish. + readonly successRate: number | null; + readonly spend7dUsd: number; + readonly spend30dUsd: number; + readonly byStage: readonly Bucket[]; + readonly byAgent: readonly Bucket[]; +} + +const FINISHED = new Set(["shipped", "failed", "rejected", "cancelled", "needs-human"]); +const DAY_MS = 86_400_000; + +function bucketBy(rows: readonly StageRun[], key: (r: StageRun) => string): Bucket[] { + const groups = new Map(); + for (const r of rows) groups.set(key(r), [...(groups.get(key(r)) ?? []), r]); + return [...groups.entries()] + .map(([k, g]) => ({ + key: k, + attempts: g.length, + failures: g.filter((r) => r.exit_code !== 0 || r.killed_reason).length, + costUsd: g.reduce((n, r) => n + r.cost_usd, 0), + tokensIn: g.reduce((n, r) => n + r.tokens_in, 0), + tokensOut: g.reduce((n, r) => n + r.tokens_out, 0), + avgDurationMs: Math.round(g.reduce((n, r) => n + r.duration_ms, 0) / g.length), + })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +export function analytics(runs: readonly Run[], stageRuns: readonly StageRun[], now = new Date()): Analytics { + const finished = runs.filter((r) => FINISHED.has(r.status)); + const shipped = runs.filter((r) => r.status === "shipped").length; + const spendSince = (days: number) => + stageRuns.filter((r) => now.getTime() - Date.parse(r.finished_at) <= days * DAY_MS).reduce((n, r) => n + r.cost_usd, 0); + return { + runs: runs.length, + shipped, + successRate: finished.length ? shipped / finished.length : null, + spend7dUsd: spendSince(7), + spend30dUsd: spendSince(30), + byStage: bucketBy(stageRuns, (r) => r.stage), + byAgent: bucketBy(stageRuns, (r) => r.agent), + }; +} diff --git a/dashboard/public/app.js b/dashboard/public/app.js new file mode 100644 index 0000000..0fb3a60 --- /dev/null +++ b/dashboard/public/app.js @@ -0,0 +1,330 @@ +// The factory cockpit. Vanilla ES modules, no build step. Every string from +// the server goes in as a text node (see h()); no markup is ever built from it. + +import { routeFromHash } from "/lib/routes.js"; +import { createStatusLoader } from "/lib/status-loader.js"; +import { formatDurationMillis } from "/lib/run-metrics.js"; + +const STAGES = ["triage", "plan", "build", "verify", "pr"]; +const WAITING = new Set(["needs-info", "awaiting-approval", "needs-human", "failed"]); +const ACTIVE = new Set(["running", "verifying"]); +const ACTION_LABEL = { approve: "Approve plan", revise: "Request changes", answer: "Send answer", retry: "Retry", cancel: "Cancel run" }; +const ICONS = { + inbox: "M3 13l3-8h12l3 8v6H3zM3 13h5l1 3h6l1-3h5", + line: "M4 6h10M4 12h16M4 18h7", + runs: "M4 5h16M4 10h16M4 15h16M4 20h10", + analytics: "M5 20V10M12 20V4M19 20v-7", + agents: "M8 8h8v8H8zM4 10v4M20 10v4M10 4h4M10 20h4", + theme: "M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9z", +}; +const NAV = [["inbox", "Inbox"], ["line", "Line"], ["runs", "Runs"], ["analytics", "Analytics"]]; + +const state = { route: routeFromHash(location.hash), inbox: [], repo: "", selected: null, thread: null, filter: "all", data: {}, error: {} }; + +function h(tag, attrs, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === false || v == null) continue; + if (k.startsWith("on")) el.addEventListener(k.slice(2), v); + else if (k === "class") el.className = v; + else el.setAttribute(k, v === true && !/^(data|aria)-/.test(k) ? "" : String(v)); + } + for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid); + return el; +} +const svg = (path) => { + const s = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + s.setAttribute("viewBox", "0 0 24 24"); + s.setAttribute("aria-hidden", "true"); + const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); + p.setAttribute("d", path); + s.append(p); + return s; +}; + +const money = (n) => `$${(n || 0).toFixed(2)}`; +const compact = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n || 0)); +function age(iso) { + if (!iso) return ""; + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m`; + if (mins < 1440) return `${Math.floor(mins / 60)}h`; + return `${Math.floor(mins / 1440)}d`; +} +const tone = (status) => (status === "shipped" ? "ok" : ["failed", "rejected"].includes(status) ? "bad" : WAITING.has(status) ? "warn" : ACTIVE.has(status) ? "live" : ""); +const statusText = (s) => s.replace(/-/g, " "); +const cleanBody = (body) => body.replace(//g, "").trim(); + +async function api(path, init) { + const res = await fetch(path, init); + if (res.status === 401) { showLogin(); throw new Error("Sign in to continue"); } + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || `Request failed (${res.status})`); + return body; +} +const post = (path, body) => api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + +/* ---- states shared by every view ---- */ +const quiet = (title, hint, ...extra) => + h("div", { class: "quiet-state" }, h("div", { class: "quiet-state-mark", "aria-hidden": "true" }, h("i"), h("i"), h("i")), h("strong", null, title), h("span", null, hint), ...extra); +const heading = (title, lede) => h("div", { class: "page-heading" }, h("div", null, h("h1", null, title), lede && h("p", { class: "lede" }, lede))); +const stateOr = (name, ready) => { + if (state.error[name]) return quiet("Could not load this view", state.error[name]); + if (!state.data[name]) return quiet("Loading", "Reading the factory's state."); + return ready(state.data[name]); +}; + +/* ---- Line ---- */ +function stationsFor(row) { + const { run, stages } = row; + const at = STAGES.indexOf(run.stage); + return STAGES.map((name, i) => { + const attempts = stages.filter((s) => s.stage === name); + const ms = attempts.reduce((n, s) => n + s.duration_ms, 0); + const live = i === at && ACTIVE.has(run.status); + const failed = attempts.length > 0 && !attempts[attempts.length - 1].ok && (i < at || run.status === "failed"); + const done = i < at || run.status === "shipped" || (i === at && attempts.length > 0 && !live && !failed && !WAITING.has(run.status)); + const grow = Math.max(1, Math.min(8, Math.round(ms / 30000))); + return { name, node: h("div", { class: "station", style: `flex-grow:${grow}`, "data-done": done, "data-live": live, "data-failed": failed, title: `${name}${ms ? ` · ${formatDurationMillis(ms)}` : ""}` }) }; + }); +} + +function lineRow(row) { + const { run } = row; + const stations = stationsFor(row); + const needs = WAITING.has(run.status); + const agents = [...new Set(row.stages.map((s) => s.agent))].join(", "); + return h("button", { class: "line-row", type: "button", onclick: () => (needs ? go("inbox", run.issue) : (location.hash = `#/runs/${run.issue}`)) }, + h("div", null, h("div", { class: "line-title" }, `#${run.issue} ${run.title}`), h("div", { class: "line-sub" }, [agents, `started ${age(run.started_at)} ago`].filter(Boolean).join(" · "))), + h("div", { class: "stations-col" }, h("div", { class: "stations" }, stations.map((s) => s.node)), h("div", { class: "station-names" }, stations.map((s) => h("span", null, s.name)))), + h("div", { class: `line-state${needs ? " needs-you" : ""}` }, h("span", { class: "num" }, needs ? "Waiting on you" : statusText(run.status)), `${money(run.cost_usd)} · ${compact(run.tokens_in + run.tokens_out)} tokens`)); +} + +function lineView() { + return h("section", null, heading("Line", "Every issue moves left to right. Each block is a stage, sized by how long it took."), + stateOr("line", ({ rows }) => rows.length ? h("div", { class: "line" }, rows.map(lineRow)) : quiet("Nothing on the line", "Label an issue factory:ready to start a run."))); +} + +/* ---- Inbox ---- */ +async function selectItem(issue) { + state.selected = issue; + state.thread = null; + render(); + try { state.thread = (await api(`/api/issues/${issue}/thread`)).issue; } catch (e) { state.thread = { error: e.message }; } + render(); +} + +async function actOn(item, action, text) { + if (action === "cancel" && !confirm(`Cancel the run for #${item.issue}? This closes the issue.`)) return; + try { + await post(`/api/inbox/${item.issue}/act`, { action, text }); + state.selected = null; + await Promise.all([loadInbox(), loadView()]); + } catch (e) { alert(e.message); } + render(); +} + +function conversation(item) { + const t = state.thread; + const body = !t ? quiet("Loading", "Reading the issue thread.") + : t.error ? quiet("Could not load the thread", t.error) + : h("div", { class: "thread" }, [{ author: "issue", body: t.body, bot: false }, ...(t.comments || []).map((c) => ({ author: c.author, body: c.body, bot: c.body.includes(" - - - -
- - + + +Factory cockpit + + + + + +
+
+
+ + factory +
+ + +
+
+
+
+ diff --git a/dashboard/public/lib/analytics-state.js b/dashboard/public/lib/analytics-state.js new file mode 100644 index 0000000..787f783 --- /dev/null +++ b/dashboard/public/lib/analytics-state.js @@ -0,0 +1,11 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/analytics-state.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: none; verbatim. +import { completedRunsForTasks, taskAnalytics } from "./run-metrics.js"; + +export function analyticsState({ jobs, days, loaded, error, now }) { + if (error) return { kind: "error", message: error }; + if (!loaded) return { kind: "loading" }; + + const metrics = taskAnalytics(jobs, days, now); + const runs = completedRunsForTasks(metrics.tasks); + return metrics.totalTasks ? { kind: "ready", metrics, runs } : { kind: "empty", metrics, runs }; +} diff --git a/dashboard/public/lib/routes.js b/dashboard/public/lib/routes.js new file mode 100644 index 0000000..b20acef --- /dev/null +++ b/dashboard/public/lib/routes.js @@ -0,0 +1,15 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/routes.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: the page set is the factory's views (inbox, line, runs, analytics, agents) and a run detail route is #/runs/. +const pages = new Set(["inbox", "line", "runs", "analytics", "agents"]); + +export function routeFromHash(hash) { + const value = hash.replace(/^#\//, ""); + if (value.startsWith("runs/")) { + if (!value.slice(5)) return { view: "runs", jobID: "" }; + try { + return { view: "task", jobID: decodeURIComponent(value.slice(5)) }; + } catch { + return { view: "runs", jobID: "" }; + } + } + return { view: pages.has(value) ? value : "inbox", jobID: "" }; +} diff --git a/dashboard/public/lib/run-metrics.js b/dashboard/public/lib/run-metrics.js new file mode 100644 index 0000000..337855a --- /dev/null +++ b/dashboard/public/lib/run-metrics.js @@ -0,0 +1,133 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/run-metrics.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: none; verbatim. Callers adapt stage_runs rows to the job/run shape (dashboard/analytics.ts). +const zeroTime = "0001-01-01T00:00:00Z"; +const activeTaskStates = new Set(["queued", "running"]); +const terminalTaskStates = new Set(["succeeded", "failed"]); + +export function tasksInWindow(jobs, days, now = new Date()) { + const since = new Date(now); + since.setHours(0, 0, 0, 0); + since.setDate(since.getDate() - Number(days) + 1); + return jobs.filter((job) => { + const createdAt = Date.parse(job.created_at); + return validDate(job.created_at) && createdAt >= since.getTime() && createdAt <= now.getTime(); + }); +} + +export function taskAnalytics(jobs, days, now = new Date()) { + const tasks = tasksInWindow(jobs, days, now); + const terminalTasks = tasks.filter((task) => terminalTaskStates.has(task.state)); + const contributingDurations = terminalTasks.flatMap((task) => { + const duration = taskDurationMillis(task.runs); + return duration === undefined ? [] : [duration]; + }); + const succeededTasks = terminalTasks.filter((task) => task.state === "succeeded").length; + + return { + tasks, + totalTasks: tasks.length, + successRate: terminalTasks.length ? succeededTasks / terminalTasks.length : null, + failedTasks: terminalTasks.length - succeededTasks, + activeTasks: tasks.filter((task) => activeTaskStates.has(task.state)).length, + averageTaskDurationMillis: contributingDurations.length + ? Math.round(contributingDurations.reduce((total, duration) => total + duration, 0) / contributingDurations.length) + : null, + contributingTasks: contributingDurations.length, + }; +} + +export function completedRuns(jobs, days, now = new Date()) { + return completedRunsForTasks(tasksInWindow(jobs, days, now)); +} + +export function completedRunsForTasks(tasks) { + return tasks + .flatMap((job) => job.runs) + .filter((run) => validDate(run.completed_at)) + .sort((left, right) => Date.parse(right.completed_at) - Date.parse(left.completed_at)); +} + +export function formatDurationMillis(milliseconds) { + if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) return "Unavailable"; + if (milliseconds < 1000) return `${milliseconds}ms`; + const seconds = Math.floor(milliseconds / 1000); + const remainder = milliseconds % 1000; + if (seconds < 60) return remainder ? `${seconds}.${String(remainder).padStart(3, "0").replace(/0+$/, "")}s` : `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainingSeconds}s`; +} + +export function formatTokenUsage(value) { + return typeof value === "string" && /^(0|[1-9]\d*)$/.test(value) ? value.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "Unavailable"; +} + +export function formatSuccessRate(rate) { + if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0 || rate > 1) return "Unavailable"; + return `${Math.round(rate * 1000) / 10}%`; +} + +export function tokenUsageSummary(runs) { + let total = 0n; + let reported = 0; + let completed = 0; + for (const run of runs) { + if (!validDate(run.completed_at)) continue; + completed += 1; + if (!validTokenUsage(run.token_usage)) continue; + total += BigInt(run.token_usage); + reported += 1; + } + return { + total: reported ? total.toString() : undefined, + reported, + completed, + unavailable: completed - reported, + }; +} + +export function runModelSummary(runs) { + const models = []; + let hasUnspecifiedModel = false; + for (const run of runs) { + const model = typeof run.model === "string" ? run.model.trim() : ""; + if (!model) { + hasUnspecifiedModel = true; + continue; + } + if (!models.includes(model)) models.push(model); + } + if (hasUnspecifiedModel) models.push("Executor default"); + return models.length ? models.join(" · ") : "Executor default"; +} + +export function taskDurationMillis(runs) { + const executedRuns = runs; + if (!executedRuns.length || !executedRuns.every((run) => validDuration(run.duration_millis))) return undefined; + return executedRuns.reduce((total, run) => total + run.duration_millis, 0); +} + +export function formatReportingCoverage(summary) { + if (!summary.completed) return "No completed runs"; + return `${summary.reported} of ${summary.completed} run${summary.completed === 1 ? "" : "s"}`; +} + +export function formatTaskTokenUsage(summary) { + if (summary.total === undefined) return "Not reported"; + const total = `${formatTokenUsage(summary.total)} tokens`; + if (!summary.unavailable) return total; + return `${total} reported · ${summary.unavailable} run${summary.unavailable === 1 ? "" : "s"} unreported`; +} + +export function runDetails(run) { + const values = [run.executor]; + if (run.worker_name) values.push(run.worker_name); + if (run.model) values.push(run.model); + if (Number.isSafeInteger(run.duration_millis)) values.push(formatDurationMillis(run.duration_millis)); + if (validDate(run.completed_at)) values.push(validTokenUsage(run.token_usage) ? `${formatTokenUsage(run.token_usage)} tokens` : "Token usage unavailable"); + return values.join(" · "); +} + +function validTokenUsage(value) { return typeof value === "string" && /^(0|[1-9]\d*)$/.test(value); } +function validDate(value) { return Boolean(value && value !== zeroTime && Number.isFinite(Date.parse(value))); } +function validDuration(value) { return Number.isSafeInteger(value) && value >= 0; } diff --git a/dashboard/public/lib/runs-board.js b/dashboard/public/lib/runs-board.js new file mode 100644 index 0000000..78b0161 --- /dev/null +++ b/dashboard/public/lib/runs-board.js @@ -0,0 +1,66 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/runs-board.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: none; verbatim. Callers map factory run states onto these states (dashboard/analytics.ts). +export const boardColumns = [ + { id: "queued", title: "Queued", description: "Waiting to start" }, + { id: "running", title: "In progress", description: "Work underway" }, + { id: "attention", title: "Needs attention", description: "Approval or input needed" }, + { id: "finished", title: "Finished", description: "Completed or stopped" }, +]; + +const activeStates = new Set(["queued", "running"]); +const failedStates = new Set(["failed", "timed_out"]); + +export function boardColumnForState(state) { + if (state === "queued") return "queued"; + if (state === "running") return "running"; + if (["blocked", "awaiting_approval", "interrupted"].includes(state)) return "attention"; + return "finished"; +} + +export function needsAttention(state) { + return !activeStates.has(state) && state !== "succeeded"; +} + +export function filterJobs(jobs, filter) { + return jobs.filter((job) => { + if (filter === "active") return activeStates.has(job.state); + if (filter === "failed") return failedStates.has(job.state); + if (filter === "succeeded") return job.state === "succeeded"; + return true; + }); +} + +export function groupJobsByBoardColumn(jobs) { + const groups = { queued: [], running: [], attention: [], finished: [] }; + for (const job of jobs) groups[boardColumnForState(job.state)].push(job); + return groups; +} + +export function jobCounts(jobs) { + return jobs.reduce((result, job) => { + result.all += 1; + if (activeStates.has(job.state)) result.active += 1; + if (failedStates.has(job.state)) result.failed += 1; + if (job.state === "succeeded") result.succeeded += 1; + return result; + }, { all: 0, active: 0, failed: 0, succeeded: 0 }); +} + +export function currentRun(job) { + if (job.workflow) return job.runs.at(-1); + return [...job.runs].reverse().find((run) => run.state !== "queued") || job.runs[0]; +} + +export function jobDisplayTitle(job) { + const title = typeof job.github_issue_title === "string" ? job.github_issue_title.trim() : ""; + return job.task?.title || title || job.task?.spec || job.task?.source_url || job.prompt || job.id; +} + +export function githubIssueReference(job) { + const match = typeof job.trigger_subject === "string" ? job.trigger_subject.match(/\/issues\/(\d+)\/?$/) : null; + return match ? `#${match[1]}` : ""; +} + +export function runProgress(runs) { + const completeStates = new Set(["succeeded", "failed", "timed_out", "cancelled"]); + return { completed: runs.filter((run) => completeStates.has(run.state)).length, total: runs.length }; +} diff --git a/dashboard/public/lib/status-loader.js b/dashboard/public/lib/status-loader.js new file mode 100644 index 0000000..ca4b2a3 --- /dev/null +++ b/dashboard/public/lib/status-loader.js @@ -0,0 +1,21 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/status-loader.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: none; verbatim. +export function createStatusLoader({ request, apply }) { + let latestRequest = 0; + + async function refresh() { + const requestNumber = ++latestRequest; + try { + const status = await request(); + if (requestNumber !== latestRequest) return; + apply({ kind: "success", status }); + } catch (error) { + if (requestNumber !== latestRequest) return; + apply({ kind: "error", message: error instanceof Error ? error.message : String(error) }); + } + } + + return { + refresh, + cancel() { latestRequest += 1; }, + }; +} diff --git a/dashboard/public/lib/task-presentation.js b/dashboard/public/lib/task-presentation.js new file mode 100644 index 0000000..8202b10 --- /dev/null +++ b/dashboard/public/lib/task-presentation.js @@ -0,0 +1,20 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/task-presentation.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: none; verbatim. +export function taskPresentation(job) { + const runs = job.runs || []; + const latest = runs.at(-1); + // Approval belongs to the next stage; its result belongs to the exact + // producing attempt bound by the server, never an arbitrary older success. + const result = job.state === "awaiting_approval" + ? runs.find(run => run.id === latest?.reviewed_run_id) + : latest; + const current = job.workflow?.current_step ?? 0; + return { + result, + history: runs.filter(run => run.id !== result?.id && run.id !== latest?.id), + stages: (job.workflow?.steps || []).map((name, index) => ({ + name, + current: index === current && job.state !== "succeeded", + complete: index < current || job.state === "succeeded", + })), + }; +} diff --git a/dashboard/public/styles.css b/dashboard/public/styles.css new file mode 100644 index 0000000..6525cbb --- /dev/null +++ b/dashboard/public/styles.css @@ -0,0 +1,235 @@ +/* Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/styles.css:1-112 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: plain CSS, no Tailwind; dark tokens on [data-theme] plus prefers-color-scheme; the factory layout (the Line, inbox, sheet) is our own. Manrope is SIL OFL 1.1 (fonts/OFL.txt). */ + +@font-face { + font-family: "Manrope Variable"; + font-style: normal; + font-weight: 200 800; + font-display: swap; + src: url("/fonts/manrope-latin.woff2") format("woff2-variations"); +} + +:root { + color-scheme: light; + --background: #f3f4f1; + --foreground: #1a1d1e; + --surface: #fafbf8; + --sidebar: #e8eae7; + --muted: #e4e7e4; + --muted-foreground: #626a6b; + --border: #c7cdca; + --primary: #1d6683; + --primary-foreground: #f8fcfd; + --success: #326b51; + --warning: #8a651b; + --danger: #a54237; + --ring: #1d6683; + --font-sans: "Manrope Variable", ui-sans-serif, system-ui, sans-serif; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --background: #151819; + --foreground: #edf1ef; + --surface: #1c2021; + --sidebar: #111415; + --muted: #252a2b; + --muted-foreground: #9ba4a4; + --border: #373e3e; + --primary: #66a9c2; + --primary-foreground: #102128; + --success: #78ad91; + --warning: #d0a75c; + --danger: #df8075; + --ring: #66a9c2; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --background: #151819; + --foreground: #edf1ef; + --surface: #1c2021; + --sidebar: #111415; + --muted: #252a2b; + --muted-foreground: #9ba4a4; + --border: #373e3e; + --primary: #66a9c2; + --primary-foreground: #102128; + --success: #78ad91; + --warning: #d0a75c; + --danger: #df8075; + --ring: #66a9c2; + } +} + +* { box-sizing: border-box; border-color: var(--border); } +html { background: var(--background); } +body { + margin: 0; min-width: 320px; min-height: 100vh; + background: var(--background); color: var(--foreground); + font-family: var(--font-sans); font-size: .875rem; line-height: 1.25rem; + font-variant-numeric: tabular-nums; -webkit-font-smoothing: antialiased; +} +button, input, textarea { font: inherit; color: inherit; } +input[type="checkbox"] { accent-color: var(--primary); } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .55; } +a { color: var(--primary); } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +::selection { background: color-mix(in srgb, var(--primary) 25%, transparent); } +.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* Shell */ +.app { display: grid; grid-template-columns: 13.5rem minmax(0, 1fr); min-height: 100vh; } +.app-sidebar { + position: sticky; top: 0; align-self: start; height: 100vh; + display: flex; flex-direction: column; gap: 1.5rem; + background: var(--sidebar); padding: 1.25rem 1rem; + box-shadow: inset -1px 0 color-mix(in srgb, var(--foreground) 4%, transparent); +} +.brand { display: flex; align-items: center; gap: .6rem; } +.brand-wordmark { font-size: 1.125rem; font-weight: 600; letter-spacing: -.02em; } +.mark { width: 2.2rem; height: auto; overflow: visible; color: var(--foreground); } +.mark path { fill: none; stroke-width: 5; stroke-linecap: square; stroke-linejoin: round; } +.mark .mark-a { stroke: currentColor; } +.mark .mark-b { stroke: var(--primary); } +.app-sidebar nav { display: flex; flex-direction: column; gap: .15rem; } +.nav-item { + position: relative; display: flex; align-items: center; gap: .6rem; height: 2.4rem; padding: 0 .625rem; + color: var(--muted-foreground); font-size: .8125rem; text-decoration: none; + background: none; border: 0; text-align: left; transition: color .15s ease, background .15s ease; +} +.nav-item:hover { background: color-mix(in srgb, var(--muted) 65%, transparent); color: var(--foreground); } +.nav-item[aria-current="page"] { color: var(--foreground); } +.nav-item[aria-current="page"]::before { content: ""; position: absolute; left: -.25rem; width: 2px; height: 1.15rem; background: var(--primary); } +.nav-item svg { width: 1.05rem; height: 1.05rem; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.badge { margin-left: auto; min-width: 1.25rem; padding: 0 .35rem; border-radius: 999px; background: var(--primary); color: var(--primary-foreground); font-size: .6875rem; font-weight: 600; text-align: center; } +.sidebar-foot { margin-top: auto; display: grid; gap: .5rem; color: var(--muted-foreground); font-size: .75rem; } +.sidebar-foot .repo { overflow-wrap: anywhere; } +.toggle { display: flex; align-items: center; gap: .5rem; } + +main { min-width: 0; padding: 2rem clamp(1rem, 4vw, 2.5rem) 4rem; max-width: 76rem; } +.page-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; border-bottom: 1px solid var(--border); padding-bottom: 1.2rem; margin-bottom: 1.5rem; } +h1 { margin: 0; font-size: 1.5rem; line-height: 1.35; font-weight: 600; letter-spacing: -.02em; } +h2 { margin: 0 0 .75rem; font-size: 1rem; font-weight: 600; letter-spacing: -.012em; } +.lede { margin: .25rem 0 0; color: var(--muted-foreground); } +.muted { color: var(--muted-foreground); } + +/* Controls */ +.btn { min-height: 2.25rem; padding: 0 .9rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); transition: border-color .15s ease, background .15s ease; } +.btn:hover:not(:disabled) { border-color: color-mix(in srgb, var(--foreground) 35%, var(--border)); } +.btn-primary { background: var(--primary); border-color: var(--primary); color: var(--primary-foreground); } +.btn-danger { color: var(--danger); } +.field-control { width: 100%; min-height: 2.5rem; border: 1px solid var(--border); border-radius: 8px; outline: none; background: var(--background); padding: .625rem .75rem; box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 3%, transparent); transition: border-color .15s ease, box-shadow .15s ease; } +.field-control:focus { border-color: color-mix(in srgb, var(--primary) 60%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); } +textarea.field-control { resize: vertical; min-height: 5rem; } +.tabs { display: flex; gap: .25rem; flex-wrap: wrap; } +.tab { padding: .3rem .7rem; border: 1px solid transparent; border-radius: 999px; background: none; color: var(--muted-foreground); } +.tab[aria-pressed="true"] { border-color: var(--border); background: var(--surface); color: var(--foreground); } + +/* The Line: one row per issue, stations sized by how long each took */ +.line { display: grid; gap: 1.1rem; } +.line-row { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr) auto; align-items: center; gap: 1.25rem; padding: .9rem 0; border-bottom: 1px solid var(--border); background: none; border-left: 0; border-right: 0; border-top: 0; text-align: left; width: 100%; } +.line-row:hover .line-title { color: var(--primary); } +.line-title { font-weight: 600; overflow-wrap: anywhere; } +.line-sub { color: var(--muted-foreground); font-size: .75rem; } +.stations { display: flex; align-items: center; gap: 3px; min-width: 0; } +.station { position: relative; height: 1.5rem; min-width: 1.5rem; flex: 1 1 0; background: var(--muted); } +.station[data-done="true"] { background: color-mix(in srgb, var(--foreground) 78%, var(--background)); } +.station[data-failed="true"] { background: var(--danger); } +.station[data-live="true"] { background: var(--primary); } +.station[data-live="true"]::after { content: ""; position: absolute; right: .35rem; top: 50%; width: .5rem; height: .5rem; margin-top: -.25rem; border-radius: 50%; background: var(--primary-foreground); } +.station-names { display: flex; gap: 3px; margin-top: .25rem; color: var(--muted-foreground); font-size: .6875rem; } +.station-names span { flex: 1 1 0; min-width: 1.5rem; overflow: hidden; text-overflow: clip; } +.line-state { text-align: right; white-space: nowrap; font-size: .75rem; color: var(--muted-foreground); } +.line-state.needs-you { color: var(--warning); font-weight: 600; } +.line-state .num { display: block; color: var(--foreground); font-size: .875rem; } + +/* Lists and panels */ +.split { display: grid; grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr); gap: 1.5rem; align-items: start; } +.list { display: grid; border-top: 1px solid var(--border); } +.list-item { display: grid; gap: .15rem; padding: .8rem .25rem; border: 0; border-bottom: 1px solid var(--border); background: none; text-align: left; width: 100%; } +.list-item:hover, .list-item[aria-current="true"] { background: color-mix(in srgb, var(--muted) 60%, transparent); } +.list-item .kind { font-size: .75rem; color: var(--warning); font-weight: 600; } +.panel { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: 0 1px 0 color-mix(in srgb, var(--foreground) 6%, transparent), 0 10px 30px color-mix(in srgb, var(--foreground) 3%, transparent); } +.panel-pad { padding: 1.1rem 1.25rem; } +.thread { display: grid; gap: .75rem; padding: 1.1rem 1.25rem; max-height: 26rem; overflow: auto; } +.msg { max-width: 46rem; padding: .7rem .9rem; border-radius: 8px; background: var(--background); border: 1px solid var(--border); white-space: pre-wrap; overflow-wrap: anywhere; } +.msg.bot { border-left: 2px solid var(--primary); } +.msg header { margin-bottom: .25rem; color: var(--muted-foreground); font-size: .75rem; } +.composer { display: grid; gap: .6rem; padding: 1rem 1.25rem; border-top: 1px solid var(--border); } +.actions { display: flex; gap: .5rem; flex-wrap: wrap; } +.back { display: none; } + +/* Tables */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th { padding: .5rem .75rem .5rem 0; color: var(--muted-foreground); font-size: .75rem; font-weight: 500; text-align: left; border-bottom: 1px solid var(--border); } +td { padding: .7rem .75rem .7rem 0; border-bottom: 1px solid var(--border); vertical-align: top; } +td.num, th.num { text-align: right; padding-right: 0; } +tr[data-href] { cursor: pointer; } +tr[data-href]:hover { background: color-mix(in srgb, var(--muted) 50%, transparent); } +.status { display: inline-flex; align-items: center; gap: .4rem; } +.status::before { content: ""; width: .5rem; height: .5rem; border-radius: 50%; background: var(--muted-foreground); } +.status[data-tone="ok"]::before { background: var(--success); } +.status[data-tone="warn"]::before { background: var(--warning); } +.status[data-tone="bad"]::before { background: var(--danger); } +.status[data-tone="live"]::before { background: var(--primary); } + +/* Analytics */ +.kpis { display: flex; gap: 2.5rem; flex-wrap: wrap; margin-bottom: 2rem; } +.kpi .value { display: block; font-size: 2.25rem; line-height: 2.5rem; font-weight: 600; letter-spacing: -.02em; } +.kpi .label { color: var(--muted-foreground); font-size: .75rem; } +.bars { display: grid; gap: .6rem; margin-bottom: 2rem; } +.bar { display: grid; grid-template-columns: 6rem minmax(0, 1fr) 11rem; align-items: center; gap: .75rem; } +.bar-track { height: .6rem; background: var(--muted); } +.bar-fill { height: 100%; background: var(--primary); } +.bar-note { text-align: right; color: var(--muted-foreground); font-size: .75rem; } + +/* Side sheet for one run */ +.sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: color-mix(in srgb, var(--foreground) 35%, transparent); } +.sheet { position: fixed; z-index: 51; top: 0; right: 0; bottom: 0; width: min(38rem, 100%); overflow: auto; background: var(--surface); border-left: 1px solid var(--border); padding: 1.5rem; animation: slide-in .18s ease-out; } +.sheet h2 { font-size: 1.25rem; } +.sheet-close { float: right; } +.kv { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: .4rem .75rem; margin: 1rem 0 1.5rem; } +.kv dt { color: var(--muted-foreground); } +.kv dd { margin: 0; overflow-wrap: anywhere; } +pre.log { margin: 0; max-height: 22rem; overflow: auto; padding: .75rem; background: var(--background); border: 1px solid var(--border); border-radius: 8px; font: .75rem/1.1rem ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +@keyframes slide-in { from { transform: translateX(1.5rem); opacity: 0; } to { transform: none; opacity: 1; } } + +/* Empty, loading and error states */ +.quiet-state { display: grid; justify-items: start; gap: .5rem; min-height: 11rem; align-content: center; color: var(--muted-foreground); } +.quiet-state strong { color: var(--foreground); font-size: 1rem; } +.quiet-state-mark { display: flex; align-items: flex-end; gap: 3px; height: 1.25rem; color: var(--primary); } +.quiet-state-mark i { display: block; width: 2px; height: .65rem; background: currentColor; } +.quiet-state-mark i:nth-child(2) { height: 1.2rem; } +.quiet-state-mark i:nth-child(3) { height: .9rem; } +.login { max-width: 22rem; margin: 6rem auto; display: grid; gap: .75rem; padding: 0 1rem; } +.error { color: var(--danger); } + +@media (max-width: 1023px) { + .split { grid-template-columns: minmax(0, 1fr); } + .split[data-open="true"] .list-col { display: none; } + .split[data-open="false"] .detail-col { display: none; } + .back { display: inline-flex; align-items: center; margin-bottom: .75rem; } +} + +@media (max-width: 767px) { + .app { display: block; } + .brand-wordmark, .sidebar-foot { display: none; } + body { padding-bottom: calc(4.15rem + env(safe-area-inset-bottom)); } + .app-sidebar { position: static; height: auto; padding: .75rem 1rem; flex-direction: row; box-shadow: inset 0 -1px color-mix(in srgb, var(--foreground) 4%, transparent); } + .app-sidebar nav { position: fixed; z-index: 40; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; overflow: visible; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--sidebar) 94%, transparent); padding: .4rem .3rem max(.4rem, env(safe-area-inset-bottom)); backdrop-filter: blur(14px); } + .app-sidebar nav .nav-item { height: 3.1rem; flex-direction: column; justify-content: center; gap: .2rem; padding: 0 .1rem; font-size: .625rem; text-align: center; } + .app-sidebar nav .nav-item .badge { position: absolute; top: .2rem; right: 22%; margin: 0; } + .app-sidebar nav .nav-item[aria-current="page"]::before { top: -.4rem; left: 50%; width: 1.5rem; height: 2px; transform: translateX(-50%); } + main { padding-top: 1.25rem; } + .line-row { grid-template-columns: minmax(0, 1fr) auto; gap: .5rem 1rem; } + .line-row .stations-col { grid-column: 1 / -1; grid-row: 2; } + .bar { grid-template-columns: 4.5rem minmax(0, 1fr); } + .bar-note { grid-column: 1 / -1; text-align: left; } + .sheet { padding: 1rem; } + .kv { grid-template-columns: 6rem minmax(0, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } } diff --git a/dashboard/server.ts b/dashboard/server.ts index 39b0ca2..331c4b4 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -6,14 +6,20 @@ // works with no local SQLite at all (a CI/VM run with no watcher on this // machine). -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { GitHub } from "../src/github"; import { FactoryState, DEFAULT_DB_PATH } from "../src/state"; import { parseChatOps } from "../src/chatops"; import { buildBoard } from "./board"; import { LABEL } from "../src/labels"; +import { InboxError, act, buildInbox, type InboxAction } from "../src/inbox"; +import { plain } from "../src/display"; +import { workspacesDir } from "../src/paths"; +import { runDir } from "../src/artifacts"; +import { analytics } from "./analytics"; const here = dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.FACTORY_DASHBOARD_PORT ?? 4100); @@ -30,7 +36,32 @@ function isLoopback(address: string | undefined): boolean { return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; } -export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false) { +// Compare digests so the check takes the same time wherever the strings differ. +export function tokenMatches(presented: string, expected: string): boolean { + const a = createHash("sha256").update(presented).digest(); + const b = createHash("sha256").update(expected).digest(); + return timingSafeEqual(a, b); +} + +const SESSION_COOKIE = "factory_session"; + +function cookie(req: Request, name: string): string { + for (const part of (req.headers.get("cookie") ?? "").split(";")) { + const [k, ...v] = part.trim().split("="); + if (k === name) return v.join("="); + } + return ""; +} + +// The only routes reachable without a token. Everything else is default-deny; +// tests/dashboard-routes.test.ts walks every route against this list. +export const PUBLIC_ROUTES: readonly string[] = ["GET /", "GET /assets", "POST /api/session"]; + +const ASSET_TYPES: Record = { css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8", woff2: "font/woff2" }; + +export const ARTIFACT_LIMIT = 1024 * 1024; + +export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, workspaces = workspacesDir()) { const indexHtml = readFileSync(join(here, "public", "index.html"), "utf8"); let boardCache: { at: number; issues: Awaited> } | null = null; @@ -64,34 +95,311 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin }); } + // Every stage attempt for a repo, paged by keyset so a long history is never one giant read. + function allStageRuns(): ReturnType { + const out: ReturnType = []; + for (let after = 0; ; ) { + const page = state.listStageRuns(repo, { after, limit: 500 }); + out.push(...page); + if (page.length < 500) return out; + after = page[page.length - 1]!.id; + } + } + + const needRepo = (): Response | null => (repo ? null : json({ error: "FACTORY_REPO not set" }, { status: 500 })); + + async function inboxItem(number: number) { + return buildInbox(await cachedIssues()).find((i) => i.issue === number); + } + + // Files a stage left in .factory/runs/issue-N/ of the issue's worktree. The + // name must be a plain file name that resolves inside that directory, and it + // is only ever sent as text, never as HTML. + function artifactRoot(issue: number): string | null { + try { + return realpathSync(join(workspaces, `issue-${issue}`, runDir(issue))); + } catch { + return null; + } + } + + function artifacts(issue: number, url: URL): Response { + const root = artifactRoot(issue); + const name = url.searchParams.get("file"); + if (!root) return json({ files: [] }); + if (!name) { + const files = readdirSync(root, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => ({ name: e.name, size: statSync(join(root, e.name)).size })); + return json({ files }); + } + let path: string; + try { + path = realpathSync(join(root, name)); + } catch { + return json({ error: "no such artifact" }, { status: 404 }); + } + if (name.includes("/") || name.includes("\\") || !path.startsWith(root + sep) || !statSync(path).isFile()) { + return json({ error: "no such artifact" }, { status: 404 }); + } + const bytes = readFileSync(path); + const truncated = bytes.length > ARTIFACT_LIMIT; + const body = bytes.subarray(0, ARTIFACT_LIMIT); + const headers: Record = { + "content-type": "text/plain; charset=utf-8", + "x-content-type-options": "nosniff", + "content-security-policy": "sandbox; default-src 'none'", + "x-artifact-truncated": String(truncated), + }; + if (url.searchParams.get("download")) headers["content-disposition"] = `attachment; filename="${name.replace(/[^\w.-]/g, "_")}"`; + return new Response(body, { headers }); + } + + type Handler = (req: Request, url: URL, m: RegExpMatchArray) => Response | Promise; + interface Route { + readonly method: "GET" | "POST"; + readonly pattern: RegExp; + readonly label: string; + readonly handler: Handler; + } + + // The routing table is the only place a route exists, so the route-walk test + // (tests/dashboard-routes.test.ts) sees every one of them. + const routes: readonly Route[] = [ + { method: "GET", pattern: /^\/(index\.html)?$/, label: "GET /", handler: () => new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } }) }, + { + // Static files of the page itself: no data, so public like the shell. + method: "GET", + pattern: /^\/(styles\.css|app\.js|lib\/[\w-]+\.js|fonts\/[\w-]+\.woff2)$/, + label: "GET /assets", + handler: (_req, _url, m) => { + try { + const bytes = readFileSync(join(here, "public", m[1]!)); + return new Response(bytes, { headers: { "content-type": ASSET_TYPES[m[1]!.split(".").pop()!]!, "cache-control": "no-cache" } }); + } catch { + return json({ error: "not found" }, { status: 404 }); + } + }, + }, + { + method: "POST", + pattern: /^\/api\/session$/, + label: "POST /api/session", + // Trade the token for an HttpOnly cookie so a browser never puts it in a URL. + handler: async (req) => { + const body = (await req.json().catch(() => ({}))) as { token?: string }; + if (!DASHBOARD_TOKEN || typeof body.token !== "string" || !tokenMatches(body.token, DASHBOARD_TOKEN)) { + return json({ error: "unauthorized" }, { status: 401 }); + } + return json({ ok: true }, { headers: { "set-cookie": `${SESSION_COOKIE}=${DASHBOARD_TOKEN}; HttpOnly; SameSite=Strict; Path=/` } }); + }, + }, + { method: "GET", pattern: /^\/api\/runs$/, label: "GET /api/runs", handler: () => json({ repo, runs: state.listRuns(repo || undefined) }) }, + { + method: "GET", + pattern: /^\/api\/board$/, + label: "GET /api/board", + handler: async () => needRepo() ?? json({ repo, cards: buildBoard(await cachedIssues()) }), + }, + { + method: "POST", + pattern: /^\/api\/mark-ready$/, + label: "POST /api/mark-ready", + handler: async (req) => { + const body = (await req.json()) as { issue?: number }; + const missing = needRepo(); + if (missing) return missing; + if (!Number.isInteger(body.issue)) return json({ error: "issue must be an integer" }, { status: 400 }); + await github.addLabels(repo, body.issue!, [LABEL.ready]); + boardCache = null; // next /api/board reflects the label immediately + return json({ ok: true }); + }, + }, + { + method: "GET", + pattern: /^\/api\/issues\/(\d+)\/thread$/, + label: "GET /api/issues/:n/thread", + handler: async (_req, _url, m) => needRepo() ?? json({ issue: await github.getIssue(repo, Number(m[1])) }), + }, + { + method: "GET", + pattern: /^\/api\/issues\/(\d+)\/artifacts$/, + label: "GET /api/issues/:n/artifacts", + handler: (_req, url, m) => artifacts(Number(m[1]), url), + }, + { + method: "GET", + pattern: /^\/api\/runs\/(\d+)\/events$/, + label: "GET /api/runs/:id/events", + handler: (_req, url, m) => { + const events = state.listEvents(Number(m[1]), { after: Number(url.searchParams.get("after") ?? "0") }); + return json({ events: events.map((e) => ({ ...e, text: plain(e.text) })) }); + }, + }, + { + method: "GET", + pattern: /^\/api\/runs\/(\d+)\/stages$/, + label: "GET /api/runs/:id/stages", + handler: (_req, _url, m) => { + const run = state.listRuns(repo || undefined).find((r) => r.id === Number(m[1])); + if (!run) return json({ error: "no such run" }, { status: 404 }); + return json({ run, stages: state.listStageRuns(run.repo, { issue: run.issue }) }); + }, + }, + { + method: "GET", + pattern: /^\/api\/line$/, + label: "GET /api/line", + // Each recent run with the stages it went through, for the Line view. + handler: () => { + const attempts = repo ? allStageRuns() : []; + const runs = state.listRuns(repo || undefined).slice(0, 100); + return json({ + repo, + rows: runs.map((run) => ({ + run, + stages: attempts + .filter((a) => a.issue === run.issue) + .map((a) => ({ stage: a.stage, agent: a.agent, duration_ms: a.duration_ms, cost_usd: a.cost_usd, ok: a.exit_code === 0 && !a.killed_reason })), + })), + }); + }, + }, + { + method: "GET", + pattern: /^\/api\/analytics$/, + label: "GET /api/analytics", + handler: () => json(analytics(state.listRuns(repo || undefined), repo ? allStageRuns() : [])), + }, + { + method: "GET", + pattern: /^\/api\/inbox$/, + label: "GET /api/inbox", + handler: async () => needRepo() ?? json({ repo, items: buildInbox(await cachedIssues()) }), + }, + { + method: "POST", + pattern: /^\/api\/inbox\/(\d+)\/act$/, + label: "POST /api/inbox/:n/act", + handler: async (req, _url, m) => { + const missing = needRepo(); + if (missing) return missing; + const body = (await req.json()) as { action?: InboxAction; text?: string }; + const item = await inboxItem(Number(m[1])); + if (!item) return json({ error: "nothing is waiting on that issue" }, { status: 404 }); + try { + const posted = await act(github, repo, item, body.action as InboxAction, body.text ?? ""); + boardCache = null; + return json({ ok: true, posted }); + } catch (err) { + if (err instanceof InboxError) return json({ error: err.message }, { status: 400 }); + throw err; + } + }, + }, + { + method: "GET", + pattern: /^\/api\/toggles$/, + label: "GET /api/toggles", + handler: () => + json({ + auto_start: state.getToggle("auto_start", true), + auto_approve_low_risk: state.getToggle("auto_approve_low_risk", autoApproveDefault), + }), + }, + { + method: "POST", + pattern: /^\/api\/toggles$/, + label: "POST /api/toggles", + handler: async (req) => { + const body = (await req.json()) as { key: string; value: boolean }; + if (body.key !== "auto_start" && body.key !== "auto_approve_low_risk") return json({ error: "unknown toggle" }, { status: 400 }); + state.setToggle(body.key, Boolean(body.value)); + return json({ ok: true }); + }, + }, + // Reply box and the action buttons post the same text a human would type in + // the issue thread, so untrusted-input handling stays in one place (watch.ts). + { + method: "POST", + pattern: /^\/api\/reply$/, + label: "POST /api/reply", + handler: async (req) => { + const body = (await req.json()) as { issue: number; text: string }; + const missing = needRepo(); + if (missing) return missing; + await github.commentIssue(repo, body.issue, body.text); + return json({ ok: true }); + }, + }, + { + method: "POST", + pattern: /^\/api\/command$/, + label: "POST /api/command", + handler: async (req) => { + const body = (await req.json()) as { issue: number; command: "approve" | "revise" | "retry" | "cancel"; text?: string }; + const missing = needRepo(); + if (missing) return missing; + const text = body.command === "revise" ? `/factory revise ${body.text ?? ""}` : `/factory ${body.command}`; + // Round-trip through parseChatOps so a malformed command from the UI + // fails the same way an equivalent typed comment would. + if (parseChatOps(text).type === "answer") return json({ error: "not a recognized /factory command" }, { status: 400 }); + await github.commentIssue(repo, body.issue, text); + return json({ ok: true }); + }, + }, + { + method: "GET", + pattern: /^\/api\/stream$/, + label: "GET /api/stream", + handler: (req) => { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const send = () => { + const payload = JSON.stringify({ repo, runs: state.listRuns(repo || undefined) }); + controller.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + send(); + const timer = setInterval(send, 2000); + req.signal.addEventListener("abort", () => { + clearInterval(timer); + controller.close(); + }); + }, + }); + return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" } }); + }, + }, + ]; + + function authorized(req: Request): boolean { + const header = req.headers.get("authorization") ?? ""; + const presented = header.startsWith("Bearer ") ? header.slice(7) : cookie(req, SESSION_COOKIE); + return tokenMatches(presented, DASHBOARD_TOKEN); + } + // `remoteAddress` comes from `server.requestIP(req)` at the real Bun.serve - // call site; undefined (unknown) is treated as NOT loopback — fail closed, - // not open, the same rule as guard-paths.sh (audit finding #13's sibling). + // call site; undefined (unknown) is treated as NOT loopback: fail closed, + // the same rule as guard-paths.sh. async function handle(req: Request, remoteAddress?: string): Promise { const url = new URL(req.url); const loopback = isLoopback(remoteAddress); + const route = routes.find((r) => r.method === req.method && r.pattern.test(url.pathname)); + const isPublic = route !== undefined && PUBLIC_ROUTES.includes(route.label); if (!loopback && !DASHBOARD_TOKEN) { - return json( - { error: "refused: reachable from a non-loopback address with no FACTORY_DASHBOARD_TOKEN set" }, - { status: 503 }, - ); - } - if (DASHBOARD_TOKEN) { - const authHeader = req.headers.get("authorization") ?? ""; - const presented = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : (url.searchParams.get("token") ?? ""); - if (presented !== DASHBOARD_TOKEN) { - return json({ error: "unauthorized" }, { status: 401 }); - } + return json({ error: "refused: reachable from a non-loopback address with no FACTORY_DASHBOARD_TOKEN set" }, { status: 503 }); } + // Token, when set, guards every non-public route for every caller, so an + // unknown path is refused before it can 404 (no route probing). + if (DASHBOARD_TOKEN && !isPublic && !authorized(req)) return json({ error: "unauthorized" }, { status: 401 }); // CSRF: a mutating request must be same-origin JSON, never the // `text/plain` a plain HTML form can send cross-site without a preflight. if (req.method === "POST") { const contentType = (req.headers.get("content-type") ?? "").toLowerCase(); - if (!contentType.startsWith("application/json")) { - return json({ error: "expected application/json" }, { status: 415 }); - } + if (!contentType.startsWith("application/json")) return json({ error: "expected application/json" }, { status: 415 }); const origin = req.headers.get("origin"); if (origin) { let originHost: string; @@ -100,116 +408,17 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin } catch { return json({ error: "invalid Origin header" }, { status: 403 }); } - if (originHost !== url.host) { - return json({ error: "cross-origin request rejected" }, { status: 403 }); - } - } - } - - if (url.pathname === "/" || url.pathname === "/index.html") { - return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } }); - } - - if (url.pathname === "/api/runs" && req.method === "GET") { - return json({ repo, runs: state.listRuns(repo || undefined) }); - } - - if (url.pathname === "/api/board" && req.method === "GET") { - if (!repo) return json({ error: "FACTORY_REPO not set" }, { status: 500 }); - const issues = await cachedIssues(); - return json({ repo, cards: buildBoard(issues) }); - } - - if (url.pathname === "/api/mark-ready" && req.method === "POST") { - const body = (await req.json()) as { issue?: number }; - if (!repo) return json({ error: "FACTORY_REPO not set" }, { status: 500 }); - if (!Number.isInteger(body.issue)) return json({ error: "issue must be an integer" }, { status: 400 }); - await github.addLabels(repo, body.issue!, [LABEL.ready]); - boardCache = null; // next /api/board reflects the label immediately - return json({ ok: true }); - } - - if (url.pathname.match(/^\/api\/issues\/\d+\/thread$/) && req.method === "GET") { - if (!repo) return json({ error: "FACTORY_REPO not set" }, { status: 500 }); - const number = Number(url.pathname.split("/")[3]); - const issue = await github.getIssue(repo, number); - return json({ issue }); - } - - if (url.pathname.match(/^\/api\/runs\/\d+\/events$/) && req.method === "GET") { - const runId = Number(url.pathname.split("/")[3]); - const after = Number(url.searchParams.get("after") ?? "0"); - return json({ events: state.listEvents(runId, { after }) }); - } - - if (url.pathname === "/api/toggles" && req.method === "GET") { - return json({ - auto_start: state.getToggle("auto_start", true), - auto_approve_low_risk: state.getToggle("auto_approve_low_risk", autoApproveDefault), - }); - } - - if (url.pathname === "/api/toggles" && req.method === "POST") { - const body = (await req.json()) as { key: string; value: boolean }; - if (body.key !== "auto_start" && body.key !== "auto_approve_low_risk") { - return json({ error: "unknown toggle" }, { status: 400 }); + if (originHost !== url.host) return json({ error: "cross-origin request rejected" }, { status: 403 }); } - state.setToggle(body.key, Boolean(body.value)); - return json({ ok: true }); - } - - // Reply box and Approve/Revise/Retry/Cancel buttons post the same text a - // human would type in the issue thread — the dashboard has no separate - // command path, so untrusted-input handling stays in one place (watch.ts). - if (url.pathname === "/api/reply" && req.method === "POST") { - const body = (await req.json()) as { issue: number; text: string }; - if (!repo) return json({ error: "FACTORY_REPO not set" }, { status: 500 }); - await github.commentIssue(repo, body.issue, body.text); - return json({ ok: true }); - } - - if (url.pathname === "/api/command" && req.method === "POST") { - const body = (await req.json()) as { issue: number; command: "approve" | "revise" | "retry" | "cancel"; text?: string }; - if (!repo) return json({ error: "FACTORY_REPO not set" }, { status: 500 }); - const text = - body.command === "revise" ? `/factory revise ${body.text ?? ""}` : `/factory ${body.command}`; - // Round-trip through parseChatOps so a malformed command from the UI - // fails the same way an equivalent typed comment would. - const parsed = parseChatOps(text); - if (parsed.type === "answer") return json({ error: "not a recognized /factory command" }, { status: 400 }); - await github.commentIssue(repo, body.issue, text); - return json({ ok: true }); - } - - if (url.pathname === "/api/stream" && req.method === "GET") { - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - const send = () => { - const payload = JSON.stringify({ repo, runs: state.listRuns(repo || undefined) }); - controller.enqueue(encoder.encode(`data: ${payload}\n\n`)); - }; - send(); - const timer = setInterval(send, 2000); - req.signal.addEventListener("abort", () => { - clearInterval(timer); - controller.close(); - }); - }, - }); - return new Response(stream, { - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }, - }); } - return json({ error: "not found" }, { status: 404 }); + if (!route) return json({ error: "not found" }, { status: 404 }); + return route.handler(req, url, url.pathname.match(route.pattern)!); } - return { handle }; + const routeLabels = routes.map((r) => r.label); + + return { handle, routeLabels }; } if (import.meta.main) { diff --git a/package.json b/package.json index f60c6c5..984ff26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "software-factory", - "version": "2.3.0", + "version": "2.4.0", "private": true, "type": "module", "description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR.", diff --git a/src/display.ts b/src/display.ts new file mode 100644 index 0000000..334cbeb --- /dev/null +++ b/src/display.ts @@ -0,0 +1,7 @@ +// Ported from owainlewis/assembler@7cac671 src/display.ts:5 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: none; the log-update progress renderer in the same file is not ported. + +import { stripVTControlCharacters } from "node:util"; + +// Anything shown in a terminal or the dashboard that came from an agent or an +// issue thread is stripped of escape sequences and control characters first. +export const plain = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); diff --git a/src/help.ts b/src/help.ts index c425334..7ebb35a 100644 --- a/src/help.ts +++ b/src/help.ts @@ -7,7 +7,9 @@ export const COMMANDS: { name: string; usage: string; does: string }[] = [ { name: "run", usage: "run (--repo-dir | --repo ) --issue ", does: "advance one issue once, then exit (CI)" }, { name: "tick", usage: "tick (--repo-dir | --repo )", does: "one poll pass over every open issue, then exit (cron)" }, { name: "park", usage: "park --repo-dir --issue [--reason ]", does: "park an issue as needs-human from outside the loop" }, - { name: "dashboard", usage: "dashboard [--repo ] [--port ]", does: "serve the board (default :4100, loopback)" }, + { name: "dashboard", usage: "dashboard [--repo | --repo-dir ] [--port ]", does: "serve the board (default :4100, loopback)" }, + { name: "logs", usage: "logs [--repo ] [--stage ] [--follow] [--json]", does: "print (or follow) a run's events; --json is one object per line" }, + { name: "inbox", usage: "inbox [ [--text ]] --repo [--json]", does: "list what waits for a human; with , post the same /factory comment a human would" }, { name: "scan", usage: "scan --repo-dir ", does: "file issues from `bun audit` (Bun/npm projects only)" }, { name: "reset", usage: "reset --repo-dir [--dry-run]", does: "DESTRUCTIVE: force base back to the baseline tag (lists dropped commits), close PRs and issues" }, { name: "rebaseline", usage: "rebaseline --repo-dir [--dry-run]", does: "move the baseline tag to origin/, keeping merged setup changes across reset" }, diff --git a/src/inbox.ts b/src/inbox.ts new file mode 100644 index 0000000..71aff94 --- /dev/null +++ b/src/inbox.ts @@ -0,0 +1,94 @@ +// The one answer to "what is waiting for me?". Derived from labels and the +// thread (GitHub stays the only state); acting posts the same comment a human +// would type, so the CLI, the dashboard and a later chat channel all go +// through chatops.ts and its trust rule instead of a second command path. + +import { parseChatOps } from "./chatops"; +import { plain } from "./display"; +import type { GhComment, GhIssue, GitHub } from "./github"; +import { LABEL, PARKED_LABELS } from "./labels"; + +export type InboxKind = "approve-plan" | "answer-question" | "review-pr" | "parked" | "failed"; +export type InboxAction = "approve" | "revise" | "answer" | "retry" | "cancel"; + +// Actions that carry the human's own words. +export const ACTIONS_WITH_TEXT: readonly InboxAction[] = ["revise", "answer"]; + +export interface InboxItem { + readonly id: string; + readonly kind: InboxKind; + readonly issue: number; + readonly title: string; + readonly label: string; + readonly waitingSince: string | undefined; + readonly ask: string; + readonly actions: readonly InboxAction[]; +} + +// Every label that means "a human is needed", and what they can do about it. +// tests/inbox.test.ts walks LABELS so a new waiting state cannot skip this table. +export const WAITING: Readonly> = { + [LABEL.awaitingApproval]: { kind: "approve-plan", actions: ["approve", "revise", "cancel"] }, + [LABEL.needsInfo]: { kind: "answer-question", actions: ["answer", "cancel"] }, + [LABEL.inReview]: { kind: "review-pr", actions: ["revise", "cancel"] }, + [LABEL.needsHuman]: { kind: "parked", actions: ["retry", "cancel"] }, + [LABEL.failed]: { kind: "failed", actions: ["retry", "cancel"] }, +}; + +export const WAITING_LABELS: readonly string[] = [LABEL.awaitingApproval, LABEL.inReview, ...PARKED_LABELS]; + +const ASK_LIMIT = 1200; + +function stripMarkers(body: string): string { + return body.replace(//g, "").trim(); +} + +// The runner's latest comment is what the human is being asked about. +function latestRunnerComment(comments: readonly GhComment[]): GhComment | undefined { + return [...comments].reverse().find((c) => c.body.includes("\nThe plan\u001b[31m", createdAt: "2026-09-20T10:00:00Z" }], +}); + +const TOKEN = "s3cret-token"; +const scratch = mkdtempSync(join(tmpdir(), "factory-routes-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +async function make(token: string, issues: GhIssue[] = []) { + const before = process.env.FACTORY_DASHBOARD_TOKEN; + process.env.FACTORY_DASHBOARD_TOKEN = token; + try { + const mod = await import(`../dashboard/server.ts?token=${token || "none"}`); + const state = new FactoryState(":memory:"); + const github = new FakeGitHub(issues); + return { mod, state, github, dashboard: mod.createDashboard(state, github, "acme/widgets", false, scratch) }; + } finally { + if (before === undefined) delete process.env.FACTORY_DASHBOARD_TOKEN; + else process.env.FACTORY_DASHBOARD_TOKEN = before; + } +} + +const samplePath = (label: string) => label.split(" ")[1]!.replace(":n", "1").replace(":id", "1"); +const call = (label: string, headers: Record = {}) => { + const [method, ] = label.split(" "); + return new Request(`http://localhost:4100${samplePath(label)}`, { + method, + headers: { ...(method === "POST" ? { "content-type": "application/json" } : {}), ...headers }, + body: method === "POST" ? "{}" : undefined, + }); +}; + +describe("route walk", () => { + test("with a token set, every route except the public allow-list is 401 without credentials", async () => { + const { mod, dashboard } = await make(TOKEN); + expect(dashboard.routeLabels.length).toBeGreaterThan(10); + for (const p of mod.PUBLIC_ROUTES) expect(dashboard.routeLabels, p).toContain(p); + for (const label of dashboard.routeLabels as string[]) { + if (mod.PUBLIC_ROUTES.includes(label)) continue; + for (const ip of ["127.0.0.1", "203.0.113.7"]) { + const res = await dashboard.handle(call(label), ip); + expect(res.status, `${label} from ${ip}`).toBe(401); + } + } + // An unknown path is refused before it can 404, so routes cannot be probed. + expect((await dashboard.handle(new Request("http://localhost:4100/api/nope"), "203.0.113.7")).status).toBe(401); + }); + + test("the token in a query string no longer works; the header and the session cookie do", async () => { + const { dashboard } = await make(TOKEN); + const at = (path: string, headers: Record = {}) => dashboard.handle(new Request(`http://localhost:4100${path}`, { headers }), "203.0.113.7"); + expect((await at(`/api/runs?token=${TOKEN}`)).status).toBe(401); + expect((await at("/api/runs", { authorization: `Bearer ${TOKEN}` })).status).toBe(200); + expect((await at("/api/runs", { authorization: "Bearer wrong-token-value" })).status).toBe(401); + + const bad = await dashboard.handle(call("POST /api/session"), "203.0.113.7"); + expect(bad.status).toBe(401); + const login = await dashboard.handle( + new Request("http://localhost:4100/api/session", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: TOKEN }) }), + "203.0.113.7", + ); + expect(login.status).toBe(200); + const setCookie = login.headers.get("set-cookie")!; + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Strict"); + expect((await at("/api/runs", { cookie: setCookie.split(";")[0]! })).status).toBe(200); + }); + + test("no token set: a non-loopback caller is refused outright, even on the public routes", async () => { + const { dashboard } = await make(""); + expect((await dashboard.handle(call("GET /"), "203.0.113.7")).status).toBe(503); + }); +}); + +describe("inbox routes", () => { + test("lists what waits and posts the same comment a human would", async () => { + const { dashboard, github } = await make("", [waiting(3, LABEL.awaitingApproval), waiting(4, LABEL.building)]); + const list = (await (await dashboard.handle(call("GET /api/inbox"), "127.0.0.1")).json()) as { items: { issue: number; ask: string; actions: string[] }[] }; + expect(list.items.map((i) => i.issue)).toEqual([3]); + expect(list.items[0]!.ask).toBe("The plan"); + + const act = (n: number, body: unknown) => + dashboard.handle(new Request(`http://localhost:4100/api/inbox/${n}/act`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), "127.0.0.1"); + expect((await act(3, { action: "approve" })).status).toBe(200); + expect(github.posted).toEqual([{ issue: 3, body: "/factory approve" }]); + expect((await act(3, { action: "retry" })).status).toBe(400); + expect((await act(3, { action: "revise", text: " " })).status).toBe(400); + expect((await act(4, { action: "cancel" })).status).toBe(404); + expect(github.posted).toHaveLength(1); + }); +}); + +describe("analytics and stages", () => { + const stage = (i: number, agent: string): StageRunInput => ({ + repo: "acme/widgets", issue: 1 + (i % 3), stage: "build", agent, model: null, started_at: "2026-09-24T00:00:00Z", + finished_at: new Date().toISOString(), duration_ms: 1000, tool_calls: 1, tokens_in: 10, tokens_out: 5, cost_usd: 0.01, + exit_code: i % 10 === 0 ? 1 : 0, killed_reason: null, + }); + + test("sums more than one page (1100 attempts) and reports per agent", async () => { + const { dashboard, state } = await make(""); + for (let i = 0; i < 1100; i++) state.recordStageRun(stage(i, i % 2 ? "claude" : "codex")); + const a = (await (await dashboard.handle(call("GET /api/analytics"), "127.0.0.1")).json()) as { + spend7dUsd: number; byStage: { attempts: number }[]; byAgent: { key: string; attempts: number; failures: number }[]; + }; + expect(a.byStage[0]!.attempts).toBe(1100); + expect(a.byAgent.map((b) => [b.key, b.attempts])).toEqual([["claude", 550], ["codex", 550]]); + expect(a.spend7dUsd).toBeCloseTo(11, 5); + expect(a.byAgent.reduce((n, b) => n + b.failures, 0)).toBe(110); + }); + + test("run stages come back for a known run and 404 for an unknown one", async () => { + const { dashboard, state } = await make(""); + state.upsertRun({ issue: 1, repo: "acme/widgets", title: "t", stage: "build", status: "running" }); + state.recordStageRun(stage(3, "claude")); + const run = state.listRuns("acme/widgets")[0]!; + const ok = (await (await dashboard.handle(new Request(`http://localhost:4100/api/runs/${run.id}/stages`), "127.0.0.1")).json()) as { stages?: unknown[] }; + expect(ok.stages).toHaveLength(1); + expect((await dashboard.handle(new Request("http://localhost:4100/api/runs/999/stages"), "127.0.0.1")).status).toBe(404); + }); +}); + +describe("artifacts", () => { + const dir = join(scratch, "issue-9", ".factory", "runs", "issue-9"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "plan.md"), ""); + writeFileSync(join(dir, "big.log"), "x".repeat(1024 * 1024 + 10)); + writeFileSync(join(scratch, "secret.txt"), "outside"); + symlinkSync(join(scratch, "secret.txt"), join(dir, "link.txt")); + const get = async (q: string) => { + const { dashboard } = await make(""); + return dashboard.handle(new Request(`http://localhost:4100/api/issues/9/artifacts${q}`), "127.0.0.1"); + }; + + test("lists regular files, sends text only with sandbox headers, caps at 1 MiB, downloads as attachment", async () => { + const list = (await (await get("")).json()) as { files: { name: string }[] }; + expect(list.files.map((f) => f.name).sort()).toEqual(["big.log", "plan.md"]) // the symlink is not listed; + const res = await get("?file=plan.md"); + expect(res.headers.get("content-type")).toContain("text/plain"); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("content-security-policy")).toContain("sandbox"); + expect(await res.text()).toBe(""); + const big = await get("?file=big.log"); + expect(big.headers.get("x-artifact-truncated")).toBe("true"); + expect((await big.arrayBuffer()).byteLength).toBe(1024 * 1024); + expect((await get("?file=plan.md&download=1")).headers.get("content-disposition")).toContain("attachment"); + }); + + test("refuses traversal and a symlink that leaves the run directory", async () => { + for (const f of ["../../../../secret.txt", "..%2Fsecret.txt", "link.txt", "missing.md"]) { + expect((await get(`?file=${f}`)).status, f).toBe(404); + } + }); + + test("an issue with no worktree lists nothing", async () => { + const { dashboard } = await make(""); + const res = await dashboard.handle(new Request("http://localhost:4100/api/issues/77/artifacts"), "127.0.0.1"); + expect(await res.json()).toEqual({ files: [] }); + }); +}); + +describe("line and assets", () => { + test("/api/line lists one row per run with its stages", async () => { + const { dashboard, state } = await make(""); + state.upsertRun({ issue: 1, repo: "acme/widgets", title: "t", stage: "build", status: "running" }); + state.recordStageRun({ + repo: "acme/widgets", issue: 1, stage: "triage", agent: "claude", model: null, started_at: "2026-09-24T00:00:00Z", + finished_at: "2026-09-24T00:00:05Z", duration_ms: 5000, tool_calls: 1, tokens_in: 1, tokens_out: 1, cost_usd: 0.01, exit_code: 0, killed_reason: null, + }); + const body = (await (await dashboard.handle(call("GET /api/line"), "127.0.0.1")).json()) as { rows: { stages: { stage: string; ok: boolean }[] }[] }; + expect(body.rows).toHaveLength(1); + expect(body.rows[0]!.stages).toMatchObject([{ stage: "triage", ok: true }]); + }); + + test("assets are public, typed, and never escape dashboard/public", async () => { + const { dashboard } = await make(TOKEN); + const css = await dashboard.handle(new Request("http://localhost:4100/styles.css"), "10.0.0.9"); + expect(css.status).toBe(200); + expect(css.headers.get("content-type")).toContain("text/css"); + const font = await dashboard.handle(new Request("http://localhost:4100/fonts/manrope-latin.woff2"), "10.0.0.9"); + expect(font.headers.get("content-type")).toContain("font/woff2"); + expect((await dashboard.handle(new Request("http://localhost:4100/lib/..%2Fserver.js"), "10.0.0.9")).status).toBe(401); + }); +}); diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts new file mode 100644 index 0000000..79ac0b7 --- /dev/null +++ b/tests/inbox.test.ts @@ -0,0 +1,92 @@ +// Structural: every label that needs a human maps to exactly one inbox kind with +// at least one action, and no in-flight label does. A new parked or waiting +// state that is not added to WAITING fails here instead of going unseen. + +import { describe, expect, test } from "bun:test"; +import { parseChatOps } from "../src/chatops"; +import type { GhIssue } from "../src/github"; +import { InboxError, WAITING, WAITING_LABELS, act, buildInbox, commandText } from "../src/inbox"; +import { LABELS, LABEL, PARKED_LABELS, STATE_LABELS } from "../src/labels"; + +const at = (n: number) => `2026-09-2${n}T10:00:00Z`; +function issue(number: number, label: string, body = "\nThe plan", when = at(1)): GhIssue { + return { + number, + title: `Issue ${number}`, + body: "", + labels: [{ name: label }, { name: "bug" }], + comments: [{ id: number, author: "op", authorAssociation: "OWNER", body, createdAt: when }], + }; +} + +describe("inbox structure", () => { + test("WAITING covers exactly the parked labels plus awaiting-approval and in-review", () => { + expect(Object.keys(WAITING).sort()).toEqual([...WAITING_LABELS].sort()); + for (const p of PARKED_LABELS) expect(WAITING[p]).toBeDefined(); + }); + + test("every factory state or parked label yields one item with actions, or none for in-flight labels", () => { + const inFlight = new Set([LABEL.ready, LABEL.triaging, LABEL.planning, LABEL.building, LABEL.verifying]); + for (const l of LABELS.filter((x) => x.category === "state" || x.category === "parked")) { + const items = buildInbox([issue(1, l.name)]); + if (inFlight.has(l.name)) expect(items, l.name).toHaveLength(0); + else { + expect(items, l.name).toHaveLength(1); + expect(items[0]!.actions.length, l.name).toBeGreaterThan(0); + } + } + expect(STATE_LABELS.filter((s) => !inFlight.has(s) && !WAITING[s])).toEqual([]); + }); + + test("every action is a command the trust parser recognises", () => { + for (const { actions } of Object.values(WAITING)) { + for (const a of actions) { + const parsed = parseChatOps(commandText(a, "some words")); + expect(parsed.type).toBe(a); + } + } + }); +}); + +describe("buildInbox", () => { + test("orders the longest-waiting item first, strips markers and control codes, and caps the ask", () => { + const items = buildInbox([ + issue(2, LABEL.failed, "\n\x1b[31mBuild broke\x1b[0m", at(3)), + issue(1, LABEL.awaitingApproval, "\n" + "x".repeat(5000), at(2)), + ]); + expect(items.map((i) => i.issue)).toEqual([1, 2]); + expect(items[0]!.ask.length).toBe(1200); + expect(items[1]!.ask).toBe("Build broke"); + expect(items[1]!.kind).toBe("failed"); + }); + + test("pages past one screen: 250 waiting issues all appear", () => { + const many = Array.from({ length: 250 }, (_, i) => issue(i + 1, LABEL.needsHuman)); + expect(buildInbox(many)).toHaveLength(250); + }); +}); + +describe("act", () => { + const item = { issue: 7, kind: "approve-plan" as const, actions: WAITING[LABEL.awaitingApproval]!.actions }; + test("posts the same comment a human would type", async () => { + const posted: string[] = []; + const github = { commentIssue: async (_r: string, _n: number, body: string) => (posted.push(body), 1) }; + expect(await act(github, "o/r", item, "approve")).toBe("/factory approve"); + expect(await act(github, "o/r", item, "revise", " cover zero ")).toBe("/factory revise cover zero"); + expect(posted).toEqual(["/factory approve", "/factory revise cover zero"]); + }); + test("refuses an action the item does not offer, and text-less revise or answer", async () => { + const github = { commentIssue: async () => 1 }; + await expect(act(github, "o/r", item, "retry")).rejects.toThrow(InboxError); + await expect(act(github, "o/r", item, "revise", " ")).rejects.toThrow(/needs text/); + const q = { issue: 8, kind: "answer-question" as const, actions: WAITING[LABEL.needsInfo]!.actions }; + await expect(act(github, "o/r", q, "answer", "")).rejects.toThrow(/needs text/); + }); + test("an answer is posted as plain text, not a command", async () => { + const posted: string[] = []; + const q = { issue: 8, kind: "answer-question" as const, actions: WAITING[LABEL.needsInfo]!.actions }; + await act({ commentIssue: async (_r, _n, b) => (posted.push(b), 1) }, "o/r", q, "answer", "USD only"); + expect(posted).toEqual(["USD only"]); + expect(parseChatOps("USD only").type).toBe("answer"); + }); +}); diff --git a/tests/ported/assembler/display.test.ts b/tests/ported/assembler/display.test.ts new file mode 100644 index 0000000..91541dd --- /dev/null +++ b/tests/ported/assembler/display.test.ts @@ -0,0 +1,14 @@ +// Ported from owainlewis/assembler@7cac671 test/outputs.test.ts:112 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: only the escape-code assertion; the formatOutputs/formatRow parts are not ported. + +import { expect, test } from "bun:test"; +import { plain } from "../../../src/display"; + +test("terminal escape codes are removed", () => { + const value = "\x1b[2J" + "line\n".repeat(100); + expect(plain(value).includes("\x1b")).toBe(false); +}); + +test("other control characters are stripped and newlines and tabs survive", () => { + expect(plain("a\x00b\x07c\x7fd\te\nf")).toBe("abcd\te\nf"); + expect(plain("\x1b]0;title\x07hello\x1b[31m red")).toBe("hello red"); +}); diff --git a/tests/ported/assembler/logs.test.ts b/tests/ported/assembler/logs.test.ts new file mode 100644 index 0000000..a14ef9d --- /dev/null +++ b/tests/ported/assembler/logs.test.ts @@ -0,0 +1,46 @@ +// Ported from owainlewis/assembler@7cac671 test/runs.test.ts:81,126 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: events come from the state DB, so the UTF-8 split and path-escape cases are replaced by escape-code stripping and keyset paging; "step" is "stage". + +import { expect, test } from "bun:test"; +import { streamLogs } from "../../../src/logs"; +import { FactoryState } from "../../../src/state"; + +function seeded(events: number) { + const state = new FactoryState(":memory:"); + const run = state.upsertRun({ repo: "o/r", issue: 7, title: "t", stage: "build", status: "running" }); + for (let i = 0; i < events; i += 1) state.appendEvent(run.id, i < events / 2 ? "plan" : "build", "text", `line ${i}`); + return { state, run }; +} + +test("step logs are filtered to the named stage and an unknown stage is an error", async () => { + const { state, run } = seeded(4); + state.updateRun("o/r", 7, { status: "shipped" }); + let text = ""; + await streamLogs(state, "o/r", 7, { stage: "build" }, (v) => (text += v)); + expect(text).toContain("line 3"); + expect(text).not.toContain("line 0"); + await expect(streamLogs(state, "o/r", 7, { stage: "absent" })).rejects.toThrow(/No stage/); + expect(run.id).toBeGreaterThan(0); +}); + +test("--json prints one object per line, escape codes stripped, and pages past the 200 event limit", async () => { + const { state, run } = seeded(450); + state.appendEvent(run.id, "build", "text", "\x1b[2Jclear"); + state.updateRun("o/r", 7, { status: "shipped" }); + const lines: string[] = []; + await streamLogs(state, "o/r", 7, { json: true }, (v) => lines.push(v)); + expect(lines).toHaveLength(451); + const last = JSON.parse(lines.at(-1)!); + expect(last).toEqual({ issue: 7, stage: "build", kind: "text", text: "clear" }); +}); + +test("follow returns once the run is no longer active", async () => { + const { state } = seeded(2); + const seen: string[] = []; + const done = streamLogs(state, "o/r", 7, { follow: true, pollMs: 5 }, (v) => seen.push(v)); + await Bun.sleep(30); + state.appendEvent(state.getRun("o/r", 7)!.id, "build", "text", "late line"); + await Bun.sleep(30); + state.updateRun("o/r", 7, { status: "shipped" }); + await done; + expect(seen.join("")).toContain("late line"); +}); diff --git a/tests/ported/machinist/revision.test.ts b/tests/ported/machinist/revision.test.ts new file mode 100644 index 0000000..8a19308 --- /dev/null +++ b/tests/ported/machinist/revision.test.ts @@ -0,0 +1,18 @@ +// Ported from owainlewis/machinist@3943516 internal/runner/revision_test.go:12-19 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: the script-executor case (TestScriptRevisionPreservesJSONInput) is not ported; the factory has no script executor. + +import { expect, test } from "bun:test"; +import { revisionPrompt } from "../../../src/revision"; + +test("revision prompt keeps feedback literal and includes saved files", () => { + const got = revisionPrompt( + { previousRun: "run_old", feedback: "Keep {{task.spec}} literal", previousSummary: "Original plan", priorFeedback: ["Keep compatibility"], artifacts: { old: "plan.md" } }, + { old: "/private/inputs/old" }, + ); + for (const want of ["run_old", "Keep {{task.spec}} literal", "Keep compatibility", "plan.md", "/private/inputs/old"]) { + expect(got).toContain(want); + } +}); + +test("no revision produces no prompt", () => { + expect(revisionPrompt(undefined, {})).toBe(""); +}); diff --git a/tests/ported/machinist/routes.test.ts b/tests/ported/machinist/routes.test.ts new file mode 100644 index 0000000..3f3f905 --- /dev/null +++ b/tests/ported/machinist/routes.test.ts @@ -0,0 +1,16 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/routes.test.js (MIT, Copyright (c) 2026 Owain Lewis). Deviations: bun:test; expected views are the factory's (fallback is inbox). +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { routeFromHash } from "../../../dashboard/public/lib/routes.js"; + +test("routeFromHash recognizes task detail routes", () => { + assert.deepEqual(routeFromHash("#/runs/job_123"), { view: "task", jobID: "job_123" }); + assert.deepEqual(routeFromHash("#/runs/job%2F123"), { view: "task", jobID: "job/123" }); +}); + +test("routeFromHash falls back to runs for incomplete or malformed routes", () => { + assert.deepEqual(routeFromHash("#/runs/"), { view: "runs", jobID: "" }); + assert.deepEqual(routeFromHash("#/runs/%E0%A4%A"), { view: "runs", jobID: "" }); + assert.deepEqual(routeFromHash("#/unknown"), { view: "inbox", jobID: "" }); +}); diff --git a/tests/ported/machinist/run-metrics.test.ts b/tests/ported/machinist/run-metrics.test.ts new file mode 100644 index 0000000..e45ffba --- /dev/null +++ b/tests/ported/machinist/run-metrics.test.ts @@ -0,0 +1,142 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/run-metrics.test.js (MIT, Copyright (c) 2026 Owain Lewis). Deviations: bun:test instead of node:test; the final test that greps analytics.jsx is dropped (no JSX here). +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { completedRuns, formatDurationMillis, formatReportingCoverage, formatSuccessRate, formatTaskTokenUsage, formatTokenUsage, runDetails, runModelSummary, taskAnalytics, taskDurationMillis, tasksInWindow, tokenUsageSummary } from "../../../dashboard/public/lib/run-metrics.js"; + +function localDate(year, month, day, hour = 0) { + return new Date(year, month - 1, day, hour).toISOString(); +} + +test("tasksInWindow starts at local midnight and drives completed run detail", () => { + const now = new Date(2026, 7, 25, 15); + const jobs = [ + { id: "inside", created_at: localDate(2026, 8, 19), runs: [ + { id: "run_reported", command: "build", completed_at: localDate(2026, 8, 25, 12), duration_millis: 1250, token_usage: "4321" }, + { id: "run_missing", command: "review", completed_at: localDate(2026, 8, 18, 11), duration_millis: 500 }, + { id: "run_unmeasured", command: "plan", completed_at: localDate(2026, 8, 25, 10) }, + ] }, + { id: "outside", created_at: localDate(2026, 8, 18, 23), runs: [{ id: "run_outside", completed_at: localDate(2026, 8, 25), duration_millis: 100 }] }, + { id: "future", created_at: localDate(2026, 8, 25, 16), runs: [{ id: "run_future", completed_at: localDate(2026, 8, 25), duration_millis: 100 }] }, + ]; + assert.deepEqual(tasksInWindow(jobs, "7", now).map((job) => job.id), ["inside"]); + const runs = completedRuns(jobs, "7", now); + assert.deepEqual(runs.map((run) => run.id), ["run_reported", "run_unmeasured", "run_missing"]); + assert.equal(runs[0].token_usage, "4321"); + assert.equal(runs[1].token_usage, undefined); + assert.equal(runs[1].duration_millis, undefined); +}); + +test("tasksInWindow applies the 30-day boundary to task creation time", () => { + const now = new Date(2026, 7, 30, 15); + const jobs = [ + { id: "first-day", created_at: localDate(2026, 8, 1), runs: [] }, + { id: "previous-day", created_at: localDate(2026, 7, 31, 23), runs: [] }, + ]; + assert.deepEqual(tasksInWindow(jobs, "30", now).map((job) => job.id), ["first-day"]); +}); + +test("taskAnalytics calculates task outcomes and averages complete terminal timings", () => { + const created_at = localDate(2026, 8, 25, 9); + const jobs = [ + { id: "success", state: "succeeded", created_at, runs: [{ state: "succeeded", duration_millis: 1000 }] }, + { id: "failed", state: "failed", created_at, runs: [{ state: "failed", duration_millis: 2000 }] }, + { id: "running", state: "running", created_at, runs: [{ state: "running", duration_millis: 500 }] }, + { id: "queued", state: "queued", created_at, runs: [{ state: "queued" }] }, + ]; + const metrics = taskAnalytics(jobs, "7", new Date(2026, 7, 25, 15)); + assert.equal(metrics.totalTasks, 4); + assert.equal(metrics.successRate, 0.5); + assert.equal(metrics.failedTasks, 1); + assert.equal(metrics.activeTasks, 2); + assert.equal(metrics.averageTaskDurationMillis, 1500); + assert.equal(metrics.contributingTasks, 2); +}); + +test("taskAnalytics excludes terminal tasks with missing or invalid duration", () => { + const created_at = localDate(2026, 8, 25, 9); + const jobs = [ + { state: "succeeded", created_at, runs: [{ state: "succeeded" }] }, + { state: "failed", created_at, runs: [{ state: "failed", duration_millis: -1 }] }, + { state: "succeeded", created_at, runs: [{ state: "succeeded" }] }, + ]; + const metrics = taskAnalytics(jobs, "7", new Date(2026, 7, 25, 15)); + assert.equal(metrics.averageTaskDurationMillis, null); + assert.equal(metrics.contributingTasks, 0); + assert.equal(metrics.successRate, 2 / 3); +}); + +test("taskDurationMillis reports the single run duration", () => { + assert.equal(taskDurationMillis([{ state: "failed", duration_millis: 1250 }]), 1250); + assert.equal(taskDurationMillis([{ state: "failed" }]), undefined); +}); + +test("taskAnalytics returns explicit zero counts and unavailable rates for no data", () => { + const metrics = taskAnalytics([], "30", new Date(2026, 7, 25, 15)); + assert.deepEqual(metrics, { tasks: [], totalTasks: 0, successRate: null, failedTasks: 0, activeTasks: 0, averageTaskDurationMillis: null, contributingTasks: 0 }); + assert.equal(formatDurationMillis(metrics.averageTaskDurationMillis), "Unavailable"); + assert.equal(formatSuccessRate(metrics.successRate), "Unavailable"); +}); + +test("formatters distinguish explicitly reported zero from unavailable usage", () => { + assert.equal(formatDurationMillis(1250), "1.25s"); + assert.equal(formatDurationMillis(3_661_000), "1h 1m 1s"); + assert.equal(formatSuccessRate(2 / 3), "66.7%"); + assert.equal(formatSuccessRate(Number.NaN), "Unavailable"); + assert.equal(formatTokenUsage("0"), "0"); + assert.equal(formatTokenUsage("9007199254740993"), "9,007,199,254,740,993"); + assert.equal(formatTokenUsage(9007199254740992), "Unavailable"); + assert.equal(formatTokenUsage(undefined), "Unavailable"); +}); + +test("tokenUsageSummary totals only reported completed runs and tracks coverage", () => { + const summary = tokenUsageSummary([ + { completed_at: "2026-08-25T12:00:00Z", token_usage: "9007199254740993" }, + { completed_at: "2026-08-25T12:01:00Z", token_usage: "17" }, + { completed_at: "2026-08-25T12:02:00Z" }, + { completed_at: "2026-08-25T12:03:00Z", token_usage: "01" }, + { completed_at: "0001-01-01T00:00:00Z" }, + ]); + assert.deepEqual(summary, { total: "9007199254741010", reported: 2, completed: 4, unavailable: 2 }); + assert.equal(formatReportingCoverage(summary), "2 of 4 runs"); +}); + +test("tokenUsageSummary does not present missing usage as zero", () => { + const missing = tokenUsageSummary([{ completed_at: "2026-08-25T12:00:00Z" }]); + assert.deepEqual(missing, { total: undefined, reported: 0, completed: 1, unavailable: 1 }); + assert.equal(formatTokenUsage(missing.total), "Unavailable"); + assert.equal(formatReportingCoverage(missing), "0 of 1 run"); + + const zero = tokenUsageSummary([{ completed_at: "2026-08-25T12:00:00Z", token_usage: "0" }]); + assert.deepEqual(zero, { total: "0", reported: 1, completed: 1, unavailable: 0 }); + assert.equal(formatTokenUsage(zero.total), "0"); + assert.equal(formatReportingCoverage(tokenUsageSummary([])), "No completed runs"); +}); + +test("formatTaskTokenUsage marks partial totals as reported", () => { + assert.equal(formatTaskTokenUsage({ total: undefined, unavailable: 2 }), "Not reported"); + assert.equal(formatTaskTokenUsage({ total: "4321", unavailable: 0 }), "4,321 tokens"); + assert.equal(formatTaskTokenUsage({ total: "4321", unavailable: 1 }), "4,321 tokens reported · 1 run unreported"); + assert.equal(formatTaskTokenUsage({ total: "4321", unavailable: 2 }), "4,321 tokens reported · 2 runs unreported"); +}); + +test("runModelSummary reports every distinct configured model and honest fallbacks", () => { + assert.equal(runModelSummary([{ model: "gpt-5.6-sol" }, { model: "gpt-5.6-sol" }]), "gpt-5.6-sol"); + assert.equal(runModelSummary([{ model: "deepseek-v4-flash" }, { model: "gpt-5.6-sol" }]), "deepseek-v4-flash · gpt-5.6-sol"); + assert.equal(runModelSummary([{ model: "gpt-5.6-sol" }, {}]), "gpt-5.6-sol · Executor default"); + assert.equal(runModelSummary([{ model: "Not specified" }]), "Not specified"); + assert.equal(runModelSummary([{}]), "Executor default"); + assert.equal(runModelSummary([{ model: "Not specified" }, {}]), "Not specified · Executor default"); + assert.equal(runModelSummary([]), "Executor default"); +}); + +test("runDetails always surfaces the executor, even when a worker has claimed the run", () => { + const claimed = { executor: "codex", worker_name: "my-macbook", model: "sonnet", duration_millis: 1250, completed_at: "2026-08-25T12:00:00Z", token_usage: "4321" }; + assert.equal(runDetails(claimed), "codex · my-macbook · sonnet · 1.25s · 4,321 tokens"); + + const completedWithoutUsage = { executor: "codex", duration_millis: 250, completed_at: "2026-08-25T12:00:00Z" }; + assert.equal(runDetails(completedWithoutUsage), "codex · 250ms · Token usage unavailable"); + + const unassigned = { executor: "claude", model: "opus" }; + assert.equal(runDetails(unassigned), "claude · opus"); +}); diff --git a/tests/ported/machinist/runs-board.test.ts b/tests/ported/machinist/runs-board.test.ts new file mode 100644 index 0000000..ac4a40d --- /dev/null +++ b/tests/ported/machinist/runs-board.test.ts @@ -0,0 +1,44 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/runs-board.test.js (MIT, Copyright (c) 2026 Owain Lewis). Deviations: bun:test instead of node:test. +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { boardColumnForState, filterJobs, githubIssueReference, groupJobsByBoardColumn, jobDisplayTitle, needsAttention } from "../../../dashboard/public/lib/runs-board.js"; + +test("job states map to the three board columns without hiding attention states", () => { + assert.equal(boardColumnForState("queued"), "queued"); + assert.equal(boardColumnForState("running"), "running"); + assert.equal(boardColumnForState("succeeded"), "finished"); + + for (const state of ["failed", "timed_out", "cancelled", "unexpected_state"]) { + assert.equal(boardColumnForState(state), "finished"); + assert.equal(needsAttention(state), true); + } + + const jobs = ["queued", "running", "succeeded", "failed", "timed_out", "cancelled", "unexpected_state"] + .map((state) => ({ id: state, state })); + const grouped = groupJobsByBoardColumn(jobs); + assert.deepEqual(grouped.queued.map(({ id }) => id), ["queued"]); + assert.deepEqual(grouped.running.map(({ id }) => id), ["running"]); + assert.deepEqual(grouped.finished.map(({ id }) => id), ["succeeded", "failed", "timed_out", "cancelled", "unexpected_state"]); +}); + +test("board and table filters use the same filtered job set", () => { + const jobs = ["queued", "running", "succeeded", "failed", "timed_out", "cancelled", "unexpected_state"] + .map((state) => ({ id: state, state })); + + assert.deepEqual(filterJobs(jobs, "all").map(({ id }) => id), jobs.map(({ id }) => id)); + assert.deepEqual(filterJobs(jobs, "active").map(({ id }) => id), ["queued", "running"]); + assert.deepEqual(filterJobs(jobs, "failed").map(({ id }) => id), ["failed", "timed_out"]); + assert.deepEqual(filterJobs(jobs, "succeeded").map(({ id }) => id), ["succeeded"]); + + const visibleJobs = filterJobs(jobs, "active"); + const grouped = groupJobsByBoardColumn(visibleJobs); + assert.equal(grouped.queued.length + grouped.running.length + grouped.finished.length, visibleJobs.length); +}); + +test("GitHub issue titles are preferred over prompts and hashes", () => { + const job = { id: "job_12345678", prompt: "Complete https://github.com/o/r/issues/7", github_issue_title: "Make cards readable", trigger_subject: "https://github.com/o/r/issues/7" }; + assert.equal(jobDisplayTitle(job), "Make cards readable"); + assert.equal(githubIssueReference(job), "#7"); + assert.equal(jobDisplayTitle({ id: "job_12345678", prompt: "Run an audit" }), "Run an audit"); +}); diff --git a/tests/ported/machinist/status-ui.test.ts b/tests/ported/machinist/status-ui.test.ts new file mode 100644 index 0000000..5244348 --- /dev/null +++ b/tests/ported/machinist/status-ui.test.ts @@ -0,0 +1,73 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/status-ui.test.js (MIT, Copyright (c) 2026 Owain Lewis). Deviations: bun:test instead of node:test. +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { analyticsState } from "../../../dashboard/public/lib/analytics-state.js"; +import { createStatusLoader } from "../../../dashboard/public/lib/status-loader.js"; + +const measuredJob = { created_at: "2026-08-25T10:00:00Z", state: "succeeded", runs: [{ + id: "run_latest", + command: "build", + completed_at: "2026-08-25T12:00:00Z", + duration_millis: 1250, + token_usage: "4321", +}] }; +const now = new Date("2026-08-25T15:00:00Z"); + +test("analytics shows loading instead of empty metrics before status loads", () => { + assert.deepEqual(analyticsState({ jobs: [], days: "30", loaded: false, error: "", now }), { kind: "loading" }); +}); + +test("analytics shows an initial status failure instead of empty metrics", () => { + assert.deepEqual(analyticsState({ jobs: [], days: "30", loaded: false, error: "Status request failed (503)", now }), { + kind: "error", + message: "Status request failed (503)", + }); +}); + +test("analytics hides prior metrics when a refresh fails", () => { + assert.deepEqual(analyticsState({ jobs: [measuredJob], days: "30", loaded: true, error: "network unavailable", now }), { + kind: "error", + message: "network unavailable", + }); +}); + +test("analytics presents measured runs from successful status data", () => { + const state = analyticsState({ jobs: [measuredJob], days: "30", loaded: true, error: "", now }); + assert.equal(state.kind, "ready"); + assert.deepEqual(state.runs.map((run) => run.id), ["run_latest"]); +}); + +test("analytics presents the empty state only after a successful status response", () => { + const state = analyticsState({ jobs: [], days: "30", loaded: true, error: "", now }); + assert.equal(state.kind, "empty"); + assert.equal(state.metrics.totalTasks, 0); + assert.deepEqual(state.runs, []); +}); + +test("an older success cannot replace a newer request failure", async () => { + const first = deferred(); + const second = deferred(); + const requests = [first.promise, second.promise]; + const applied = []; + const loader = createStatusLoader({ request: () => requests.shift(), apply: (result) => applied.push(result) }); + + const olderRefresh = loader.refresh(); + const newerRefresh = loader.refresh(); + second.reject(new Error("newer request failed")); + await newerRefresh; + first.resolve({ jobs: [measuredJob] }); + await olderRefresh; + + assert.deepEqual(applied, [{ kind: "error", message: "newer request failed" }]); +}); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/tests/ported/machinist/task-presentation.test.ts b/tests/ported/machinist/task-presentation.test.ts new file mode 100644 index 0000000..7f316ca --- /dev/null +++ b/tests/ported/machinist/task-presentation.test.ts @@ -0,0 +1,25 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/task-presentation.test.js (MIT, Copyright (c) 2026 Owain Lewis). Deviations: bun:test instead of node:test. +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { taskPresentation } from "../../../dashboard/public/lib/task-presentation.js"; + +test("review shows the bound revision, with earlier attempts in history", () => { + const runs = [{ id: "original" }, { id: "old-gate" }, { id: "revised" }, { id: "gate", reviewed_run_id: "revised" }]; + const view = taskPresentation({ state: "awaiting_approval", runs, workflow: { steps: ["plan", "build"], current_step: 1 } }); + assert.equal(view.result.id, "revised"); + assert.deepEqual(view.history.map(r => r.id), ["original", "old-gate"]); + assert.deepEqual(view.stages.map(s => [s.complete, s.current]), [[true, false], [false, true]]); +}); + +test("initial approval has no fabricated result; running revision shows itself", () => { + assert.equal(taskPresentation({ state: "awaiting_approval", runs: [{ id: "gate" }] }).result, undefined); + const view = taskPresentation({ state: "running", runs: [{ id: "original" }, { id: "revision" }] }); + assert.equal(view.result.id, "revision"); + assert.equal(view.history.length, 1); +}); + +test("successful task marks all stages complete", () => { + const view = taskPresentation({ state: "succeeded", runs: [{ id: "done" }], workflow: { steps: ["plan", "build"], current_step: 1 } }); + assert.ok(view.stages.every(s => s.complete && !s.current)); +}); diff --git a/tests/provenance.test.ts b/tests/provenance.test.ts new file mode 100644 index 0000000..51605ca --- /dev/null +++ b/tests/provenance.test.ts @@ -0,0 +1,60 @@ +// Ported code must say where it came from, and this file must say the same thing. +// A header with no notice, a notice with no header, or a port with no ported +// upstream test is a failure, so a copy cannot land unrecorded (Rule 0). + +import { expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const root = join(import.meta.dir, ".."); +const HEADER = /Ported from owainlewis\/([a-z.-]+)@([0-9a-f]{7}) (\S+) \(MIT, Copyright \(c\) 2026 Owain Lewis\)\. Deviations: \S/; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if ([".git", ".worktrees", "node_modules", "template", "workspaces"].includes(name)) continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, out); + else if (/\.(ts|js|css)$/.test(name)) out.push(path); + } + return out; +} + +const headers = walk(root).flatMap((file) => { + const first = readFileSync(file, "utf8").split("\n").slice(0, 2).join("\n"); + const m = HEADER.exec(first); + return first.includes("Ported from ") ? [{ file: relative(root, file), m }] : []; +}); + +const notices = readFileSync(join(root, "THIRD_PARTY_NOTICES.md"), "utf8"); +const entries = [...notices.matchAll(/^## owainlewis\/([a-z.-]+)@([0-9a-f]{7})$/gm)].map((m) => `${m[1]}@${m[2]}`); +const listed = [...notices.matchAll(/^- `([^`]+)` from /gm)].map((m) => m[1]!); + +test("every Ported-from header is well formed", () => { + expect(headers.length).toBeGreaterThan(0); + for (const h of headers) expect(h.m, `${h.file}: header does not match the required format`).not.toBeNull(); +}); + +test("every ported file is listed in THIRD_PARTY_NOTICES.md under its repo and commit", () => { + for (const h of headers) { + expect(entries, `${h.file}: no notices section for ${h.m![1]}@${h.m![2]}`).toContain(`${h.m![1]}@${h.m![2]}`); + // Ported tests are covered by their repo's section; ported source files are listed one by one. + if (!h.file.startsWith("tests/ported/")) expect(listed, `${h.file}: not listed in THIRD_PARTY_NOTICES.md`).toContain(h.file); + } +}); + +test("every file listed in the notices exists and carries a header", () => { + const withHeader = new Set(headers.map((h) => h.file)); + for (const file of listed) { + expect(existsSync(join(root, file)), `${file} is listed but missing`).toBe(true); + expect(withHeader.has(file), `${file} is listed but has no Ported-from header`).toBe(true); + } +}); + +test("every ported repo has ported upstream tests, and the licence text is included", () => { + for (const repo of new Set(headers.map((h) => h.m![1]!))) { + const dir = join(root, "tests", "ported", repo); + expect(existsSync(dir) && readdirSync(dir).some((f) => f.endsWith(".test.ts")), `no tests/ported/${repo}/*.test.ts`).toBe(true); + } + expect(notices).toContain("MIT License"); + expect(notices).toContain("Copyright (c) 2026 Owain Lewis"); +}); diff --git a/tests/reset.test.ts b/tests/reset.test.ts index 235b7f6..b4e9781 100644 --- a/tests/reset.test.ts +++ b/tests/reset.test.ts @@ -5,7 +5,7 @@ // drives planReset directly against an injected fake gh/git. import { describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseIssueSeed, planReset, rebaseline, reset, type ResetDeps } from "../src/reset"; @@ -183,6 +183,19 @@ describe("factory reset --dry-run", () => { }); }); +describe("reset wipes the WAL files with the state database", () => { + test("factory.db-wal and -shm go too", async () => { + const dir = mkdtempSync(join(tmpdir(), "factory-wal-")); + const db = join(dir, "factory.db"); + for (const f of [db, `${db}-wal`, `${db}-shm`]) writeFileSync(f, "x"); + const github = new FakeGitHub([], []); + const ctx = { repo: "acme/widgets", cloneDir: "/tmp/x", baselineTag: "baseline", base: "trunk", issuesDir: mkdtempSync(join(tmpdir(), "factory-i-")), workspacesDir: join(dir, "ws"), statePath: db }; + await reset({ github, git: new FakeGitRunner() }, ctx, false); + expect(readdirSync(dir)).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); +}); + describe("reset keeps merged setup safe", () => { const ctxFor = (issuesDir: string) => ({ repo: "acme/widgets", diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 70c51e2..b31ee8a 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -126,6 +126,25 @@ describe("approval paths", () => { done(c); }); + test("3b. a second /factory revise still carries the first round of feedback", async () => { + const c = setup([LABEL.ready]); + c.push("triage", triage({ risk: "medium" })); + c.push("plan", plan("medium")); + await c.step(); + c.push("plan", plan("medium", {}, 2)); + c.github.say(1, "/factory revise also cover the empty case"); + await c.step(); + c.push("plan", plan("medium", {}, 3)); + c.github.say(1, "/factory revise and reject negatives"); + await c.step(); + const dir = join(c.workspacesDir, "issue-1", runDir(1)); + expect(readFileSync(join(dir, "revise.md"), "utf8")).toBe("and reject negatives"); + const history = readFileSync(join(dir, "revision.md"), "utf8"); + expect(history).toContain("Earlier review feedback: also cover the empty case"); + expect(history).toContain("Requested changes:\nand reject negatives"); + expect(history).toContain("plan.json"); + }); + test("4. /factory cancel clears the label, closes the issue and removes the worktree", async () => { const c = setup([LABEL.ready]); c.push("triage", triage({ risk: "medium" })); diff --git a/tests/state.test.ts b/tests/state.test.ts index 140c301..0945692 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -50,3 +50,18 @@ test("migrating twice, and on a database from v2.2 without stage_runs, keeps the expect(state.listStageRuns("acme/widgets")).toHaveLength(1); state.close(); }); + +test("two processes opening a fresh database at once both get the full schema", async () => { + const dir = mkdtempSync(join(tmpdir(), "factory-race-")); + const path = join(dir, "factory.db"); + const script = `import { FactoryState } from ${JSON.stringify(join(import.meta.dir, "../src/state"))}; const s = new FactoryState(${JSON.stringify(path)}); s.listEvents(1); s.listStageRuns("r");`; + for (let round = 0; round < 5; round++) { + const target = `${path}${round}`; + const src = script.replaceAll(JSON.stringify(path), JSON.stringify(target)); + const procs = Array.from({ length: 16 }, () => Bun.spawn(["bun", "-e", src], { stderr: "pipe" })); + const codes = await Promise.all(procs.map((p) => p.exited)); + const errs = await Promise.all(procs.map((p) => new Response(p.stderr).text())); + expect({ codes, errs: errs.filter(Boolean) }).toEqual({ codes: Array(16).fill(0), errs: [] }); + } + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/tests/visual-system.test.ts b/tests/visual-system.test.ts new file mode 100644 index 0000000..87021f7 --- /dev/null +++ b/tests/visual-system.test.ts @@ -0,0 +1,58 @@ +// Structural: the cockpit's design system stays whole. Modelled on machinist +// visual-system.test.js: tokens exist for both themes, the mobile bottom nav +// is in place, and every view goes through the shared heading. Also pins the +// rule that page code never renders server text as HTML. + +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const dir = join(import.meta.dir, "..", "dashboard", "public"); +const css = readFileSync(join(dir, "styles.css"), "utf8"); +const app = readFileSync(join(dir, "app.js"), "utf8"); +const TOKENS = ["background", "foreground", "surface", "sidebar", "muted", "muted-foreground", "border", "primary", "primary-foreground", "success", "warning", "danger", "ring"]; + +function block(startsWith: string): string { + const start = css.indexOf(startsWith); + return css.slice(start, css.indexOf("}", start)); +} + +describe("visual system", () => { + test("every colour token is defined for light, dark, and the system-dark preference", () => { + const light = block(":root {"); + const dark = block(':root[data-theme="dark"] {'); + const system = block(':root:not([data-theme="light"]) {'); + for (const t of TOKENS) { + for (const [name, b] of [["light", light], ["dark", dark], ["system", system]] as const) expect(b, `${t} in ${name}`).toContain(`--${t}:`); + } + }); + + test("mobile navigation is a bottom bar with safe-area padding", () => { + expect(css).toMatch(/\.app-sidebar nav \{ position: fixed;[^}]*bottom: 0;/); + expect(css).toContain("grid-template-columns: repeat(5, minmax(0, 1fr))"); + expect(css).toContain("padding-bottom: calc(4.15rem + env(safe-area-inset-bottom))"); + }); + + test("motion is reduced on request and focus is always visible", () => { + expect(css).toContain("prefers-reduced-motion: reduce"); + expect(css).toContain(":focus-visible"); + }); + + test("the font ships with its licence, and the file the CSS names exists", () => { + expect(existsSync(join(dir, "fonts", "manrope-latin.woff2"))).toBe(true); + expect(readFileSync(join(dir, "fonts", "OFL.txt"), "utf8")).toContain("SIL OPEN FONT LICENSE Version 1.1"); + expect(css).toContain("/fonts/manrope-latin.woff2"); + }); + + test("every top-level view is built with the shared heading", () => { + for (const view of ["lineView", "inboxView", "runsView", "analyticsView"]) { + const body = app.slice(app.indexOf(`function ${view}`)); + expect(body.slice(0, body.indexOf("\n}\n")), view).toMatch(/heading\("/); + } + }); + + test("server text never reaches innerHTML, and labels are not all-caps", () => { + expect(app).not.toMatch(/\.(innerHTML|outerHTML)\b|insertAdjacentHTML/); + expect(css).not.toMatch(/text-transform:\s*uppercase/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index bb88c80..35a2313 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "ESNext", "moduleResolution": "bundler", "moduleDetection": "force", - "allowJs": false, + "allowJs": true, "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true,