Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 [<N> <action> [--text <words>]] [--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.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Third-party notices

Code ported from other projects. Each ported file starts with a `Ported from` header naming the
upstream repo, commit, path and lines; `tests/provenance.test.ts` keeps this file and those headers
in step. Only MIT-licensed code is copied.

## owainlewis/machinist@3943516

Source: https://github.com/owainlewis/machinist (MIT, Copyright (c) 2026 Owain Lewis)

- `dashboard/public/styles.css` from `internal/controlplane/web/src/styles.css`
- `dashboard/public/lib/run-metrics.js` from `internal/controlplane/web/src/run-metrics.js`
- `dashboard/public/lib/runs-board.js` from `internal/controlplane/web/src/runs-board.js`
- `dashboard/public/lib/status-loader.js` from `internal/controlplane/web/src/status-loader.js`
- `dashboard/public/lib/analytics-state.js` from `internal/controlplane/web/src/analytics-state.js`
- `dashboard/public/lib/task-presentation.js` from `internal/controlplane/web/src/task-presentation.js`
- `dashboard/public/lib/routes.js` from `internal/controlplane/web/src/routes.js`
- `src/revision.ts` from `internal/runner/revision.go` (shape from `internal/protocol/revision.go`)

## owainlewis/assembler@7cac671

Source: https://github.com/owainlewis/assembler (MIT, Copyright (c) 2026 Owain Lewis)

- `src/display.ts` from `src/display.ts`
- `src/logs.ts` from `src/runs.ts`

## License text (both projects)

MIT License

Copyright (c) 2026 Owain Lewis

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

## Manrope (font)

`dashboard/public/fonts/manrope-latin.woff2` is the Latin subset of Manrope, copied from the build output
of owainlewis/machinist@3943516. Copyright 2018 The Manrope Project Authors
(https://github.com/sharanda/manrope), SIL Open Font License 1.1. The licence text is in
`dashboard/public/fonts/OFL.txt`, shipped beside the font.
44 changes: 43 additions & 1 deletion bin/factory
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { EXIT, UsageError, failureJson, successJson } from "../src/cli-output";
import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } from "../src/watch";
import { ShellGateRunner } from "../src/gates";
import { scan } from "../src/scan";
import { streamLogs } from "../src/logs";
import { act, buildInbox, type InboxAction } from "../src/inbox";
import { reset, rebaseline } from "../src/reset";
import { runDoctor, fixDoctor } from "../src/doctor";
import { createDashboard } from "../dashboard/server";
Expand Down Expand Up @@ -147,6 +149,41 @@ async function cmdPark(): Promise<void> {
console.log(`factory park #${issueNumber}: needs-human — ${reason}`);
}

async function cmdLogs(): Promise<void> {
const issue = Number(args[1]);
if (!Number.isInteger(issue) || issue < 1) throw new UsageError("logs: an issue number is required, e.g. `factory logs 12 --follow`");
const state = new FactoryState(flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH);
const repo = flag("repo") ?? process.env.FACTORY_REPO ?? state.listRuns().find((r) => r.issue === issue)?.repo ?? "";
const controller = new AbortController();
process.on("SIGINT", () => controller.abort());
await streamLogs(state, repo, issue, { follow: has("follow"), stage: flag("stage"), json: has("json"), signal: controller.signal });
}

async function cmdInbox(): Promise<void> {
const repo = flag("repo") ?? process.env.FACTORY_REPO;
if (!repo) throw new UsageError("inbox: --repo <owner/name> (or FACTORY_REPO) is required");
const github = new GitHub();
const items = buildInbox(await github.listOpenIssues(repo));
const positional: string[] = [];
for (let i = 1; i < args.length; i++) {
if (args[i] === "--json") continue;
if (args[i]!.startsWith("--")) i++; // a flag and its value
else positional.push(args[i]!);
}
const [issueArg, actionArg] = positional;
if (issueArg) {
const item = items.find((i) => i.issue === Number(issueArg));
if (!item) throw new UsageError(`inbox: #${issueArg} is not waiting for you`);
if (!actionArg) throw new UsageError(`inbox: choose an action: ${item.actions.join(", ")}`);
const posted = await act(github, repo, item, actionArg as InboxAction, flag("text") ?? "");
console.log(has("json") ? successJson({ issue: item.issue, posted }, true) : `#${item.issue}: posted "${posted}"`);
return;
}
if (has("json")) console.log(successJson({ items }, true));
else if (!items.length) console.log("Nothing is waiting for you.");
else for (const i of items) console.log(`#${i.issue} ${i.kind} (${i.actions.join("/")}): ${i.title}`);
}

async function cmdScan(): Promise<void> {
const cloneDir = await resolveCloneDir();
const config = await loadConfig(cloneDir);
Expand Down Expand Up @@ -265,7 +302,8 @@ function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoA

async function cmdDashboard(): Promise<void> {
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);
Expand Down Expand Up @@ -320,6 +358,10 @@ async function main(): Promise<void> {
return cmdPark();
case "dashboard":
return cmdDashboard();
case "logs":
return cmdLogs();
case "inbox":
return cmdInbox();
case "scan":
return cmdScan();
case "reset":
Expand Down
60 changes: 60 additions & 0 deletions dashboard/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, StageRun[]>();
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),
};
}
Loading
Loading