From 126874d70a53b6f1e6140a8706d0c7c93d00a718 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:06:16 +0300 Subject: [PATCH 1/9] Port machinist dashboard modules, revision history, assembler logs and plain(); add provenance test Co-Authored-By: Claude Sonnet 5 --- THIRD_PARTY_NOTICES.md | 48 ++++++ bin/factory | 13 ++ dashboard/public/lib/analytics-state.js | 11 ++ dashboard/public/lib/routes.js | 15 ++ dashboard/public/lib/run-metrics.js | 133 ++++++++++++++++ dashboard/public/lib/runs-board.js | 66 ++++++++ dashboard/public/lib/status-loader.js | 21 +++ dashboard/public/lib/task-presentation.js | 20 +++ src/display.ts | 7 + src/help.ts | 1 + src/logs.ts | 56 +++++++ src/revision.ts | 80 ++++++++++ src/watch.ts | 5 +- template/.claude/skills/factory-plan/SKILL.md | 2 + tests/ported/assembler/display.test.ts | 14 ++ tests/ported/assembler/logs.test.ts | 46 ++++++ tests/ported/machinist/revision.test.ts | 18 +++ tests/ported/machinist/routes.test.ts | 16 ++ tests/ported/machinist/run-metrics.test.ts | 142 ++++++++++++++++++ tests/ported/machinist/runs-board.test.ts | 44 ++++++ tests/ported/machinist/status-ui.test.ts | 73 +++++++++ .../machinist/task-presentation.test.ts | 25 +++ tests/provenance.test.ts | 60 ++++++++ tests/scenarios.test.ts | 19 +++ tsconfig.json | 2 +- 25 files changed, 934 insertions(+), 3 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 dashboard/public/lib/analytics-state.js create mode 100644 dashboard/public/lib/routes.js create mode 100644 dashboard/public/lib/run-metrics.js create mode 100644 dashboard/public/lib/runs-board.js create mode 100644 dashboard/public/lib/status-loader.js create mode 100644 dashboard/public/lib/task-presentation.js create mode 100644 src/display.ts create mode 100644 src/logs.ts create mode 100644 src/revision.ts create mode 100644 tests/ported/assembler/display.test.ts create mode 100644 tests/ported/assembler/logs.test.ts create mode 100644 tests/ported/machinist/revision.test.ts create mode 100644 tests/ported/machinist/routes.test.ts create mode 100644 tests/ported/machinist/run-metrics.test.ts create mode 100644 tests/ported/machinist/runs-board.test.ts create mode 100644 tests/ported/machinist/status-ui.test.ts create mode 100644 tests/ported/machinist/task-presentation.test.ts create mode 100644 tests/provenance.test.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..d2996fe --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,48 @@ +# 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/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. diff --git a/bin/factory b/bin/factory index 2695511..1cf319f 100755 --- a/bin/factory +++ b/bin/factory @@ -14,6 +14,7 @@ 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 { reset, rebaseline } from "../src/reset"; import { runDoctor, fixDoctor } from "../src/doctor"; import { createDashboard } from "../dashboard/server"; @@ -147,6 +148,16 @@ 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 cmdScan(): Promise { const cloneDir = await resolveCloneDir(); const config = await loadConfig(cloneDir); @@ -320,6 +331,8 @@ async function main(): Promise { return cmdPark(); case "dashboard": return cmdDashboard(); + case "logs": + return cmdLogs(); case "scan": return cmdScan(); case "reset": 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/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..7b882cd 100644 --- a/src/help.ts +++ b/src/help.ts @@ -8,6 +8,7 @@ export const COMMANDS: { name: string; usage: string; does: string }[] = [ { 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: "logs", usage: "logs [--repo ] [--stage ] [--follow] [--json]", does: "print (or follow) a run's events; --json is one object per line" }, { 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/logs.ts b/src/logs.ts new file mode 100644 index 0000000..eb1fd02 --- /dev/null +++ b/src/logs.ts @@ -0,0 +1,56 @@ +// Ported from owainlewis/assembler@7cac671 src/runs.ts:93-140 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: the source is the run's events in the state DB (keyset on id) instead of log files, so there is no path-escape or split-UTF-8 handling; the stage filter, NDJSON shape, follow loop and "no step matching" error are kept. + +import { plain } from "./display"; +import type { FactoryState, Run, RunStatus } from "./state"; + +const ACTIVE: readonly RunStatus[] = ["running", "verifying"]; +const delay = (ms: number, signal?: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true }); + }); + +export interface LogOptions { + follow?: boolean; + stage?: string; + json?: boolean; + signal?: AbortSignal; + pollMs?: number; +} + +export async function streamLogs( + state: FactoryState, + repo: string, + issue: number, + options: LogOptions, + print: (value: string) => void = (value) => process.stdout.write(value), +): Promise { + let after = 0; + let lastStage = ""; + let matched = false; + while (!options.signal?.aborted) { + const run: Run | undefined = state.getRun(repo, issue); + if (!run) throw new Error(`No run recorded for issue #${issue}`); + for (;;) { + const events = state.listEvents(run.id, { after, limit: 200 }); + if (!events.length) break; + for (const event of events) { + after = event.id; + if (options.stage && event.stage !== options.stage) continue; + matched = true; + const text = plain(event.text); + if (options.json) print(JSON.stringify({ issue, stage: event.stage, kind: event.kind, text }) + "\n"); + else { + if (lastStage !== event.stage) print(`\n--- ${event.stage} ---\n`); + print(`${text}\n`); + lastStage = event.stage; + } + } + } + if (options.stage && !matched && (!options.follow || !ACTIVE.includes(run.status))) { + throw new Error(`No stage matching ${options.stage}`); + } + if (!options.follow || !ACTIVE.includes(run.status)) return; + await delay(options.pollMs ?? 250, options.signal); + } +} diff --git a/src/revision.ts b/src/revision.ts new file mode 100644 index 0000000..d324ff2 --- /dev/null +++ b/src/revision.ts @@ -0,0 +1,80 @@ +// Ported from owainlewis/machinist@3943516 internal/runner/revision.go:1-31 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: the Revision shape is internal/protocol/revision.go:1-10; the previous run id is the stage that produced the work; the revision is rebuilt from the issue thread (GitHub is the state), so no run store is needed. + +import { existsSync } from "node:fs"; +import { COMMENT_FILENAMES, JSON_FILENAMES, runDir } from "./artifacts"; +import { isHumanComment, parseChatOps } from "./chatops"; +import { latestDataFor, parseDataMarkers } from "./derive"; +import type { GhComment, GhIssue } from "./github"; + +export interface Revision { + previousRun: string; + previousSummary: string; + feedback: string; + priorFeedback: string[]; + // alias -> path relative to the run directory of the earlier output + artifacts: Record; +} + +// Feedback is appended after everything else: braces in it are literal. +export function revisionPrompt(r: Revision | undefined, inputs: Record): string { + if (!r) return ""; + let out = `\n\nHuman review: revise your previous work from ${r.previousRun}.\nPrevious result: ${r.previousSummary}\n`; + for (const feedback of r.priorFeedback) out += `Earlier review feedback: ${feedback}\n`; + out += `Requested changes:\n${r.feedback}\n`; + for (const alias of Object.keys(r.artifacts).sort()) { + out += `Previous output ${JSON.stringify(r.artifacts[alias])} is available at ${JSON.stringify(inputs[alias])}\n`; + } + out += + "Use the original task requirements and the review feedback. Revise the existing work, preserve unrelated changes, and publish the revised deliverables to your output directory. Report what changed.\n"; + return out; +} + +// Every earlier trusted `/factory revise` in the thread, oldest first, so a +// second round of feedback does not make the agent forget the first. +export function priorFeedback(comments: readonly GhComment[], current: Pick): string[] { + const out: string[] = []; + for (const c of comments) { + if (c.id === current.id) break; + if (!isHumanComment(c)) continue; + const cmd = parseChatOps(c.body); + if (cmd.type === "revise") out.push(cmd.text); + } + return out; +} + +const SUMMARY_STAGES = ["verify", "build", "plan"] as const; + +export function previousSummary(issue: GhIssue): string { + const markers = parseDataMarkers(issue.comments); + for (const stage of SUMMARY_STAGES) { + const json = latestDataFor(markers, stage) as { summary?: unknown } | undefined; + if (typeof json?.summary === "string" && json.summary) return `${stage}: ${json.summary}`; + } + return "no summary was recorded"; +} + +export function buildRevision(worktree: string, issue: GhIssue, comment: Pick, feedback: string): Revision { + const artifacts: Record = {}; + for (const stage of Object.keys(JSON_FILENAMES) as (keyof typeof JSON_FILENAMES)[]) { + for (const name of [JSON_FILENAMES[stage], COMMENT_FILENAMES[stage]]) { + if (existsSync(`${worktree}/${runDir(issue.number)}/${name}`)) artifacts[name] = name; + } + } + return { + previousRun: `the earlier round on issue #${issue.number}`, + previousSummary: previousSummary(issue), + feedback, + priorFeedback: priorFeedback(issue.comments, comment), + artifacts, + }; +} + +// `revise.md` keeps the latest feedback verbatim (the stage skills read it); +// `revision.md` is the full review history the next attempt is asked to honour. +export async function writeRevision(worktree: string, issue: GhIssue, comment: Pick, feedback: string): Promise { + const dir = `${worktree}/${runDir(issue.number)}`; + const revision = buildRevision(worktree, issue, comment, feedback); + const inputs = Object.fromEntries(Object.keys(revision.artifacts).map((alias) => [alias, `${dir}/${alias}`])); + await Bun.write(`${dir}/revise.md`, feedback); + await Bun.write(`${dir}/revision.md`, revisionPrompt(revision, inputs).trimStart()); +} diff --git a/src/watch.ts b/src/watch.ts index 3c43fe8..7d54164 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -11,6 +11,7 @@ // on a different machine can all resume any issue. import type { FactoryConfig } from "./config"; +import { writeRevision } from "./revision"; import type { Executor, StageName, StageRunResult } from "./executor"; import { clearStageArtifacts, @@ -510,7 +511,7 @@ export async function resumeAwaitingApproval(issue: GhIssue, deps: WatchDeps, co } if (command.type === "revise") { await deps.git.ensureWorktree(deps.cloneDir, worktree, issue.number); - await Bun.write(`${worktree}/${runDir(issue.number)}/revise.md`, command.text); + await writeRevision(worktree, issue, reply, command.text); await deps.github.setStateLabel(config.repo, issue.number, [LABEL.awaitingApproval], LABEL.planning); return runFromStage(deps, config, issue, "plan", worktree, ctxFrom(issue)); } @@ -567,7 +568,7 @@ export async function resumeInReview(issue: GhIssue, deps: WatchDeps, config: Fa const worktree = worktreeFor(deps, issue.number); await deps.git.ensureWorktree(deps.cloneDir, worktree, issue.number); - await Bun.write(`${worktree}/${runDir(issue.number)}/revise.md`, command.text); + await writeRevision(worktree, issue, latest, command.text); const head = deps.git.branchName(issue.number); if (await deps.github.findPrByHead(config.repo, head)) await deps.github.markReady(config.repo, head, false); await deps.github.setStateLabel(config.repo, issue.number, [LABEL.inReview], LABEL.building); diff --git a/template/.claude/skills/factory-plan/SKILL.md b/template/.claude/skills/factory-plan/SKILL.md index 540dd0e..883e796 100644 --- a/template/.claude/skills/factory-plan/SKILL.md +++ b/template/.claude/skills/factory-plan/SKILL.md @@ -16,6 +16,8 @@ runner posts and labels. Runs after `factory-triage` returned `proceed`. handoff: type, risk hint, done_when, files_expected. - `.factory/runs/issue-/revise.md` — present only when a human sent `/factory revise `: their feedback on the previous plan revision. + `revision.md` beside it repeats that feedback with every earlier round + of feedback and the previous summary; honour all of it. - `AGENTS.md`, `.factory/charter.md`, and the repo's skills index (`.claude/skills/*/SKILL.md`, minus `factory-*`) — what repo-specific skills exist to apply (e.g. `handling-money`). 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..1890c28 --- /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)$/.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/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/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, From 68b25f04b72c1cc1d95670fd08bbd9c8650c4120 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:08:54 +0300 Subject: [PATCH 2/9] Add the human inbox layer and factory inbox command One derived list of everything waiting on a human, acting through the same /factory comments a human types. A structural test walks every waiting label. Co-Authored-By: Claude Sonnet 5 --- bin/factory | 22 +++++++++++ src/help.ts | 1 + src/inbox.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++ tests/inbox.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 src/inbox.ts create mode 100644 tests/inbox.test.ts diff --git a/bin/factory b/bin/factory index 1cf319f..943763b 100755 --- a/bin/factory +++ b/bin/factory @@ -15,6 +15,7 @@ import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } f 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"; @@ -158,6 +159,25 @@ async function cmdLogs(): Promise { 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 [, issueArg, actionArg] = args; + 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); @@ -333,6 +353,8 @@ async function main(): Promise { return cmdDashboard(); case "logs": return cmdLogs(); + case "inbox": + return cmdInbox(); case "scan": return cmdScan(); case "reset": diff --git a/src/help.ts b/src/help.ts index 7b882cd..98c5442 100644 --- a/src/help.ts +++ b/src/help.ts @@ -9,6 +9,7 @@ export const COMMANDS: { name: string; usage: string; does: string }[] = [ { 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: "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", 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"); + }); +}); From 537ac15607abab3dd885d4459c94dc66ea413455 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:12:09 +0300 Subject: [PATCH 3/9] Add inbox, analytics, stages and artifact routes; harden dashboard auth Routes live in one table walked by a test. The token is compared in constant time, is header or session cookie only, and artifacts are sent as text with sandbox headers. Co-Authored-By: Claude Sonnet 5 --- dashboard/analytics.ts | 60 +++++ dashboard/server.ts | 422 +++++++++++++++++++++++---------- tests/dashboard-routes.test.ts | 191 +++++++++++++++ 3 files changed, 549 insertions(+), 124 deletions(-) create mode 100644 dashboard/analytics.ts create mode 100644 tests/dashboard-routes.test.ts 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/server.ts b/dashboard/server.ts index 39b0ca2..9148a15 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,30 @@ 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 /", "POST /api/session"]; + +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 +93,278 @@ 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" } }) }, + { + 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\/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 +373,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/tests/dashboard-routes.test.ts b/tests/dashboard-routes.test.ts new file mode 100644 index 0000000..4fd159c --- /dev/null +++ b/tests/dashboard-routes.test.ts @@ -0,0 +1,191 @@ +// Structural: every dashboard route sits in one table, and with a token set +// each one is refused without credentials unless it is on the public +// allow-list. A new route cannot ship open by accident. Also covers the new +// read and write routes (inbox, analytics, stages, artifacts). + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GitHub, type GhIssue } from "../src/github"; +import { LABEL } from "../src/labels"; +import { FactoryState, type StageRunInput } from "../src/state"; + +class FakeGitHub extends GitHub { + posted: { issue: number; body: string }[] = []; + constructor(private readonly issues: GhIssue[] = []) { + super(); + } + override async listOpenIssues(): Promise { + return this.issues; + } + override async commentIssue(_repo: string, issue: number, body: string): Promise { + this.posted.push({ issue, body }); + return 1; + } +} + +const waiting = (n: number, label: string): GhIssue => ({ + number: n, + title: `Issue ${n}`, + body: "", + labels: [{ name: label }], + comments: [{ id: n, author: "bot", authorAssociation: "NONE", body: "\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: [] }); + }); +}); From d6c97f0cb3b1359c376ad305a9c3a6578458a861 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:20:18 +0300 Subject: [PATCH 4/9] Redesign the cockpit: Line, Inbox, Runs, Analytics Machinist design tokens, self-hosted Manrope, mobile bottom nav, run side sheet with artifact viewer, /api/line and public /assets routes, and a visual-system structural test. Co-Authored-By: Claude Sonnet 5 --- THIRD_PARTY_NOTICES.md | 8 + dashboard/public/app.js | 330 +++++++++++++++ dashboard/public/fonts/OFL.txt | 92 +++++ dashboard/public/fonts/manrope-latin.woff2 | Bin 0 -> 24836 bytes dashboard/public/index.html | 455 ++------------------- dashboard/public/styles.css | 235 +++++++++++ dashboard/server.ts | 37 +- tests/dashboard-routes.test.ts | 24 ++ tests/provenance.test.ts | 4 +- tests/visual-system.test.ts | 58 +++ 10 files changed, 812 insertions(+), 431 deletions(-) create mode 100644 dashboard/public/app.js create mode 100644 dashboard/public/fonts/OFL.txt create mode 100644 dashboard/public/fonts/manrope-latin.woff2 create mode 100644 dashboard/public/styles.css create mode 100644 tests/visual-system.test.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d2996fe..b7e6633 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,6 +8,7 @@ in step. Only MIT-licensed code is copied. 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` @@ -46,3 +47,10 @@ 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/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("-?@O z>DuWMbjiAHx=VVxdVYGDdWZEM>9h2M^_%p&^w$hf10RDVgTn^j8S)GXh7v>C4uvzq z5|N#J3s>aQC&ePjF{zaYG9NebCko@CD8#lDmuBfNbtNth)>-ve-BovJq(<%NSXX+~ zhra2r_G+H8TC7g3RFBr_Exm1QTie6_t=aM|V9#24>$g?fYH9^q8TOj{n^Vja&Bf*g z=2hnH<_F9_pwy^vR0669HGx_{EuvOX0~TTnTMLqfz~T#wdluss4=sFXW3&aDie{rT z(Aj7?x&mE~Zbf&a2hm@kC(v`~E%X!y#q7k`V}dc^7zQQ*la7&L8Zd2`!xU&_Sm-HK&i9e(ChNCf6Wn0FDA6%CXZ3L%DmK zCh#^&6pKKHBoZAo6Q{l~>hXbDuAqJOMVLZSE9hG2ImsNKR-2x%>WFc-Tne9?t8bdg zajDdR%^lSKvWvt#Ykr=WyCz$5C6|6@8{y3pLgBZSlp8&io>s0)npYU_$kRFc+>XWR z^1xD88}+M+!wsGvYAR5e)S4}AZKhd=tP)*&A-7KW3>L-Zens1@ttzamI!dsQ{?rQS z&j-I3TA8YsiPS?{6qUdsfRxwn9K(!{*{*>xPQ2Jqo|xhQ%HjEV++WAyb z1|kd+c*-g|fERv!3r$+hr(*H>xxznZE~#=2soAV9g@ol~&8cDPbd8@UcBKSO7a7n_ z(yPX*iRZAky*)Oa4x}k0#Dl09MErn9^}h-?WfCvQ6GVk1LMajzxpuw+h2W%p3|tEJ z^H(VN_{m1uYA@v+)B6!PF_jMjdVP@QrTY`%mXt!B0L z%14*977eRf_58S%CG_|o%icK$!JMv~rU5f$I$Gr52quO&4G&F~`!lXq4>wbDwKD#GI_Q@Cu1OOXQ!gmiDNRAg0!m??VDmt+L8kj(}**&02 zO(YkYmT8WkBSmQ{3isR)A0u=MW)z(L!Wa>-I`}zfPeF}}(QQ3qLRomky)h+tMTlAq zH#3DrfuhuyfQ9WIQEd)L%GHUy(n?FcD>I`7%aekZ1LY$cE!MY1g%G0E6wH5@?=XGJ zdLrU8gft;?ons5GbdZC`1Gz$-t_eR#e%Ux5IDCPFf0sp3$e?0Tt0i1WITRnLZQRid z=4YvB5|OY8hQ*iKmR5*8h6`~~&??1<%TH!7oP<$B7_Wc?b(bZnBsfT5;i2ggCkKALsZhtiBiR|++?E>R!zpGRk#u{?B z7-YttSlA_wVT9`HRuudCc*feeK09u;3zVrc3C=_kNR7C+Dq6r|=42DG<)rp-vBbe* zm$3vakcc;AG%;y&n5}2+juh_=+-*4VoJnxpl`Kv7qLv_x&Ciu7- z4}}|4+K7(!&9`)r4Ewwhp6zc%JE9fZ@_{2%VrWzO1*K8_m;e1;oR+v|p&my-FpDT|@&lY&$ge`R8OS^ia>SR|g#bMNmX+uJY%#e{}S* zbcr4de)N7)jZQk4&HvLM_FfcfRm&AvA?*1_%Y8o2Ise}D9}L0H?rvR^U9ak>WzoEM z(!%TB^-NacS(y$P^n~tzGNAAiu_F2itVEi=$g`&n=4vqa+Pr|n6*HU4$6lQ7bksSb@ zNgFMV*0q6okYA!a#>JvX7~H>|VWAot2eF zG*yLBVu_h+i_6trfu?nCYDuor$xyAO?p!Ieo`{>xC?wr#N%iP_1Sqz|WHI@bH4Os4 zdWVU*^!RMWpU$mR5V&fZ+z7v3jYC8YOWiCR2}q)^ zN-Vz~J1FBP@Ax-^&MkWTy@L9MMr`_Ch5t)vYt5?ub8bXhtCGcd9F1V%xD3kB+w-1v z4ysYZxip{1Hkmw->-LjkyRr8p@6mefcnreY~E&M z5-#?WRbN z#h>r_5%CR5g{+ah@swzxz7 zE-NSIVY@kt^Dt(SZrfI*6dtVRJr$>n4Fr5WL;_{k4(}xbpA?-E)&aNvVHd-(d7Up^Q<2LxlH zcYNb-{}jAqsEu*bXL<~;``h;S4$n}fZU4|02e$Le!az5Z+J3B6=61h{PnvzK_+~|e z#zVH4Xst>P0n;EX-_uca1V34>c`iL?7(X4nAJl3;eGQBxCJ*}Nd_kM_@LX>%@&Agh zE$+nh3D|x;{P^K`gd}kI;e4m}Zyk*KWygT4@79lXliYFvn$sfc{LjJ?GO{cEPd?h{ zpWYCao{<^bbj1GkPETue0eWvwevP~FCiWSY870zGg=Xclw#SQeO%+anQhMS~#7fSrZM(_~NF-@HW(%&K{UA%R*n9J!>G`z;>nzOQUVc zahjrHN{SOvn@&4@lcyF}x*87tFwV{U=WpDXwvX<7l&(C~VK7#i6{MLU^9|ne;5p_F zeFk-mQ6IxG_1^R34IJvbQCPxffC-4jGn6SmJKyj8<#J#qu|UI`RPj%ENmRUBB8DHS zareo7`39bKqjONUuJik8hSt?zxOd$BwC;!s>)>D>z)+>~VKBwexmaL{Rl9msle&ee zfBp__pT#FGOZ6FxxB27L+U4_x`o^aXH>sO)qnOjt5cBo?m#$L9=or=Og|c+zY#!Bq zo8>;!+e`HC&RKa;&Rz523;57`y|hQDd1+J+92o=^<qa}{e0VQq$bwR8;K!w zrlMUhmDr!XHEaFGVsBr$6re!&f2pI5jeBr|T`rZ%QE=8!Pub+CyqD2X7!nLok;x5#I09bdZbXDm`6lkK7&_FR^o+uX~5)y=dkF68n#o{fCh4WKjwK7@}b8 zZ!-4$)W84kV_Shidr`k?t##X6qsBUQXQ$rc!T6;GC!&!pf@*1w{BnARx% z|2SGb;IiJTN zIbR0YPuH1N4^JU*^@vkNoCt1OOCo6t?+MzR+uXi6iK7O3!*(9f^KWKNi6-}QUS%aN zC1i?KpU;x8IUwF7bzt(Us^>Bp?_pmCy@dy9Pi-B*t8J|$fnY`XdJ41p{#F?Uju1UD zp-4EkioybUDjROwdE3%tzR?*#N36D#iD_MNL4SvZ5(UN^?IE-2+Cf(GusT|XiI(O> z+hOdHFJ~z-ip~R-bKZrR8aXxUbk|JGwnEhkSLmbPq!SuZOsdl*D--G8kIl$33iLBt zxFFAfk&UVRY1=?&+SsNWtY)fCS@E_+GE1AcuBzcHW7-F{31eDIlp=R!mj=GfGFhk- zWNg;3i9rzhrL$#K=exSy&iFBAg@AWM*! zGigtjZCY3dx>1wOg9@XQ*Y#TYwf0wJZzRO5He^K|S zMu^4u>f=`G`kZ0{1C#n0kkwjg@**WXjB( zf{x;N;#>9J8Qd4)@cWSUhQWocJ|adEfL}Uipr)^^g!S&VwrmZ}Azc|pJYq9TCGS^fG zZd&7LS6x?!-{zk_Z4c!R1ROm95qrcD@=d`(Q|PZ%oo+eOGp^ZI1KZ1oCmJ7o8pM-U zl-s&~zt)tfnd#edbUv`=u|HjLc>9>ro_R;^j_SRqw)?vI{A_Z#Bc&T#{B`N>_e)oo zp7vm1b6@D6D3x2X=i`wi<>(@jAM`ZMKFdA6k1NG`bRHE)bol1ci$ARvT@Oc$hwcY!T78RYwskc^^z# zU`y?rx=a@A;Om?PGwngP6Az@kqcvYnyD9imQbOw~Lv|RNs08=8`3)JWaZtZHA`V8% zZb9qkwRqlITE!12w}7-z>T%~ePp)rvuC5lEYM2%%oDt7@cZ}#2e~?n9ODn+~YEeVZ zweae?4ik(t9)KHoBqNhPYX;e9+x259tavViQ}!YyzLz;3R~5OKXvAJTE_hD1(Gd$h zNBao{N#ujqN{v|m)Xr)>d%@ZHY`V{bC(6@8|8PuPFpf|k?g`KRCp`N1-bts^eJ3a& zR5GUm;kvUC*1r|V`$3XE`>pvm=VK&jGq|Iq)|%`YeFW0%NO#S|lzYlKGv&p&*#Sz7 z1^g`{uU1W9aOB!aPy4j@EnYG`?2 zgxo$)S>l;(^Oo(JYy#!fZ_&I7i zxn`^9D?hSdM-&|e_8(Ub8;@#MQQdaLpp^1m@K2pu&%=*1sun%BygLJ@HfY1eNg!Wh zabLWBFI7FV*^h3rAMB#Qz{yEpUrc}Ctz>obP+fQarkzg}{Wxxn_)280+|BRMJ-#o| zM3YPYIse)aT``?Ytm-Q)$gYxPsUYt2dY>f}JZDv8&Yb4ym5oR`>vdv2O{5XbZPf^8 zXiKtvg~!@W)s^8VXtVY3+z(Zfu&>X1>>_guu^q~Ew9e8~|2T@X_^&s0HjQ|}(W4vV^(gd2j&^#KfA1OU z!1HL%`IN8KgJY{epj2v+DC09@@p}L0QjJU*Ar3O0cVd86RYl3F^absuA++B|9aGjR zr}fpaeJagipPo->RJHV(@wPW6H3FUGV`bze()HO!F>l;ZV!K);yN-k=P){JZ}(4jRE)bkl6?{rRMovtx;@D@C5RtamlwI~FihZn zsO&h)roVsnQ43GpT}CMzcqQWCC^K%~T3>(({cq@I-HZng{-ZoDvF%ll`vW%5j;Unq6J694ZbGAfb=wDt%7>q`%SI z3U$o*X}(;&2W3UZvax=LSYx*yN=_GySl64=dtc81zeuuy5n0*YquVs-v*^p;%$b2_N<<&=Hwg3lK6-}9ty zBhWjIY14-)1dP>mZao-Q1JTEpST{}R+K;z0D*a-q_rhUjt3XmpT0U~#T-zVP1w z6DgKuXk<={;pnk))E5&pZghs}B#C;?oyYfGGwSgK&UpOpp5c1~HMtx!#gfc;Yr>{% zP~;m&pNv?ROFh}O4wGbO8?fW)CiA5F$w-7EH^eZJq-xo^Ty_xueYciz^*Zl^atAi$ zd?w)?n!v+#UC<&9?akh6Ogw1+ut45dBSS5$TFEGfkRDdCqGw?!8}2@ZJ2t7W(_Wm^ zmG@2#5k|ALyf{TB#tX!ZU?Fhb*)&aLR%rMH%zV+73%b14z;7;47EY$I6=4zluR>t% zNBM1;m4ifv`Jw(3=U{nDlLc;RX<8~v5)Q}mdEZnd#(0?<+C?*S5MS!(c_x#haW!rS z!7Yy$Z)Pgf{c4%W!?_&qy&a=hMw=4DJXGWIi5s>ZdM)l%yYrk z_>t#Ue1P_cjyeK|%KL+a&klx<&{X=BT+hjN{q^g%lynW|Or@3KEBWC*Q-W zN$|5B@;mhJ{0C@`-5;0;us-^AQ9_6$JWUO|)^-maa>-e?*f}%|{E%zx;IEF=sVqE9@~B zfOrd{YQ6!;nUbq3QUc*#PnhXXo@mRSa)*_gZWL69^Q?95Y-f?9HJ9`Kq4b0(?H#$P z1Klp5*O(-pi+SAlFXcOq3X)Icw?sizHFzE;oD%A@dCQjiH!vYOr|4 z7);nN=;Nh#ke+_pb!Yy;nV*L4-5LB&oubJi#szcp^V;h*0|U?Gavno@cK5iyj$R-W z**GilzOQGN_TE?Q!RuOoMrq-FG_rZL3OdQ!DZi$Os~hJbyj|(AK_KVtioj}P6Nm`WEiAsCUx7u!i zoqa#aQk5ul+{8pO%TJkocCa^-NV2f5|LXWTEPsua4E0OpN*cOuciXrrbm4WnKZnj$xi^A6_>>Hd7Kh9iP9pFwrQL^{+of?+iz+jF->WxB z78fp8UTH#F9bfOjT86L*uDwZLa?e#){k%Y+3$x(5vHaU`1q}Tm{HyCL>NiK<|L5x# zs!B8o&Pb2lRq;fi8Fi;AS_-Q9@^0p1TuK#>tTyjgWPU*CEX>|Zw=UQCt7Q8QQytrO z6KfPfRr~(&JfcBTA>_?C&r}h7zFb@n6D_Q4jb&9#OuLd2pgwA6&$VwTvl$Eu<9IT~ zo%{D4sv4{)A-c`lSJ^D@Jgde;Lxi_l&Wa8qO^W&riCS@g?lNw=`Qk+4y=rURmn#e; zK?cB0N*;U|0*jEch`zLNb9V){%*umTBEZ886wGO!Izj(k^@$)4ilbP0ZogY+5_n5p z+)*D3cGk$8PG7}Cdi9Xo1CilCZo{0FG5L{RFsDZK@%ud+ynU?;`Rc0nuhs7n*p+NMdp6ZQC223pUZoZ1%!b^m`!zDCj}X?>F;t zxeXltM2!D2=g$<3&>ub~^dAHT9#5{md8={!+KP?-ZEf@2k6SNlf@BoT{@@8%wSVgP z5o>5zuRo6SY^d~^Gtz;OXuTzxlAg5gae~beTtx>bwulpbxl8?I>&cxij)EVUR&=?x zzWHq2)x1+4qEg3Pm7WSy=)(RIAeJUA-cYJ+35cPqqA#stN5d%;-brCOQEW)4cc2_GfS8;_^ZB)&yNm~u5rvht zD4NZDBM;v-Fv-!3H8j2u`kzKvwL_)Jwtly<@XlF!0nIlR^Kx{tz^JhIJ?x8IUP5rW zjf<&;eSxN>XD_8TH3Si15#{xW2&Yp}al&Xzr6Hv-{%xL^{OJN9QYM~j#%`P|T%|}M z>J#w9^eA=?4(_!)20E|F>zsfIRpk;QsBWv(3sstb%zw}Aaqqv3<+fH~tJ2B0y zqFpfAcqgpnDxMz}Z2G}<_k|=oy&*BC`j>*|%gz-Zi8IQG2K-x6h?1IYCIvk{<(~nn zy0*}bU-+fdi!5=Hon`?eZm_rDy2jnVc;b5^|D-;{MQD)x@&tZWY$g<2&OTasE6vV~ z8~(5(m(`cE?Sn=ZK-jyw#v!fE6~T@qb6Ne zDxhTKF*oc8o$*(ya5&-7#Y=2mVY)OHaH zX@NMF4?M&cr|S?hq^a)M5mpS}9V!7aD(p330|k+M}y9SvL9 z!-9ex+i>uyOQPu7jxWdgV)C2JJ;8MNs_N;#pH|qUj$0c`<6w+<$GC_bOw^^4~Txp)npz5HjxQLjj*tOVhvCcjVGr?O{{uMs}~TwK)z=aO6wW>gDddlsc~chZ`rW z=8WZ^cVF}m%t8|Vf7AX!Y<7k7&MomY%}5JY>_|v?Y%t_9f}i%;r+(_<%#6xSg`QJR zdnuNH#$55WDGEo?j?QRBSI%BiadL3X8-gs#33vN2Nxu7|ZaoeKN9`My`P=7%!#6pG z+}NbJM80~fjyv!a(WMWp_GqY_N~68M9#Uw6@iJESUANC+X?`}}my|5mcgt*~QUv)8 z8XTB%V5+0(VI!0xJBH|+=oP5F<;`+T_H0-Zuz5xF9hZAD$hDv#L zp=g`>q*4z`bIwaSf0*OMty}i{lTRZFa>a=}*LE#>0`}~DgsNCqq|utv*~@NH&O-trn-d?ktvfQu~1O6MuUNC(=3-Kypl4L+xd5epDe5Sd2>!xlaW&@6fqGU&}f z1CN;$>?r4eiNQCq$$6{?)8pD^Rgygap7?M|64VV}klw83w3RRz(b!wmTp5`0v44J) zl7AaQ3I0f=VUd~|$wHEhc}K;)?-*9-jFE#$1HRXjmwF-Gt&+| zoB&bCoI@eZjx;5@>R@=-Rz~Tg@c}Fh#(HQ}2BPZ#rlcl63OhE|1yy+e&VayFH{!Xe zo7WI+#{|xBT{)L>e`daA!IaH0+gYYMnUHQyG2ifkrH9u-(i#F4;VBKnD0IuC0O+~+ zEn7Gcn-pJtjI_h$+mfD`)>eHqlep66{xzV4)oYd0T@C^yl*QrzEB*_*_<&$b6>_SF z1grmUbN~oF59xzOGCU1p_YRs(=rx8_&wO}%p{qUX8W%swkN}`lPU}%vJU#ZHT3IJx z&!F3RArtSYe{G%VYvNlr)eFgKfpsbnlgt``w;8&qB=a30rUozx$C7*?mRO_0#;T~u z_-+X`4I#IIuT_UsW_S?pxhr*X_J{CpN)Qs7EY(!F{aJHO=+u)-WKHCW37ll57z~fVJnbdR`Kllth z>^=(tH+{BJcvSk5B=cGh=?7ntsoz&+De^U8Jl`anzi*b#T3O=nhJXyAZ25BJO6MkA z3AubiWI3eLtkz5lb2vB|i4Ec)?TQP@%)n4YQIW>h`4&$s$!ubBW`de7uoP%o-`f5L zj`WT+IG&NqpGR?lRQV)RMeaQA3KVJj=H|#J;vpVIEYs=ffvx5HvU6m^^5zcD)78pV z129F36V))C^>pnV>#T)ZHk8Wmm1BAFw1*!R2uPojM5A?(sile(Nnw(-oM<*+IVLPv zAX6d|YobsJmDDOtmPk#^l2nN_LN`+uWP=lx@x-5_*QZfg0ZzM-#{ntkq?*^wj=eMv zzUC<1bvNDO#F-0Mx7~5qy%D;-`FmvWa^C|YkMvk@49@qw$L{vVJ@$my20#7+1o~Ex zu#Kf<$|f@+97T=@--xv2A4-TEcXPZPk$6A)$rjlfYTyNh7Mef`6^+=HsG=z|*w8dzqc*MD)e-nAU5U{tREMWt zdgi%T#e3D&{Pd!9&GkTSGgS&1F*_t@RT6<=@CU{U&c@J?gA4OenKn zL8o%USmfw47o+ zWbjSgBpERJqHn>}?{`OKK=+5jBj3!8ZaMU{`~=YdsjFWi?j9)Cz<~aStaP~y8qC#f z`lH(JYufg1t~mWb$l6Ix^FeWU+J?dKnVS-+bk>x!XCb4_+RcFB-UJ0}q$Gky^TJod zqmvQT;kt||39qBYdiD-g3x*JvX_c)+wUN;Qp=e&_S_kr?I vHGTYtQeSD*Z7MFz)l}pN#|t4r#9qfVWnOP^;?KG~|Js~im7eo&;4}dMEb^A; literal 0 HcmV?d00001 diff --git a/dashboard/public/index.html b/dashboard/public/index.html index b6c123c..3140a0f 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -1,435 +1,34 @@ - - -Software Factory - - - -
-
-

Software Factory

-
loading…
-
-
- - -
-
-
-
-

Needs attention

-
  • Nothing waiting.
-
- -
- -
-
- -
- - + + +Factory cockpit + + + + + +
+
+
+ + factory +
+ + +
+
+
+
+ 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 9148a15..331c4b4 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -55,7 +55,9 @@ function cookie(req: Request, name: string): string { // 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 /", "POST /api/session"]; +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; @@ -165,6 +167,20 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin // (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$/, @@ -230,6 +246,25 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin 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$/, diff --git a/tests/dashboard-routes.test.ts b/tests/dashboard-routes.test.ts index 4fd159c..bc59880 100644 --- a/tests/dashboard-routes.test.ts +++ b/tests/dashboard-routes.test.ts @@ -189,3 +189,27 @@ describe("artifacts", () => { 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/provenance.test.ts b/tests/provenance.test.ts index 1890c28..51605ca 100644 --- a/tests/provenance.test.ts +++ b/tests/provenance.test.ts @@ -14,7 +14,7 @@ function walk(dir: string, out: string[] = []): string[] { 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)$/.test(name)) out.push(path); + else if (/\.(ts|js|css)$/.test(name)) out.push(path); } return out; } @@ -22,7 +22,7 @@ function walk(dir: string, out: string[] = []): string[] { 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 }] : []; + return first.includes("Ported from ") ? [{ file: relative(root, file), m }] : []; }); const notices = readFileSync(join(root, "THIRD_PARTY_NOTICES.md"), "utf8"); 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/); + }); +}); From 805954ef9b6c7463e8488c16d3f2b94df28cc1fb Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:20:47 +0300 Subject: [PATCH 5/9] Release v2.4.0: version, runner ref, changelog Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 22 ++++++++++++++++++++++ package.json | 2 +- template-ci/factory.yml.example | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) 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/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/template-ci/factory.yml.example b/template-ci/factory.yml.example index ba5f26e..9d71732 100644 --- a/template-ci/factory.yml.example +++ b/template-ci/factory.yml.example @@ -35,7 +35,7 @@ on: required: false env: - FACTORY_RUNNER_REF: v2.3.0 # pinned software-factory release; bump deliberately + FACTORY_RUNNER_REF: v2.4.0 # pinned software-factory release; bump deliberately FACTORY_RUNNER_REPO: learnwithparam/software-factory CLAUDE_CODE_VERSION: "2.1.281" # pinned claude, same version the Dockerfile installs From eb1058b71a55a30139365468d96f9fda3b229332 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:28:49 +0300 Subject: [PATCH 6/9] Fix reset leaving SQLite WAL files behind Co-Authored-By: Claude Sonnet 5 --- src/reset.ts | 3 ++- tests/reset.test.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/reset.ts b/src/reset.ts index 574026f..455b63b 100644 --- a/src/reset.ts +++ b/src/reset.ts @@ -205,7 +205,8 @@ async function applyAction(deps: ResetDeps, ctx: ResetContext, action: ResetActi await Bun.$`rm -rf ${action.detail}`.quiet(); return; case "wipe-state": - await Bun.$`rm -rf ${action.detail}`.quiet(); + // SQLite runs in WAL mode; a leftover -wal or -shm next to a fresh file corrupts it. + await Bun.$`rm -rf ${action.detail} ${action.detail}-wal ${action.detail}-shm`.quiet(); return; } } 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", From 44380acbb4b352d94edb5f8d4bc6b3485860c6eb Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:30:42 +0300 Subject: [PATCH 7/9] Fix a fresh database racing between the runner and the dashboard Co-Authored-By: Claude Sonnet 5 --- src/state.ts | 8 +++++++- tests/state.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/state.ts b/src/state.ts index 83b7421..766008d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -80,8 +80,14 @@ export class FactoryState { constructor(path: string = DEFAULT_DB_PATH) { if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); this.db = new Database(path); + // The runner and the dashboard open a fresh database at the same moment after a reset; + // wait for the other's lock, and migrate inside one write transaction so neither sees half a schema. + + // The runner and the dashboard open a fresh database at the same moment after a reset; + // wait for the other's lock, and migrate inside one write transaction so neither sees half a schema. + this.db.exec("PRAGMA busy_timeout = 5000;"); this.db.exec("PRAGMA journal_mode = WAL;"); - this.migrate(); + this.db.transaction(() => this.migrate()).immediate(); } private migrate(): void { diff --git a/tests/state.test.ts b/tests/state.test.ts index 140c301..4ee000e 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -50,3 +50,17 @@ 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: 4 }, () => Bun.spawn(["bun", "-e", src], { stderr: "pipe" })); + const codes = await Promise.all(procs.map((p) => p.exited)); + expect(codes).toEqual([0, 0, 0, 0]); + } + rmSync(dir, { recursive: true, force: true }); +}); From 7ab413535372d21498f58915eddf5e872602b055 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:34:01 +0300 Subject: [PATCH 8/9] Dashboard resolves its repo from --repo-dir; inbox CLI skips flags when reading positionals Co-Authored-By: Claude Sonnet 5 --- Makefile | 2 +- bin/factory | 11 +++++++++-- src/help.ts | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) 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/bin/factory b/bin/factory index 943763b..da0a916 100755 --- a/bin/factory +++ b/bin/factory @@ -164,7 +164,13 @@ async function cmdInbox(): Promise { if (!repo) throw new UsageError("inbox: --repo (or FACTORY_REPO) is required"); const github = new GitHub(); const items = buildInbox(await github.listOpenIssues(repo)); - const [, issueArg, actionArg] = args; + 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`); @@ -296,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); diff --git a/src/help.ts b/src/help.ts index 98c5442..7ebb35a 100644 --- a/src/help.ts +++ b/src/help.ts @@ -7,7 +7,7 @@ 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)" }, From 9e501607e2ff4d5d00232287334acb59f0565acf Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 10:50:21 +0300 Subject: [PATCH 9/9] Retry a locked first open of the state database; show child stderr in the race test Co-Authored-By: Claude Sonnet 5 --- src/state.ts | 19 ++++++++++++------- tests/state.test.ts | 5 +++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/state.ts b/src/state.ts index 766008d..21188e2 100644 --- a/src/state.ts +++ b/src/state.ts @@ -80,14 +80,19 @@ export class FactoryState { constructor(path: string = DEFAULT_DB_PATH) { if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); this.db = new Database(path); - // The runner and the dashboard open a fresh database at the same moment after a reset; - // wait for the other's lock, and migrate inside one write transaction so neither sees half a schema. - - // The runner and the dashboard open a fresh database at the same moment after a reset; - // wait for the other's lock, and migrate inside one write transaction so neither sees half a schema. + // The runner and the dashboard open a fresh database at the same moment after a reset. + // Retry a locked open, and migrate in one write transaction so neither sees half a schema. this.db.exec("PRAGMA busy_timeout = 5000;"); - this.db.exec("PRAGMA journal_mode = WAL;"); - this.db.transaction(() => this.migrate()).immediate(); + for (let attempt = 0; ; attempt++) { + try { + this.db.exec("PRAGMA journal_mode = WAL;"); + this.db.transaction(() => this.migrate()).immediate(); + return; + } catch (e) { + if (attempt >= 20 || !/locked|busy/i.test(String(e))) throw e; + Bun.sleepSync(25 * (attempt + 1)); + } + } } private migrate(): void { diff --git a/tests/state.test.ts b/tests/state.test.ts index 4ee000e..0945692 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -58,9 +58,10 @@ test("two processes opening a fresh database at once both get the full schema", 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: 4 }, () => Bun.spawn(["bun", "-e", src], { stderr: "pipe" })); + const procs = Array.from({ length: 16 }, () => Bun.spawn(["bun", "-e", src], { stderr: "pipe" })); const codes = await Promise.all(procs.map((p) => p.exited)); - expect(codes).toEqual([0, 0, 0, 0]); + 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 }); });