From 02975481a845e272e998bc3c06658ee7083d6b63 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Thu, 8 Oct 2026 22:06:37 +0900 Subject: [PATCH 1/2] basou: Measure the workspace on the spot when the board has no record basou view's board page draws a record of board/board.yaml. With no record yet, or no board.yaml read (the manifest does not declare the workspace's own repo private), it said why and drew nothing. It now measures what basou board measure measures without a declaration and draws that: the period and effort, the repositories, the decisions and open tracks, the sessions and how many basou verify does not find verified, the review gaps, the newest session and the sessions not yet imported, and the components the markers find. A page that draws a record links to the same measurement (/board?live=1), and a button measures again. Nothing on it is judged and nothing is written, so it needs no private repo, and it is not served in portfolio mode. The effort starts on the day of the first session in this host's time zone, so an adopted repository's older history does not fill the charts with empty months. @basou/core gains measureBoardLive and boardLivePage, and the effort section's start may be left out; a declared start is measured as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 33 +++ packages/cli/src/commands/board.test.ts | 42 +++- packages/cli/src/commands/board.ts | 49 +++++ packages/cli/src/commands/view.test.ts | 254 +++++++++++++++++++++++- packages/cli/src/commands/view.ts | 32 ++- packages/cli/src/lib/board-ui.ts | 140 ++++++++++++- packages/cli/src/lib/view-server.ts | 36 ++++ packages/core/src/board/effort.test.ts | 89 ++++++++- packages/core/src/board/effort.ts | 54 ++++- packages/core/src/board/index.ts | 2 + packages/core/src/board/live.test.ts | 186 +++++++++++++++++ packages/core/src/board/live.ts | 215 ++++++++++++++++++++ packages/core/src/board/page.ts | 59 ++++-- packages/core/src/index.ts | 5 + packages/core/src/lib/view-strings.ts | 87 ++++++++ 15 files changed, 1238 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/board/live.test.ts create mode 100644 packages/core/src/board/live.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8efd3512..88fb83dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- **`basou view`'s board page measures the workspace on the spot when there + is no record to draw.** With no record yet, or no `board/board.yaml` read + (the manifest does not declare the workspace's own repo private), `/board` + now measures what `basou board measure` measures without a declaration and + draws it, saying it was measured now and is not a record: the period and + effort, from the day of the first session in this host's time zone; each + repository the manifest declares (the workspace's own repo when it declares + none), with HEAD, its branch, the last commit, the commits, the uncommitted + paths and the commits behind `origin/main` as of the last fetch; the + decisions and open tracks; how many sessions there are and how many + `basou verify` does not find verified; the review gaps; the newest session + and the sessions not yet imported, counted by the dry run `basou orient` + runs; and the components the markers find. A page drawing a record links + to the same measurement (`/board?live=1`), and a button measures again. + Nothing on it is judged (no reach matrix, lanes or operator's turns), it + writes nothing, so it needs no private repo, and it is not served in + portfolio mode. Like every page of `basou view`, it is a local UI and not + an API. + +### Internal + +- **`@basou/core` measures a workspace with no board declared.** + `measureBoardLive` runs the built-in sections of a board's measurement that + need nothing from a declaration, and `boardLivePage` lays the result out for + the board page. The effort section's start may be left out: the days then + start on the day of the first session, or today when there is none or the + sessions cannot be read (which is said under `not_found`). A declared start + is measured as before. + ## 0.66.0 — 2026-10-08 ### Fixed diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 72512176..12d03e33 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -24,7 +24,13 @@ import { } from "@basou/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { probeStaleness } from "../lib/provenance-actions.js"; -import { doRunBoardMeasure, doRunBoardRecord, runBoardMeasure, runBoardRecord } from "./board.js"; +import { + doRunBoardMeasure, + doRunBoardRecord, + measureLiveBoard, + runBoardMeasure, + runBoardRecord, +} from "./board.js"; import { doRunPortfolioList } from "./portfolio.js"; import { doRunReviewGaps } from "./review-gaps.js"; import { doRunStats } from "./stats.js"; @@ -1321,3 +1327,37 @@ describe("basou board measure and record against the previous record", () => { expect(out.join("\n")).toContain("Components:\n 0 found, 0 unacknowledged, gone not known\n"); }); }); + +describe("measureLiveBoard: the board page's measurement with no board", () => { + it("measures the repos the manifest declares, with no declaration and no board.yaml", async () => { + const repo = await workspace([ + { path: ".", visibility: "public" }, + { path: "../elsewhere", visibility: "private" }, + ]); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.repos.map((r) => r.path)).toEqual([".", "../elsewhere"]); + expect(m.not_found.map((n) => n.at)).toContain("repos[../elsewhere]"); + expect(m.measured_at).toBe(NOW.toISOString()); + // The dry run of an import ran, over the logs beside the workspace. + expect(m.freshness.unimported).toEqual({ new: 0, updated: 0, unverifiable: 0 }); + }); + + it("measures the workspace's own repo when the manifest declares none", async () => { + const repo = await workspace(); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.repos.map((r) => r.path)).toEqual(["."]); + expect(m.not_found.map((n) => n.at)).not.toContain("repos"); + }); + + it("measures the workspace's own repo, and says why, when the manifest cannot be read", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await writeFile(join(repo, ".basou", "manifest.yaml"), "repos: [: broken\n"); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.repos.map((r) => r.path)).toEqual(["."]); + expect(m.complete).toBe(false); + expect(m.not_found[0]).toEqual({ + at: "repos", + reason: "the manifest could not be read, so only the workspace's own repo was measured", + }); + }); +}); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 302d6a2b..28720e3f 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -6,6 +6,7 @@ import { type BoardCellChange, type BoardDeclaration, type BoardDiff, + type BoardLiveMeasurement, type BoardMeasurement, type BoardNotFound, type BoardObservation, @@ -22,6 +23,7 @@ import { findErrorCode, type Manifest, measureBoard, + measureBoardLive, NO_PREVIOUS_RECORDS, parseBoardDeclaration, parseRecordInput, @@ -558,6 +560,53 @@ async function measureLoaded( }); } +/** + * Measure what the workspace at `root` shows with no board declared, for the + * board page of `basou view`: the built-in sections that need nothing from a + * declaration, over the repos the manifest declares (the workspace's own repo + * alone when it declares none, or cannot be read). Writes nothing, sends + * nothing, and runs no import: the sessions not yet imported are counted by + * the dry run `basou orient` runs. + */ +export async function measureLiveBoard( + root: string, + ctx: BoardContext, +): Promise { + const paths = basouPaths(root); + let repos: string[] = ["."]; + let manifestUnread = false; + try { + const declared = ((await readManifest(paths)).repos ?? []).map((repo) => repo.path); + if (declared.length > 0) repos = declared; + } catch { + manifestUnread = true; + } + const now = ctx.nowProvider?.() ?? new Date(); + const probeCtx: ImportContext = { cwd: root }; + if (ctx.claudeProjectsDir !== undefined) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir; + if (ctx.codexSessionsDir !== undefined) probeCtx.codexSessionsDir = ctx.codexSessionsDir; + const measured = await measureBoardLive({ + root, + repos, + paths, + now, + measuredWith: { basou: BASOU_CLI_VERSION, build: BASOU_BUILD?.commit ?? null }, + probeImports: () => probeStaleness({ ctx: probeCtx, paths, nowIso: now.toISOString() }), + }); + if (!manifestUnread) return measured; + return { + ...measured, + complete: false, + not_found: [ + { + at: "repos", + reason: "the manifest could not be read, so only the workspace's own repo was measured", + }, + ...measured.not_found, + ], + }; +} + /** Where a board.yaml is, how to show it, and whether --board gave it. */ type BoardLocation = { path: string; shown: string; given: boolean }; diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 6ef3705e..c43532b3 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -28,13 +28,14 @@ import { import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BOARD_HTML } from "../lib/board-ui.js"; -import type { ViewServerHandle } from "../lib/view-server.js"; +import { startViewServer, type ViewServerHandle } from "../lib/view-server.js"; import { VIEW_HTML } from "../lib/view-ui.js"; -import { doRunBoardMeasure, doRunBoardRecord } from "./board.js"; +import { doRunBoardMeasure, doRunBoardRecord, measureLiveBoard } from "./board.js"; import { doRunView, registerViewCommand, runView, + sharedWhileRunning, type ViewContext, type ViewOptions, } from "./view.js"; @@ -1052,6 +1053,8 @@ describe("basou view: the board page", () => { cwd: repo, signal: controller.signal, openBrowser: () => {}, + // The live board runs the dry run of an import: never over this host's logs. + claudeProjectsDir: getClaudeRoot(), codexSessionsDir: getCodexRoot(), onListening: (h) => { handle = h; @@ -1131,6 +1134,107 @@ describe("basou view: the board page", () => { await withPortfolioServer([repo], {}, async (h) => { expect((await fetch(`${h.url}/board`)).status).toBe(404); expect((await getJson(h, `/api/ws/${WS_ID_A}/board`)).status).toBe(404); + expect((await getJson(h, `/api/ws/${WS_ID_A}/board/live`)).status).toBe(404); + }); + }); + + it("serves the measurement on the spot in single mode only, whatever it is given", async () => { + const root = await initWorkspaceAt(tmpRepo as string, WS_ID_A, "a"); + let measured = 0; + const entry = { + key: WS_ID_A, + label: "a", + paths: basouPaths(root), + repoRoot: root, + importCtx: { cwd: root }, + initialized: true, + }; + const boardLive = () => { + measured++; + return measureLiveBoard(root, { + claudeProjectsDir: getClaudeRoot(), + codexSessionsDir: getCodexRoot(), + }); + }; + for (const mode of ["portfolio", "single"] as const) { + const h = await startViewServer({ + port: 0, + deps: { workspaces: [entry], mode, nowProvider: () => FIXED_DATE, boardLive }, + }); + try { + const status = (await getJson(h, `/api/ws/${WS_ID_A}/board/live`)).status; + expect([mode, status]).toEqual([mode, mode === "single" ? 200 : 404]); + } finally { + await h.close(); + } + } + expect(measured).toBe(1); + }); + + it("shares one measurement among the requests made while it runs", async () => { + let runs = 0; + let finish: (value: number) => void = () => {}; + const shared = sharedWhileRunning(() => { + runs++; + return new Promise((resolve) => { + finish = resolve; + }); + }); + const first = shared(); + const second = shared(); + expect(second).toBe(first); + finish(1); + expect(await first).toBe(1); + // A call after it settled runs again. + const third = shared(); + expect(third).not.toBe(first); + finish(2); + expect(await third).toBe(2); + expect(runs).toBe(2); + }); + + it("measures the workspace on the spot when there is no board, whatever its visibility", async () => { + const repo = await workspaceWith(); + await withView(repo, {}, async (h) => { + const live = await getJson(h, "/api/board/live"); + expect(live.status).toBe(200); + expect(live.data).toMatchObject({ + language: "en", + strings: boardPageStrings("en"), + live: { + heading: { title: "view-ws", measured_at: expect.any(String) }, + effort: { milestones: [] }, + // The manifest declares no repos: the workspace's own repo. + repos: [{ path: "." }], + trail: { decisions_all: 0, tracks_open: [] }, + footnotes: { measured_with: { basou: expect.any(String) } }, + }, + }); + const data = live.data as Record; + expect(data).not.toHaveProperty("page"); + for (const key of ["matrix", "lanes", "turns", "composition", "summary"]) { + expect(data.live).not.toHaveProperty(key); + } + const scoped = await getJson(h, `/api/ws/${FIXED_WS_ID}/board/live`); + expect(scoped.status).toBe(200); + expect(scoped.data).toMatchObject({ live: { heading: { title: "view-ws" } } }); + }); + }); + + it("measures beside the records, in the anchor's language, and takes live for no record", async () => { + const repo = await workspaceWith([{ path: ".", visibility: "private", language: "ja" }]); + const id = await recorded(repo); + await withView(repo, {}, async (h) => { + expect((await getJson(h, "/api/board/live")).data).toMatchObject({ + language: "ja", + strings: boardPageStrings("ja"), + live: { heading: { title: "view-ws" }, repos: [{ path: "." }] }, + }); + expect((await getJson(h, "/api/board")).data).toMatchObject({ page: { status: "ok", id } }); + expect((await getJson(h, `/api/board/${id}`)).data).toMatchObject({ page: { id } }); + // Nothing was written beside the records. + const records = await readdir(join(repo, "board", "records")); + expect(records).toEqual([`${id}.json`]); }); }); @@ -1252,17 +1356,17 @@ describe("basou view: the board page", () => { document, boardPageStrings("en"), "en", - ) as Record Node>; - const call = (name: string, arg: unknown): Node => { + ) as Record Node>; + const call = (name: string, ...args: unknown[]): Node => { const fn = fns[name]; if (fn === undefined) throw new Error(`no ${name}`); - return fn(arg); + return fn(...args); }; return { call, byId }; } it("draws a page it cannot draw around the record asked for", () => { - const { call, byId } = drawing(["records"]); + const { call, byId } = drawing(["records", "recordLinks"]); const records = [A_REC, B_REC, C_REC].map((id, i) => ({ id, at: `2026-10-0${i + 1}T00:00:00.000Z`, @@ -1282,6 +1386,7 @@ describe("basou view: the board page", () => { `/board?record=${A_REC}`, `/board?record=${C_REC}`, "/board", + "/board?live=1", ]); const chosen = nav.find((n) => n.tag === "option" && n.selected); expect(chosen.map((o) => o.attrs.value)).toEqual([B_REC]); @@ -1539,6 +1644,143 @@ describe("basou view: the board page", () => { expect(placed.map((p) => p.row)).toEqual([0, 1, 0]); }); + // A board measured on the spot, as the live route lays it out. + const LIVE = { + heading: { + title: "Board", + measured_at: "2026-10-05T03:00:00.000Z", + complete: false, + not_found: 1, + }, + effort: { + start: "2026-10-04", + time_zone: "UTC", + elapsed_days: 1, + active_ms: { union: 3_600_000, claude: 3_600_000, codex: null }, + output_tokens: 10, + sessions_without_tokens: 0, + commits: [{ repo: ".", count: 2 }], + milestones: [], + active_days: 1, + period_days: 2, + daily: [ + { + date: "2026-10-04", + union: 3_600_000, + claude: 3_600_000, + not_claude: 0, + cumulative: 3_600_000, + commits: 2, + }, + { + date: "2026-10-05", + union: 0, + claude: 0, + not_claude: 0, + cumulative: 3_600_000, + commits: 0, + }, + ], + weeks: [ + { + week: "2026-09-28", + union: 3_600_000, + claude: 3_600_000, + codex: null, + active_days: 1, + commits: 2, + }, + ], + }, + repos: [ + { + path: ".", + name: "app", + head: "0123456789abcdef0123456789abcdef01234567", + branch: null, + last_commit: null, + commits: 2, + uncommitted: 1, + behind_main: null, + }, + ], + trail: { + decisions_all: 3, + decisions_live: 2, + tracks_open: [{ id: "decision_01HXABCDEF1234567890ABCDEF", title: "open" }], + }, + integrity: { by_status: { verified: 1 }, not_verified: 0, sessions: 1 }, + review_gaps: { by_verdict: { omission: 4 }, gaps: 4 }, + freshness: { newest_session_at: null, unimported: { new: 1, updated: 0, unverifiable: 0 } }, + components: [{ key: "app", kinds: ["ci", "manifest"] }], + footnotes: { + not_found: [ + { at: "freshness.newest_session_at", reason: "a session.yaml could not be read" }, + ], + measured_with: { basou: "0.0.0-test", build: null }, + }, + }; + + it("draws a board measured on the spot: what basou measured, nothing judged", () => { + const names = [ + "liveTile", + "liveHeading", + "liveRepos", + "liveTrail", + "liveComponents", + "liveFootnotes", + "effort", + ]; + const { call } = drawing(names); + const S = boardPageStrings("en"); + const heading = call("liveHeading", LIVE, null); + expect(heading.text()).toContain("Board"); + expect(heading.text()).toContain("Measured "); + expect(heading.text()).toContain("1 could not be measured"); + expect(heading.find((n) => n.tag === "button").map((b) => b.textContent)).toEqual([ + S.live.remeasure, + ]); + + const repos = call("liveRepos", LIVE); + const cells = repos.find((n) => n.tag === "td").map((c) => c.textContent); + expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-"]); + + const trail = call("liveTrail", LIVE); + // The newest session was not measured: not "no session". + expect(trail.text()).toContain(S.tiles.notMeasured); + expect(trail.text()).not.toContain(S.live.noSession); + expect(trail.text()).toContain("not imported: 1 new, 0 updated, 0 unverifiable"); + // A track's title is text, never markup. + expect(trail.find((n) => n.tag === "b")).toEqual([]); + expect(trail.text()).toContain("open"); + + const components = call("liveComponents", LIVE); + expect(components.find((n) => n.tag === "code").map((c) => c.textContent)).toEqual(["app"]); + expect(components.text()).toContain("ci, manifest"); + + const noBoard = call("liveFootnotes", LIVE, "no_board"); + for (const s of [S.live.note, S.live.judged, S.unavailable.noBoard, S.footnotes.notMeasured]) { + expect(noBoard.text()).toContain(s); + } + expect(noBoard.text()).toContain( + "freshness.newest_session_at: a session.yaml could not be read", + ); + expect(call("liveFootnotes", LIVE, null).text()).not.toContain(S.unavailable.noBoard); + + // The period and effort are drawn as a record's are. + expect(call("effort", LIVE).find((n) => n.tag === "figure").length).toBeGreaterThan(0); + }); + + it("measures the workspace when there is no board or no record, and links to it otherwise", () => { + const draw = lift("draw"); + expect(draw).toContain("page.why === 'no_board' || page.why === 'no_records'"); + expect(draw).toContain("loadLive(page.why);"); + expect(lift("drawLive")).toContain( + "if (why === null) nav.appendChild(el('a', { href: '/board', text: S.live.toRecords }));", + ); + expect(lift("records")).toContain("href: '/board?live=1'"); + }); + it("builds prose of text nodes, with only a span between backticks as a code element", () => { const document = { createElement: (tag: string) => new Node(tag), diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index e87dd29e..17648629 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { basename, dirname, join, resolve } from "node:path"; import { assertBasouRootSafe, + type BoardLiveMeasurement, basouPaths, findErrorCode, loadPortfolioConfig, @@ -22,7 +23,7 @@ import { type ViewServerHandle, type WorkspaceEntry, } from "../lib/view-server.js"; -import { DEFAULT_BOARD_PATH } from "./board.js"; +import { DEFAULT_BOARD_PATH, measureLiveBoard } from "./board.js"; import type { ImportContext } from "./import.js"; const DEFAULT_PORT = 4319; @@ -234,6 +235,35 @@ async function buildSingleDeps(ctx: ViewContext, cwd: string): Promise Promise { + return sharedWhileRunning(() => + measureLiveBoard(root, { + ...(ctx.nowProvider !== undefined ? { nowProvider: ctx.nowProvider } : {}), + ...(ctx.claudeProjectsDir !== undefined ? { claudeProjectsDir: ctx.claudeProjectsDir } : {}), + ...(ctx.codexSessionsDir !== undefined ? { codexSessionsDir: ctx.codexSessionsDir } : {}), + }), + ); +} + +/** + * Share one run of `run` among the calls made while it runs: such a call gets + * that run's result, and a call after it settles starts another. + */ +export function sharedWhileRunning(run: () => Promise): () => Promise { + let running: Promise | null = null; + return () => { + running ??= run().finally(() => { + running = null; + }); + return running; }; } diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 070ceb07..8ae175ac 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -3,6 +3,9 @@ * mode. It draws one record of the board (`GET /api/board`, or * `/api/board/` with `?record=`) in eight sections, from the record * alone, with the fixed strings the server sends in the workspace's language. + * With no record to draw, or with `?live=1`, it draws what basou measures of + * the workspace now (`GET /api/board/live`): the period and effort, the + * repositories, the trail and the components, with nothing judged. * Everything is put in with createElement / textContent (never innerHTML): a * record's prose is the judge's text, and a script running on this origin * could call the view's POST routes. Only a span between backticks in prose @@ -126,6 +129,7 @@ export const BOARD_HTML = ` if (k === 'text') node.textContent = attrs[k]; else if (k === 'class') node.className = attrs[k]; else if (k === 'onchange') node.addEventListener('change', attrs[k]); + else if (k === 'onclick') node.addEventListener('click', attrs[k]); else node.setAttribute(k, attrs[k]); }); } @@ -219,7 +223,11 @@ export const BOARD_HTML = ` clear(nav); var page = d.page; var list = page.records || []; - if (list.length === 0) return; + if (list.length > 0) recordLinks(nav, page, list); + nav.appendChild(el('a', { href: '/board?live=1', text: S.live.measureNow })); + } + + function recordLinks(nav, page, list) { var link = function (id, label) { return el('a', { href: '/board?record=' + encodeURIComponent(id), text: label }); }; @@ -700,8 +708,12 @@ export const BOARD_HTML = ` not_found: S.unavailable.notFound, not_json: S.unavailable.notJson, unknown_version: S.unavailable.unknownVersion, not_a_record: S.unavailable.notARecord }; - var quiet = page.why === 'no_board' || page.why === 'no_records'; - setStatus(fill(messages[page.why] || page.why, { record: page.id || '', version: page.version }), !quiet); + // With no board or no record yet, draw what basou measures now instead. + if (page.why === 'no_board' || page.why === 'no_records') { + loadLive(page.why); + return; + } + setStatus(fill(messages[page.why] || page.why, { record: page.id || '', version: page.version }), true); return; } setStatus('', false); @@ -710,17 +722,125 @@ export const BOARD_HTML = ` .forEach(function (node) { root.appendChild(node); }); } - var record = new URLSearchParams(location.search).get('record'); - setStatus('...', false); - fetch('/api/board' + (record ? '/' + encodeURIComponent(record) : '')) - .then(function (res) { + // The board measured now: the sections basou measures with nothing declared + // or judged. why is the reason there was no record to draw, or null when + // asked for with ?live=1. + function liveTile(k, v, sub) { + return el('div', { class: 'tile' }, [ + el('div', { class: 'k', text: k }), el('div', { class: 'v', text: v }), + sub ? el('div', { class: 's', text: sub }) : null + ]); + } + function liveHeading(l, why) { + var h = l.heading; + var measured = h.complete ? S.heading.complete : fill(S.heading.incomplete, { n: h.not_found }); + var again = el('button', { type: 'button', text: S.live.remeasure, onclick: function () { loadLive(why); } }); + return el('header', null, [ + el('h1', { text: h.title }), + el('div', { class: 'stamp' }, [fill(S.live.measuredAt, { at: when(h.measured_at) }) + ' / ' + measured + ' ', again]) + ]); + } + function liveRepos(l) { + var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.behindMain]; + var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: i >= 4 ? 'n' : '', text: label }); })); + var rows = l.repos.map(function (r) { + return el('tr', null, [ + el('td', { text: r.name === null ? r.path : r.name }), + el('td', { text: r.branch === null ? '-' : r.branch }), + el('td', { text: r.head === null ? '-' : r.head.slice(0, 7) }), + el('td', { text: r.last_commit === null ? '-' : when(r.last_commit) }), + el('td', { class: 'n', text: num(r.commits) }), + el('td', { class: 'n', text: num(r.uncommitted) }), + el('td', { class: 'n', text: r.behind_main === null ? '-' : num(r.behind_main) }) + ]); + }); + return section(S.live.repos, false, [el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))])]); + } + function liveTrail(l) { + var t = l.trail; + var i = l.integrity; + var f = l.freshness; + var missing = function (at) { return l.footnotes.not_found.some(function (n) { return n.at === at; }); }; + var newest = f.newest_session_at !== null ? when(f.newest_session_at) + : missing('freshness.newest_session_at') ? S.tiles.notMeasured : S.live.noSession; + var unimported = f.unimported === null ? null : fill(S.live.unimported, { + new: f.unimported.new, updated: f.unimported.updated, unverifiable: f.unimported.unverifiable + }); + var tiles = el('div', { class: 'tiles' }, [ + liveTile(S.live.decisions, num(t.decisions_live), t.decisions_all === null ? null : fill(S.live.decisionsAll, { n: num(t.decisions_all) })), + liveTile(S.tiles.openTracks, t.tracks_open === null ? S.tiles.notMeasured : num(t.tracks_open.length), null), + liveTile(S.tiles.sessions, num(i.sessions), i.not_verified === null ? null : fill(S.tiles.sessionsNotVerified, { n: num(i.not_verified) })), + liveTile(S.live.reviewGaps, num(l.review_gaps.gaps), null), + liveTile(S.live.newestSession, newest, unimported) + ]); + var tracks = t.tracks_open === null ? el('p', { class: 'muted', text: S.tiles.notMeasured }) + : t.tracks_open.length === 0 ? el('p', { class: 'muted', text: S.live.noTracks }) + : el('ul', null, t.tracks_open.map(function (tr) { + return el('li', null, [el('span', { class: 'muted', text: tr.id + ' ' }), tr.title]); + })); + return section(S.live.trail, false, [tiles, el('h3', { text: S.live.tracks }), tracks]); + } + function liveComponents(l) { + var c = l.components; + var body = c === null ? el('p', { class: 'muted', text: S.tiles.notMeasured }) + : c.length === 0 ? el('p', { class: 'muted', text: S.live.noComponents }) + : el('ul', null, c.map(function (x) { + return el('li', null, [el('code', { text: x.key }), el('span', { class: 'muted', text: ' ' + x.kinds.join(', ') })]); + })); + return section(S.live.components, false, [body]); + } + function liveFootnotes(l, why) { + var f = l.footnotes; + var items = [el('li', { text: S.live.note }), el('li', { text: S.live.judged })]; + if (why === 'no_board') items.push(el('li', { text: S.unavailable.noBoard })); + if (f.not_found.length > 0) { + items.push(el('li', null, [el('b', { text: S.footnotes.notMeasured }), el('ul', null, f.not_found.map(function (n) { + return el('li', { text: n.at + ': ' + n.reason }); + }))])); + } + var build = f.measured_with.build === null ? '' : ' (' + f.measured_with.build + ')'; + items.push(el('li', { text: fill(S.footnotes.measuredWith, { basou: f.measured_with.basou, build: build }) })); + return section(S.sections.footnotes, false, [el('ul', { class: 'foot' }, items)]); + } + function drawLive(d, why) { + S = d.strings; + lang = d.language; + document.documentElement.lang = lang; + document.title = S.pageTitle; + var nav = $('records'); + clear(nav); + if (why === null) nav.appendChild(el('a', { href: '/board', text: S.live.toRecords })); + var root = $('board'); + clear(root); + setStatus('', false); + var l = d.live; + [liveHeading(l, why), effort(l), liveRepos(l), liveTrail(l), liveComponents(l), liveFootnotes(l, why)] + .forEach(function (node) { root.appendChild(node); }); + } + + function getJson(url) { + return fetch(url).then(function (res) { return res.json().then(function (body) { if (!res.ok) throw new Error(body && body.error ? body.error : String(res.status)); return body; }); - }) - .then(draw) - .catch(function (err) { setStatus(S ? fill(S.loadFailed, { message: err.message }) : String(err.message), true); }); + }); + } + function failed(err) { setStatus(S ? fill(S.loadFailed, { message: err.message }) : String(err.message), true); } + // Measuring takes seconds, so the page says so until it is done. + function loadLive(why) { + setStatus(S ? S.live.measuring : '...', false); + getJson('/api/board/live').then(function (d) { drawLive(d, why); }).catch(failed); + } + + var params = new URLSearchParams(location.search); + var record = params.get('record'); + if (params.get('live') !== null) { + loadLive(null); + } else { + setStatus('...', false); + getJson('/api/board' + (record ? '/' + encodeURIComponent(record) : '')).then(draw).catch(failed); + } })(); diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index e96e35af..82b85597 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -3,6 +3,8 @@ import type { AddressInfo } from "node:net"; import { basename, join, resolve } from "node:path"; import { type BasouPaths, + type BoardLiveMeasurement, + boardLivePage, boardPage, boardPageStrings, boardPageUnavailable, @@ -88,6 +90,11 @@ export type ViewServerDeps = { remoteUrlOf?: RemoteUrlResolver; /** The board the board page shows: single mode only (the page is not served otherwise). */ board?: ViewBoard; + /** + * Measure the workspace as it is now for the board page, with no record: + * single mode only, like the board. It writes nothing. + */ + boardLive?: () => Promise; }; /** A running view server, with the means to stop it. */ @@ -236,6 +243,7 @@ async function handleGet( deps.nowProvider, remoteUrlOf(deps), boardOf(deps), + boardLiveOf(deps), )) ) { sendError(res, 404, "Not found"); @@ -252,6 +260,7 @@ async function handleGet( deps.nowProvider, remoteUrlOf(deps), boardOf(deps), + boardLiveOf(deps), )) ) { sendError(res, 404, "Not found"); @@ -266,6 +275,11 @@ function boardOf(deps: ViewServerDeps): ViewBoard | undefined { return deps.mode === "single" ? deps.board : undefined; } +/** The measurement the live board route serves: none outside single mode. */ +function boardLiveOf(deps: ViewServerDeps): (() => Promise) | undefined { + return deps.mode === "single" ? deps.boardLive : undefined; +} + /** The configured live remote-URL resolver, or core's default. */ function remoteUrlOf(deps: ViewServerDeps): RemoteUrlResolver { return deps.remoteUrlOf ?? tryRemoteUrl; @@ -308,7 +322,13 @@ async function handleWorkspaceGet( nowProvider: () => Date, resolveRemoteUrl: RemoteUrlResolver, board: ViewBoard | undefined, + live: (() => Promise) | undefined, ): Promise { + // Before the records' route, which would take "live" for a record's id. + if (live !== undefined && sub === "board/live") { + sendJson(res, 200, await boardLiveView(ws, live)); + return true; + } if (board !== undefined) { if (sub === "board") { sendJson(res, 200, await boardView(ws, board, undefined)); @@ -467,6 +487,22 @@ async function boardView( return { language, strings: boardPageStrings(language), page }; } +/** + * What the board page draws of the workspace measured now, with no record and + * nothing judged, with its fixed strings in the workspace's language. + */ +async function boardLiveView( + ws: WorkspaceEntry, + live: () => Promise, +): Promise> { + const language = await resolveViewLanguageFromPaths(ws.paths); + return { + language, + strings: boardPageStrings(language), + live: boardLivePage(ws.label, await live()), + }; +} + /** * Aggregate the per-workspace "current position" for the portfolio landing. * Read-only: it runs NO import, so a stale capture is shown as stale (run a diff --git a/packages/core/src/board/effort.test.ts b/packages/core/src/board/effort.test.ts index 2e9dbaf5..c202d2f0 100644 --- a/packages/core/src/board/effort.test.ts +++ b/packages/core/src/board/effort.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { stringify } from "yaml"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { measureEffort } from "./effort.js"; @@ -74,3 +75,89 @@ describe("measureEffort", () => { expect(notFound).toEqual([]); }); }); + +describe("measureEffort with no declared start", () => { + const SES = (s: string): string => `ses_01HXABCDEF1234567890ABC${s}`; + + // A session that started at `startedAt`, with no events. + async function placeStarted(id: string, startedAt: string): Promise { + const dir = join(paths.sessions, id); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "session.yaml"), + stringify({ + schema_version: "0.1.0", + session: { + id, + label: "fixture", + task_id: null, + workspace_id: "ws_01HXABCDEF1234567890ABCDEF", + source: { kind: "terminal", version: "0.1.0" }, + started_at: startedAt, + status: "completed", + working_directory: "/tmp/fixture", + invocation: { command: "echo", args: [], exit_code: 0 }, + related_files: [], + events_log: "events.jsonl", + }, + }), + ); + await writeFile(join(dir, "events.jsonl"), ""); + } + + const undeclared = () => { + const { start: _declared, ...rest } = input("Asia/Tokyo"); + return rest; + }; + + it("starts on the day the first session started, in the section's time zone", async () => { + await placeStarted(SES("S02"), "2026-09-25T00:00:00Z"); + // 08:30 on 2026-09-21 in Tokyo, though 2026-09-20 in UTC. + await placeStarted(SES("S01"), "2026-09-20T23:30:00Z"); + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-09-21"); + expect(effort.elapsed_days).toBe(14); + expect(effort.daily?.map((d) => d.date)).toHaveLength(15); + expect(effort.daily?.[0]?.date).toBe("2026-09-21"); + expect(notFound).toEqual([]); + }); + + it("starts today when there is no session", async () => { + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-10-05"); + expect(effort.elapsed_days).toBe(0); + expect(effort.daily?.map((d) => d.date)).toEqual(["2026-10-05"]); + expect(notFound).toEqual([]); + }); + + it("starts today, and says so, when the sessions cannot be read", async () => { + await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); + await writeFile(join(paths.sessions, SES("S01"), "session.yaml"), "session: [broken]\n"); + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-10-05"); + expect(effort.active_ms).toEqual({ union: null, claude: null, codex: null }); + expect(notFound.map((n) => n.at)).toEqual([ + "effort.start", + "effort.active_ms", + "effort.output_tokens", + ]); + expect(notFound[0]?.reason).toBe( + "the first session is not known, so the days are counted from today", + ); + }); + + it("names today in UTC as its start when this host's zone has no name, measuring nothing", async () => { + await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); + hostZoneIs(undefined); + const { start: _declared, timeZone: _zone, ...rest } = input("Asia/Tokyo"); + const { effort, notFound } = await measureEffort(rest); + expect(effort.start).toBe("2026-10-05"); + expect(effort.daily).toBeNull(); + expect(notFound.map((n) => n.at)).toEqual(["effort"]); + }); + + it("keeps a declared start, before or after the first session", async () => { + await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); + expect((await measureEffort(input("Asia/Tokyo"))).effort.start).toBe("2026-10-01"); + }); +}); diff --git a/packages/core/src/board/effort.ts b/packages/core/src/board/effort.ts index cfb6d5f0..4c9bfde4 100644 --- a/packages/core/src/board/effort.ts +++ b/packages/core/src/board/effort.ts @@ -60,7 +60,11 @@ export type BoardEffortDay = { * `effort`. */ export type BoardEffort = { - /** The declared start. */ + /** + * The declared start, or, with none declared, the day of the first session + * in the section's time zone (today when there is no session, or the + * sessions cannot be read). + */ start: string; /** The time zone the days are in: the declared one, or this host's, as named by `Intl`. */ time_zone: string | null; @@ -102,7 +106,12 @@ export type BoardEffort = { export type EffortInput = { paths: BasouPaths; now: Date; - start: string; + /** + * The declared start. Without one, the days start on the day of the first + * session, or today when there is none: the work the board counts is the + * work done with an agent, which an adopted repository's history predates. + */ + start?: string | undefined; /** The declared time zone, if any. */ timeZone?: string | undefined; /** The manifest's paths, in its order. */ @@ -121,7 +130,9 @@ export async function measureEffort(input: EffortInput): Promise<{ const unknown = { union: null, claude: null, codex: null }; return { effort: { - start: input.start, + // With no zone there are no days, the first session's included: the + // date is only a label then, and every value is null. + start: input.start ?? input.now.toISOString().slice(0, 10), time_zone: null, elapsed_days: null, active_ms: unknown, @@ -141,8 +152,19 @@ export async function measureEffort(input: EffortInput): Promise<{ } const calendar = new Calendar(zone); const today = calendar.dateOf(input.now.getTime()); - const days = datesFrom(input.start, today); - const elapsedDays = Math.round((utcOf(today) - utcOf(input.start)) / 86_400_000); + const sessions = await readSessions(input.paths, input.now, zone); + let start = input.start; + if (start === undefined) { + start = sessions.ok ? firstDay(sessions.sessions, calendar, today) : today; + if (!sessions.ok) { + notFound.push({ + at: "effort.start", + reason: "the first session is not known, so the days are counted from today", + }); + } + } + const days = datesFrom(start, today); + const elapsedDays = Math.round((utcOf(today) - utcOf(start)) / 86_400_000); // Commits, each repository apart. const commits: Record = {}; @@ -170,7 +192,6 @@ export async function measureEffort(input: EffortInput): Promise<{ commits[path] = total; } - const sessions = await readSessions(input.paths, input.now, zone); let effort: BoardEffort; if (!sessions.ok) { for (const at of ["effort.active_ms", "effort.output_tokens"]) { @@ -178,7 +199,7 @@ export async function measureEffort(input: EffortInput): Promise<{ } const unknown = { union: null, claude: null, codex: null }; effort = { - start: input.start, + start, time_zone: zone, elapsed_days: elapsedDays, active_ms: unknown, @@ -215,7 +236,7 @@ export async function measureEffort(input: EffortInput): Promise<{ } const sum = (values: Iterable) => [...values].reduce((a, b) => a + b, 0); effort = { - start: input.start, + start, time_zone: zone, elapsed_days: elapsedDays, active_ms: { @@ -250,6 +271,23 @@ export function todayIn(declared: string | undefined, now: Date): string | undef return zone === undefined ? undefined : new Calendar(zone).dateOf(now.getTime()); } +// The day the first session started on, never later than today (a clock set +// back is not a day to come); today when there is no session. +function firstDay( + sessions: readonly SessionWorkStats[], + calendar: Calendar, + today: string, +): string { + let first = today; + for (const session of sessions) { + const at = Date.parse(session.startedAt); + if (Number.isNaN(at)) continue; + const day = calendar.dateOf(at); + if (day < first) first = day; + } + return first; +} + /** Whole days from one date to another, both `YYYY-MM-DD`. */ export function daysBetween(from: string, to: string): number { return Math.round((utcOf(to) - utcOf(from)) / 86_400_000); diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 999ecb81..f3c11e81 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -46,6 +46,8 @@ export type { GlobMatcher } from "./glob.js"; export { compileGlob, compileGlobs } from "./glob.js"; export type { BoardIntegrity } from "./integrity.js"; export { BOARD_INTEGRITY_METHOD } from "./integrity.js"; +export type { BoardLiveMeasurement, BoardLivePage, MeasureBoardLiveInput } from "./live.js"; +export { boardLivePage, measureBoardLive } from "./live.js"; export type { BoardMeasurement, BoardMeasureValue, diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts new file mode 100644 index 00000000..a27274de --- /dev/null +++ b/packages/core/src/board/live.test.ts @@ -0,0 +1,186 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stringify } from "yaml"; +import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; +import { boardLivePage, measureBoardLive } from "./live.js"; + +const NULL_CONFIG = process.platform === "win32" ? "\\\\.\\nul" : "/dev/null"; +const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: NULL_CONFIG, GIT_CONFIG_SYSTEM: NULL_CONFIG }; +const NOW = new Date("2026-10-05T03:00:00.000Z"); +const WITH = { basou: "0.0.0-test", build: null }; +const SES = (s: string): string => `ses_01HXABCDEF1234567890ABC${s}`; +// A dry run of an import that finds nothing to import, in place of the one +// the CLI passes, which reads the host's native logs. +const NOTHING_TO_IMPORT = async () => ({ + newSessions: 0, + updatedSessions: 0, + unverifiableSessions: 0, +}); + +let root: string; +let paths: BasouPaths; + +const git = (...args: string[]) => + execFileSync("git", args, { cwd: root, env: GIT_ENV, stdio: "pipe" }).toString(); + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "basou-board-live-"))); + git("-c", "init.defaultBranch=main", "init", "-q"); + git("config", "user.email", "t@e.com"); + git("config", "user.name", "t"); + await writeFile(join(root, ".gitignore"), ".basou/\n"); + await writeFile(join(root, "package.json"), "{}\n"); + await mkdir(join(root, "web")); + await writeFile(join(root, "web", "package.json"), "{}\n"); + git("add", "."); + git("commit", "-q", "-m", "first"); + paths = await ensureBasouDirectory(root); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function placeStarted(id: string, startedAt: string): Promise { + const dir = join(paths.sessions, id); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "session.yaml"), + stringify({ + schema_version: "0.1.0", + session: { + id, + label: "fixture", + task_id: null, + workspace_id: "ws_01HXABCDEF1234567890ABCDEF", + source: { kind: "terminal", version: "0.1.0" }, + started_at: startedAt, + status: "completed", + working_directory: "/tmp/fixture", + invocation: { command: "echo", args: [], exit_code: 0 }, + related_files: [], + events_log: "events.jsonl", + }, + }), + ); + await writeFile(join(dir, "events.jsonl"), ""); +} + +// Every path under a directory, for telling that nothing was written. +async function tree(dir: string): Promise { + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + out.push(path); + if (entry.isDirectory()) out.push(...(await tree(path))); + } + return out.sort(); +} + +const measure = () => + measureBoardLive({ + root, + repos: ["."], + paths, + now: NOW, + measuredWith: WITH, + probeImports: NOTHING_TO_IMPORT, + }); + +describe("measureBoardLive", () => { + it("measures the built-in sections a board needs no declaration for, and nothing else", async () => { + await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + const m = await measure(); + expect(Object.keys(m).sort()).toEqual( + [ + "measured_at", + "measured_with", + "complete", + "not_found", + "methods", + "repos", + "trail", + "integrity", + "review_gaps", + "freshness", + "effort", + "components", + ].sort(), + ); + expect(m.methods).toEqual({ + repos: 1, + trail: 1, + integrity: 1, + review_gaps: 1, + freshness: 1, + effort: 1, + components: 2, + }); + expect(m.not_found).toEqual([]); + expect(m.complete).toBe(true); + expect(m.measured_at).toBe(NOW.toISOString()); + expect(m.repos).toMatchObject([{ path: ".", branch: "main", commits: 1, uncommitted: 0 }]); + expect(m.trail).toEqual({ decisions_all: 0, decisions_live: 0, tracks_open: [] }); + expect(m.freshness).toEqual({ + newest_session_at: "2026-10-01T00:00:00Z", + unimported: { new: 0, updated: 0, unverifiable: 0 }, + }); + // From the day of the first session, in this host's time zone. + expect(m.effort.daily?.[0]?.date).toBe(m.effort.start); + expect(m.effort.time_zone).not.toBeNull(); + const name = basename(root); + expect(m.components).toEqual({ [name]: ["manifest"], [`${name}/web`]: ["manifest"] }); + }); + + it("writes nothing", async () => { + await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + const before = await tree(root); + const status = git("status", "--porcelain"); + await measure(); + expect(await tree(root)).toEqual(before); + expect(git("status", "--porcelain")).toBe(status); + }); + + it("says what it could not measure, and is not complete then", async () => { + const m = await measureBoardLive({ + root, + repos: [".", "../missing"], + paths, + now: NOW, + measuredWith: WITH, + }); + expect(m.complete).toBe(false); + const at = m.not_found.map((n) => n.at); + expect(at).toContain("repos[../missing]"); + // No dry run of an import was given, so the sessions not imported are not known. + expect(at).toContain("freshness.unimported"); + expect(m.components).toBeNull(); + }); +}); + +describe("boardLivePage", () => { + it("lays the measurement out for the page, under the workspace's name", async () => { + await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); + const m = await measure(); + const page = boardLivePage("ws", m); + const name = basename(root); + expect(page.heading).toEqual({ + title: "ws", + measured_at: NOW.toISOString(), + complete: true, + not_found: 0, + }); + expect(page.effort.milestones).toEqual([]); + expect(page.effort.daily?.length).toBe(page.effort.period_days); + expect(page.integrity.sessions).toBe(1); + expect(page.components).toEqual([ + { key: name, kinds: ["manifest"] }, + { key: `${name}/web`, kinds: ["manifest"] }, + ]); + expect(page.repos[0]).not.toHaveProperty("files"); + expect(page.footnotes).toEqual({ not_found: [], measured_with: WITH }); + }); +}); diff --git a/packages/core/src/board/live.ts b/packages/core/src/board/live.ts new file mode 100644 index 00000000..2911a378 --- /dev/null +++ b/packages/core/src/board/live.ts @@ -0,0 +1,215 @@ +import { resolve } from "node:path"; +import type { ReplayWarning } from "../events/event-replay.js"; +import type { BasouPaths } from "../storage/basou-dir.js"; +import type { TaskSkipReason } from "../storage/tasks.js"; +import { + BOARD_COMPONENTS_METHOD, + type BoardComponent, + byCodePoint, + measureComponents, +} from "./components.js"; +import { BOARD_DEFAULT_AT } from "./declaration.js"; +import { BOARD_EFFORT_METHOD, type BoardEffort, measureEffort } from "./effort.js"; +import { + BOARD_FRESHNESS_METHOD, + type BoardFreshness, + type BoardImportProbe, + measureFreshness, +} from "./freshness.js"; +import { BOARD_INTEGRITY_METHOD, type BoardIntegrity, measureIntegrity } from "./integrity.js"; +import type { BoardNotFound } from "./measure.js"; +import { type BoardPageBody, effortOf } from "./page.js"; +import { BOARD_REPOS_METHOD, type BoardRepo, measureRepos } from "./repos.js"; +import { + BOARD_REVIEW_GAPS_METHOD, + type BoardReviewGaps, + measureReviewGaps, +} from "./review-gaps.js"; +import { openRepoScope, type RepoScopeResult } from "./scope.js"; +import { BOARD_TRAIL_METHOD, type BoardTrail, readDecisions, trailSection } from "./trail.js"; + +/** + * What can be measured of a workspace with no board declared: the built-in + * sections of `basou board measure` that need nothing from a declaration, + * measured as that command measures them. The effort starts on the day of + * the first session, in this host's time zone; the components are the ones + * the markers find, held against no registry. There is no axis, no measure + * or ratio of a declaration, no digest and no diff: nothing here is recorded + * or judged. + */ +export type BoardLiveMeasurement = { + measured_at: string; + measured_with: { basou: string; build: string | null }; + complete: boolean; + not_found: BoardNotFound[]; + /** The version of how each section measures, as in a measurement of a declared board. */ + methods: { + repos: number; + trail: number; + integrity: number; + review_gaps: number; + freshness: number; + effort: number; + components: number; + }; + repos: BoardRepo[]; + trail: BoardTrail; + integrity: BoardIntegrity; + review_gaps: BoardReviewGaps; + freshness: BoardFreshness; + effort: BoardEffort; + /** The components found, by key; null, with an entry at `components`, when they were not measured. */ + components: Record | null; +}; + +export type MeasureBoardLiveInput = { + /** The absolute root the manifest's repo paths are relative to. */ + root: string; + /** The paths of the manifest's repos, in its order. */ + repos: readonly string[]; + paths: BasouPaths; + now: Date; + measuredWith: { basou: string; build: string | null }; + onReplayWarning?: (warning: ReplayWarning, sessionId: string) => void; + onTaskSkip?: (taskId: string, reason: TaskSkipReason) => void; + /** A dry run of `basou refresh`, to count the sessions not yet imported. */ + probeImports?: () => Promise; +}; + +/** Measure what a workspace shows with no board declared. Reads, never writes, and sends nothing. */ +export async function measureBoardLive( + input: MeasureBoardLiveInput, +): Promise { + const notFound: BoardNotFound[] = []; + const scopes = new Map>(); + const worktreeOf = (repo: string): Promise => { + let scope = scopes.get(repo); + if (scope === undefined) { + scope = openRepoScope(resolve(input.root, repo), BOARD_DEFAULT_AT); + scopes.set(repo, scope); + } + return scope; + }; + + const repos = await measureRepos(input.repos, input.root, worktreeOf); + notFound.push(...repos.notFound); + const trailed = trailSection(await readDecisions(input)); + notFound.push(...trailed.notFound); + const verified = await measureIntegrity(input.paths); + notFound.push(...verified.notFound); + const reviewed = await measureReviewGaps(input); + notFound.push(...reviewed.notFound); + const current = await measureFreshness(input); + notFound.push(...current.notFound); + const worked = await measureEffort({ + paths: input.paths, + now: input.now, + repos: input.repos, + authorDates: repos.authorDates, + }); + notFound.push(...worked.notFound); + const built = await measureComponents({ + repos: input.repos, + names: new Map(repos.repos.map((repo) => [repo.path, repo.name])), + worktreeOf, + registered: {}, + }); + notFound.push(...built.notFound); + const { found } = built.components; + + return { + measured_at: input.now.toISOString(), + measured_with: input.measuredWith, + complete: notFound.length === 0, + not_found: notFound, + methods: { + repos: BOARD_REPOS_METHOD, + trail: BOARD_TRAIL_METHOD, + integrity: BOARD_INTEGRITY_METHOD, + review_gaps: BOARD_REVIEW_GAPS_METHOD, + freshness: BOARD_FRESHNESS_METHOD, + effort: BOARD_EFFORT_METHOD, + components: BOARD_COMPONENTS_METHOD, + }, + repos: repos.repos, + trail: trailed.trail, + integrity: verified.integrity, + review_gaps: reviewed.reviewGaps, + freshness: current.freshness, + effort: worked.effort, + components: + found === null + ? null + : Object.fromEntries(Object.entries(found).map(([key, c]) => [key, [...c.kinds]])), + }; +} + +/** What the board page draws for a workspace measured now, with no record. */ +export type BoardLivePage = { + heading: { title: string; measured_at: string; complete: boolean; not_found: number }; + effort: BoardPageBody["effort"]; + repos: { + path: string; + name: string | null; + head: string | null; + branch: string | null; + last_commit: string | null; + commits: number | null; + uncommitted: number | null; + behind_main: number | null; + }[]; + trail: BoardTrail; + integrity: BoardIntegrity & { + /** Every session counted in `by_status`, null when it was not measured. */ + sessions: number | null; + }; + review_gaps: BoardReviewGaps; + freshness: BoardFreshness; + /** By key, in code point order; null when they were not measured. */ + components: { key: string; kinds: string[] }[] | null; + footnotes: { + not_found: BoardNotFound[]; + measured_with: { basou: string; build: string | null }; + }; +}; + +/** Lay out a measurement of a workspace with no board for the board page. */ +export function boardLivePage(title: string, m: BoardLiveMeasurement): BoardLivePage { + const byStatus = m.integrity.by_status; + return { + heading: { + title, + measured_at: m.measured_at, + complete: m.complete, + not_found: m.not_found.length, + }, + effort: effortOf(m.effort, []), + repos: m.repos.map((r) => ({ + path: r.path, + name: r.name, + head: r.head, + branch: r.branch, + last_commit: r.last_commit, + commits: r.commits, + uncommitted: r.uncommitted, + behind_main: r.behind_main, + })), + trail: m.trail, + integrity: { + ...m.integrity, + sessions: byStatus === null ? null : Object.values(byStatus).reduce((sum, n) => sum + n, 0), + }, + review_gaps: m.review_gaps, + freshness: m.freshness, + components: + m.components === null + ? null + : Object.keys(m.components) + .sort(byCodePoint) + .map((key) => ({ key, kinds: [...(m.components?.[key] ?? [])] })), + footnotes: { + not_found: m.not_found.map(({ at, reason }) => ({ at, reason })), + measured_with: { basou: m.measured_with.basou, build: m.measured_with.build }, + }, + }; +} diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 00df005b..f7e325dd 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -447,24 +447,7 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { ...(o.value === null ? { previous: previousValue(previous, name) } : {}), })), }, - effort: { - start: m.effort.start, - time_zone: m.effort.time_zone, - elapsed_days: m.effort.elapsed_days, - active_ms: { - union: m.effort.active_ms.union, - claude: m.effort.active_ms.claude, - codex: m.effort.active_ms.codex, - }, - output_tokens: m.effort.output_tokens, - sessions_without_tokens: m.effort.sessions_without_tokens, - commits: - m.effort.commits === null - ? null - : Object.entries(m.effort.commits).map(([repo, n]) => ({ repo, count: n })), - milestones: (d.effort.milestones ?? []).map(({ date, label, ref }) => ({ date, label, ref })), - ...daysOf(m.effort.daily), - }, + effort: effortOf(m.effort, d.effort.milestones ?? []), matrix: { stages, lanes: d.lanes.map((lane) => ({ @@ -578,6 +561,46 @@ type MeasuredDay = { commits: Readonly>; }; +/** The effort section of a measurement, as a record holds it or as basou measured it now. */ +export type MeasuredEffort = { + start: string; + time_zone: string | null; + elapsed_days: number | null; + active_ms: { union: number | null; claude: number | null; codex: number | null }; + output_tokens: number | null; + sessions_without_tokens: number | null; + commits: Readonly> | null; + daily: readonly MeasuredDay[] | null; +}; + +/** + * What the page draws of the period and effort: the measured effort, the + * milestones declared, and the days and weeks derived from its daily rows. + */ +export function effortOf( + effort: MeasuredEffort, + milestones: readonly { date: string; label: string; ref: string }[], +): BoardPageBody["effort"] { + return { + start: effort.start, + time_zone: effort.time_zone, + elapsed_days: effort.elapsed_days, + active_ms: { + union: effort.active_ms.union, + claude: effort.active_ms.claude, + codex: effort.active_ms.codex, + }, + output_tokens: effort.output_tokens, + sessions_without_tokens: effort.sessions_without_tokens, + commits: + effort.commits === null + ? null + : Object.entries(effort.commits).map(([repo, n]) => ({ repo, count: n })), + milestones: milestones.map(({ date, label, ref }) => ({ date, label, ref })), + ...daysOf(effort.daily), + }; +} + // The sum, null when any of it was not measured: a part is not the whole. function sumOf(values: readonly (number | null)[]): number | null { let sum = 0; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 956f0262..5487ca70 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -103,6 +103,8 @@ export type { BoardImportProbe, BoardIntegrity, BoardLastReview, + BoardLiveMeasurement, + BoardLivePage, BoardMeasure, BoardMeasureKind, BoardMeasurement, @@ -129,6 +131,7 @@ export type { BoardValueChange, BoardValueOnly, MeasureBoardInput, + MeasureBoardLiveInput, PreviousOutcome, ReadBoardRecord, } from "./board/index.js"; @@ -152,6 +155,7 @@ export { BOARD_TRAIL_METHOD, BOARD_VERSION, boardDigest, + boardLivePage, boardPage, boardPageUnavailable, buildRecord, @@ -160,6 +164,7 @@ export { diffMeasurements, diffObserved, measureBoard, + measureBoardLive, modelKey, NO_PREVIOUS_RECORDS, orderAnomalies, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 27d3a62d..0572436b 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -958,6 +958,35 @@ export type BoardPageStrings = { unknownVersion: string; notARecord: string; }; + /** The board measured on the spot, with no record: what basou measures, nothing judged. */ + live: { + measureNow: string; + toRecords: string; + measuring: string; + remeasure: string; + measuredAt: string; + repos: string; + repo: string; + branch: string; + head: string; + lastCommit: string; + commits: string; + uncommitted: string; + behindMain: string; + trail: string; + decisions: string; + decisionsAll: string; + reviewGaps: string; + newestSession: string; + noSession: string; + unimported: string; + tracks: string; + noTracks: string; + components: string; + noComponents: string; + note: string; + judged: string; + }; }; const BOARD_PAGE_EN: BoardPageStrings = { @@ -1089,6 +1118,35 @@ const BOARD_PAGE_EN: BoardPageStrings = { "The record {record} is of record_version {version}, which this basou does not draw.", notARecord: "The record {record} is not in the shape of a record.", }, + live: { + measureNow: "Measure now", + toRecords: "Recorded board", + measuring: "Measuring the workspace...", + remeasure: "Measure again", + measuredAt: "Measured {at}, on the spot (not a record)", + repos: "Repositories", + repo: "Repository", + branch: "Branch", + head: "HEAD", + lastCommit: "Last commit", + commits: "Commits", + uncommitted: "Uncommitted", + behindMain: "Behind origin/main", + trail: "Trail", + decisions: "Decisions", + decisionsAll: "{n} in all, voided ones included", + reviewGaps: "Review gaps", + newestSession: "Newest session", + noSession: "no session", + unimported: "not imported: {new} new, {updated} updated, {unverifiable} unverifiable", + tracks: "Open tracks", + noTracks: "No open track.", + components: "Components", + noComponents: "No component was found.", + note: "basou measured these values from this workspace's repos and trail when the page was opened. They are not recorded, and nothing here is judged.", + judged: + "The reach matrix, the lanes and the operator's turns are drawn from a record: declare the board in board/board.yaml, have an agent judge it, and record it with basou board record.", + }, }; const BOARD_PAGE_JA: BoardPageStrings = { @@ -1219,6 +1277,35 @@ const BOARD_PAGE_JA: BoardPageStrings = { unknownVersion: "記録 {record} は record_version {version} で、この basou はこの版を描けない。", notARecord: "記録 {record} は記録の形をしていない。", }, + live: { + measureNow: "いま測る", + toRecords: "記録の盤へ", + measuring: "workspace を測っています...", + remeasure: "測り直す", + measuredAt: "{at} にその場で測った(記録ではない)", + repos: "repo", + repo: "repo", + branch: "branch", + head: "HEAD", + lastCommit: "最終 commit", + commits: "commit 数", + uncommitted: "未 commit", + behindMain: "origin/main からの遅れ", + trail: "証跡", + decisions: "判断", + decisionsAll: "取り消したものを含めて {n}", + reviewGaps: "レビューの抜け", + newestSession: "最新の session", + noSession: "session なし", + unimported: "未取り込み: 新規 {new}・更新 {updated}・確かめられない {unverifiable}", + tracks: "未完トラック", + noTracks: "未完トラックはない。", + components: "構成要素", + noComponents: "見つかった構成要素はない。", + note: "ページを開いたときに、basou がこの workspace の repo と証跡を測った値。記録には残らず、判定も含まない。", + judged: + "到達マトリクス・レーン・operator の手番は、判定の記録から描かれる。board/board.yaml に盤を宣言し、AI に判定させて basou board record で記録すると、ここに出る。", + }, }; /** The fixed strings of the board page for a resolved view language. */ From 3cbddfb13d334bc55b365416b10f7b130600f632 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Thu, 8 Oct 2026 22:37:52 +0900 Subject: [PATCH 2/2] basou: Close the gaps the review of the live board found - Captures of an import's console now run one at a time in the process, whoever starts them: the board page's dry run overlapped the portfolio's (and two portfolio requests, or one and a refresh, already could), and one of them came out wrong. - The page tells a value it could not measure from a null that means so (a detached HEAD, no commit yet, no origin/main), says when the sessions not imported could not be counted, and draws the sessions by basou verify status and the units of work by review-gaps verdict. - With ?live=1 it leads back to the records only when there are some. - The effort's start with no named time zone is documented: today in UTC, only a label, as nothing is measured then. - Tests: "writes nothing" compares each file's size, modification time and content, with the real dry run of an import too; dry runs started at once count the same and leave the console as they found it; the page's script runs with a fake fetch for each way it loads; a session dated after today does not move the start. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++- packages/cli/src/commands/board.test.ts | 87 ++++++++++++ packages/cli/src/commands/view.test.ts | 158 ++++++++++++++++++++- packages/cli/src/lib/board-ui.ts | 44 ++++-- packages/cli/src/lib/provenance-actions.ts | 22 ++- packages/cli/src/lib/view-server.ts | 6 +- packages/core/src/board/effort.test.ts | 8 ++ packages/core/src/board/effort.ts | 5 +- packages/core/src/board/live.test.ts | 31 ++-- packages/core/src/lib/view-strings.ts | 9 ++ 10 files changed, 354 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fb83dc..1c78e05a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ All notable changes to **basou** are recorded here. The project follows portfolio mode. Like every page of `basou view`, it is a local UI and not an API. +### Fixed + +- **`basou view` no longer lets two imports, or dry runs of one, spoil each + other.** An import the view runs, and the dry run that counts the sessions + a refresh would import for a portfolio card, capture the console the + import writes to, which only one can do at a time. Two at once (the + portfolio opened in two tabs, or while a refresh or import runs) could + leave a card saying its freshness could not be checked, a refresh failing + with `Import produced no parseable result`, or the import's JSON result + printed by the view. Every capture now waits its turn, the board page's + measurement included. + ### Internal - **`@basou/core` measures a workspace with no board declared.** @@ -33,8 +45,10 @@ All notable changes to **basou** are recorded here. The project follows need nothing from a declaration, and `boardLivePage` lays the result out for the board page. The effort section's start may be left out: the days then start on the day of the first session, or today when there is none or the - sessions cannot be read (which is said under `not_found`). A declared start - is measured as before. + sessions cannot be read (which is said under `not_found`). With no time zone + declared and none named for this host, the effort is not measured, as + before, and such a start is only a label: today in UTC. A declared start is + measured as before. ## 0.66.0 — 2026-10-08 diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 12d03e33..34d9cd6b 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmod, mkdir, @@ -7,6 +8,7 @@ import { readFile, realpath, rm, + stat, symlink, writeFile, } from "node:fs/promises"; @@ -1329,6 +1331,91 @@ describe("basou board measure and record against the previous record", () => { }); describe("measureLiveBoard: the board page's measurement with no board", () => { + // A Claude Code transcript of the workspace that was never imported, beside + // it where ctx() points the dry run of an import. + async function neverImported(repo: string): Promise { + const projectDir = join(ctx(repo).claudeProjectsDir, repo.replace(/[^a-zA-Z0-9]/g, "-")); + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, "sess-1.jsonl"), + [ + { + type: "user", + timestamp: "2026-10-01T00:00:00.000Z", + cwd: repo, + sessionId: "sess-1", + message: { role: "user", content: [{ type: "text", text: "go" }] }, + }, + { + type: "assistant", + timestamp: "2026-10-01T00:00:01.000Z", + cwd: repo, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] }, + }, + ] + .map((record) => JSON.stringify(record)) + .join("\n"), + ); + } + + // Every path under a directory with, for a file, its size, modification + // time and content. + async function snapshot(dir: string): Promise> { + const out: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + out[path] = "dir"; + Object.assign(out, await snapshot(path)); + } else { + const s = await stat(path, { bigint: true }); + const sha = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + out[path] = `${s.size}:${s.mtimeNs}:${sha}`; + } + } + return out; + } + + it("writes nothing, the dry run of an import included", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await neverImported(repo); + const before = await snapshot(repo); + const m = await measureLiveBoard(repo, ctx(repo)); + expect(m.freshness.unimported).toEqual({ new: 1, updated: 0, unverifiable: 0 }); + expect(m.freshness.newest_session_at).toBeNull(); + expect(await snapshot(repo)).toEqual(before); + }); + + it("counts the same when dry runs of an import are started at once", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await neverImported(repo); + const context = ctx(repo); + const args = { + ctx: { + cwd: repo, + claudeProjectsDir: context.claudeProjectsDir, + codexSessionsDir: context.codexSessionsDir, + }, + paths: basouPaths(repo), + nowIso: NOW.toISOString(), + }; + const log = console.log; + const error = console.error; + const [live, ...probes] = await Promise.all([ + measureLiveBoard(repo, context), + ...[1, 2, 3].map(() => probeStaleness(args)), + ]); + expect(live?.freshness.unimported).toEqual({ new: 1, updated: 0, unverifiable: 0 }); + for (const probe of probes) { + expect(probe).toEqual({ newSessions: 1, updatedSessions: 0, unverifiableSessions: 0 }); + } + // Each capture put the console back as it found it. + expect(console.log).toBe(log); + expect(console.error).toBe(error); + }); + it("measures the repos the manifest declares, with no declaration and no board.yaml", async () => { const repo = await workspace([ { path: ".", visibility: "public" }, diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index c43532b3..b74ed1b3 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1723,6 +1723,7 @@ describe("basou view: the board page", () => { it("draws a board measured on the spot: what basou measured, nothing judged", () => { const names = [ + "missingIn", "liveTile", "liveHeading", "liveRepos", @@ -1743,13 +1744,64 @@ describe("basou view: the board page", () => { const repos = call("liveRepos", LIVE); const cells = repos.find((n) => n.tag === "td").map((c) => c.textContent); + // Nulls that mean so: a detached HEAD, no origin/main, a last commit unread. expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-"]); + // Nulls the measurement could not measure are said to be. + const unmeasured = { + ...LIVE, + repos: [ + LIVE.repos[0], + { + ...LIVE.repos[0], + path: "../gone", + name: null, + head: null, + commits: null, + uncommitted: null, + }, + ], + footnotes: { + ...LIVE.footnotes, + not_found: [ + ...LIVE.footnotes.not_found, + { at: "repos[.].branch", reason: "x" }, + { at: "repos[../gone]", reason: "not a git repository" }, + ], + }, + }; + const rows = call("liveRepos", unmeasured) + .find((n) => n.tag === "tr") + .slice(1) + .map((row) => row.find((n) => n.tag === "td").map((c) => c.textContent)); + expect(rows).toEqual([ + ["app", "not measured", "0123456", "-", "2", "1", "-"], + [ + "../gone", + "not measured", + "not measured", + "not measured", + "not measured", + "not measured", + "not measured", + ], + ]); const trail = call("liveTrail", LIVE); // The newest session was not measured: not "no session". expect(trail.text()).toContain(S.tiles.notMeasured); expect(trail.text()).not.toContain(S.live.noSession); expect(trail.text()).toContain("not imported: 1 new, 0 updated, 0 unverifiable"); + expect(trail.text()).toContain("Sessions by basou verify status: verified 1"); + expect(trail.text()).toContain("Units of work by basou review-gaps verdict: omission 4"); + const unknown = call("liveTrail", { + ...LIVE, + integrity: { by_status: null, not_verified: null, sessions: null }, + review_gaps: { by_verdict: null, gaps: null }, + freshness: { newest_session_at: null, unimported: null }, + }); + expect(unknown.text()).toContain(S.live.unimportedNotMeasured); + expect(unknown.text()).toContain("Sessions by basou verify status: not measured"); + expect(unknown.text()).toContain("Units of work by basou review-gaps verdict: not measured"); // A track's title is text, never markup. expect(trail.find((n) => n.tag === "b")).toEqual([]); expect(trail.text()).toContain("open"); @@ -1771,14 +1823,106 @@ describe("basou view: the board page", () => { expect(call("effort", LIVE).find((n) => n.tag === "figure").length).toBeGreaterThan(0); }); - it("measures the workspace when there is no board or no record, and links to it otherwise", () => { - const draw = lift("draw"); - expect(draw).toContain("page.why === 'no_board' || page.why === 'no_records'"); - expect(draw).toContain("loadLive(page.why);"); - expect(lift("drawLive")).toContain( - "if (why === null) nav.appendChild(el('a', { href: '/board', text: S.live.toRecords }));", + // Run the page's script as a browser would, with a fake DOM and a fake fetch + // that answers each URL from `responses`. + async function runPage(search: string, responses: Record) { + const script = BOARD_HTML.slice( + BOARD_HTML.indexOf(""), ); - expect(lift("records")).toContain("href: '/board?live=1'"); + const byId: Record = {}; + const fetched: string[] = []; + const document = { + createElement: (tag: string) => new Node(tag), + createElementNS: (_ns: string, tag: string) => new Node(tag), + createTextNode: (text: string) => Object.assign(new Node("#text"), { textContent: text }), + getElementById: (id: string) => { + byId[id] ??= new Node("div"); + return byId[id]; + }, + documentElement: { lang: "" }, + title: "", + }; + const fetch = async (url: string) => { + fetched.push(url); + const body = responses[url]; + return { + ok: body !== undefined, + status: body === undefined ? 404 : 200, + json: async () => body ?? { error: "Not found" }, + }; + }; + new Function("document", "location", "fetch", script)(document, { search }, fetch); + for (let i = 0; i < 20; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + // A page that never drew has no navigation at all. + const links = (byId.records?.find((n) => n.tag === "a") ?? []).map((a) => a.attrs.href); + return { fetched, byId, links, document }; + } + + const STRINGS = { language: "en", strings: boardPageStrings("en") }; + const NO_RECORDS = { + ...STRINGS, + page: { status: "unavailable", why: "no_records", records: [] }, + }; + const LIVE_BODY = { ...STRINGS, live: LIVE }; + + it("measures on the spot when there is no board or no record to draw", async () => { + for (const why of ["no_board", "no_records"]) { + const page = await runPage("", { + "/api/board": { ...STRINGS, page: { status: "unavailable", why, records: [] } }, + "/api/board/live": LIVE_BODY, + }); + expect(page.fetched).toEqual(["/api/board", "/api/board/live"]); + const board = page.byId.board as Node; + expect(board.find((n) => n.tag === "h1").map((h) => h.textContent)).toEqual(["Board"]); + expect(board.text().includes(boardPageStrings("en").unavailable.noBoard)).toBe( + why === "no_board", + ); + // Not asked for: there is no record to lead back to. + expect(page.links).toEqual([]); + expect((page.byId.status as Node).textContent).toBe(""); + } + }); + + it("says why a record cannot be drawn, and links to the measurement on the spot", async () => { + const page = await runPage("", { + "/api/board": { + ...STRINGS, + page: { status: "unavailable", why: "records_unreadable", records: [] }, + }, + "/api/board/live": LIVE_BODY, + }); + expect(page.fetched).toEqual(["/api/board"]); + expect((page.byId.status as Node).textContent).toBe( + boardPageStrings("en").unavailable.recordsUnreadable, + ); + expect(page.links).toEqual(["/board?live=1"]); + }); + + it("measures when asked with ?live=1, leading back to the records only when there are some", async () => { + const alone = await runPage("?live=1", { + "/api/board/live": LIVE_BODY, + "/api/board": NO_RECORDS, + }); + expect(alone.fetched).toEqual(["/api/board/live", "/api/board"]); + expect((alone.byId.board as Node).find((n) => n.tag === "h1").length).toBe(1); + expect(alone.links).toEqual([]); + const withRecords = await runPage("?live=1", { + "/api/board/live": LIVE_BODY, + "/api/board": { + ...STRINGS, + page: { + status: "unavailable", + why: "not_json", + records: [{ id: A_REC, at: "2026-10-01T00:00:00.000Z" }], + }, + }, + }); + expect(withRecords.links).toEqual(["/board"]); + // A measurement that fails says so. + const failed = await runPage("?live=1", {}); + expect(failed.fetched).toEqual(["/api/board/live"]); + expect((failed.byId.status as Node).textContent).toBe("Not found"); }); it("builds prose of text nodes, with only a span between backticks as a code element", () => { diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 8ae175ac..44251da6 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -740,18 +740,30 @@ export const BOARD_HTML = ` el('div', { class: 'stamp' }, [fill(S.live.measuredAt, { at: when(h.measured_at) }) + ' / ' + measured + ' ', again]) ]); } + // Whether the measurement says why the value at a place was not measured. + function missingIn(l) { + return function (at) { return l.footnotes.not_found.some(function (n) { return n.at === at; }); }; + } function liveRepos(l) { + var missing = missingIn(l); var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.behindMain]; var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: i >= 4 ? 'n' : '', text: label }); })); var rows = l.repos.map(function (r) { + var at = 'repos[' + r.path + ']'; + // A null the measurement says it could not measure, or one that means + // so (a detached HEAD, no commit yet, no origin/main), drawn as '-'. + var value = function (field, v, show) { + if (v !== null) return show(v); + return missing(at) || missing(at + '.' + field) ? S.tiles.notMeasured : '-'; + }; return el('tr', null, [ el('td', { text: r.name === null ? r.path : r.name }), - el('td', { text: r.branch === null ? '-' : r.branch }), - el('td', { text: r.head === null ? '-' : r.head.slice(0, 7) }), - el('td', { text: r.last_commit === null ? '-' : when(r.last_commit) }), + el('td', { text: value('branch', r.branch, String) }), + el('td', { text: value('head', r.head, function (h) { return h.slice(0, 7); }) }), + el('td', { text: value('last_commit', r.last_commit, when) }), el('td', { class: 'n', text: num(r.commits) }), el('td', { class: 'n', text: num(r.uncommitted) }), - el('td', { class: 'n', text: r.behind_main === null ? '-' : num(r.behind_main) }) + el('td', { class: 'n', text: value('behind_main', r.behind_main, num) }) ]); }); return section(S.live.repos, false, [el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))])]); @@ -760,12 +772,16 @@ export const BOARD_HTML = ` var t = l.trail; var i = l.integrity; var f = l.freshness; - var missing = function (at) { return l.footnotes.not_found.some(function (n) { return n.at === at; }); }; + var missing = missingIn(l); var newest = f.newest_session_at !== null ? when(f.newest_session_at) : missing('freshness.newest_session_at') ? S.tiles.notMeasured : S.live.noSession; - var unimported = f.unimported === null ? null : fill(S.live.unimported, { + var unimported = f.unimported === null ? S.live.unimportedNotMeasured : fill(S.live.unimported, { new: f.unimported.new, updated: f.unimported.updated, unverifiable: f.unimported.unverifiable }); + var counts = function (byKey) { + if (byKey === null) return S.tiles.notMeasured; + return Object.keys(byKey).map(function (k) { return k + ' ' + num(byKey[k]); }).join(' / '); + }; var tiles = el('div', { class: 'tiles' }, [ liveTile(S.live.decisions, num(t.decisions_live), t.decisions_all === null ? null : fill(S.live.decisionsAll, { n: num(t.decisions_all) })), liveTile(S.tiles.openTracks, t.tracks_open === null ? S.tiles.notMeasured : num(t.tracks_open.length), null), @@ -778,7 +794,11 @@ export const BOARD_HTML = ` : el('ul', null, t.tracks_open.map(function (tr) { return el('li', null, [el('span', { class: 'muted', text: tr.id + ' ' }), tr.title]); })); - return section(S.live.trail, false, [tiles, el('h3', { text: S.live.tracks }), tracks]); + var breakdown = el('div', { class: 'muted' }, [ + el('div', { text: fill(S.live.byStatus, { list: counts(i.by_status) }) }), + el('div', { text: fill(S.live.byVerdict, { list: counts(l.review_gaps.by_verdict) }) }) + ]); + return section(S.live.trail, false, [tiles, breakdown, el('h3', { text: S.live.tracks }), tracks]); } function liveComponents(l) { var c = l.components; @@ -802,14 +822,22 @@ export const BOARD_HTML = ` items.push(el('li', { text: fill(S.footnotes.measuredWith, { basou: f.measured_with.basou, build: build }) })); return section(S.sections.footnotes, false, [el('ul', { class: 'foot' }, items)]); } + var liveDrawn = 0; function drawLive(d, why) { S = d.strings; lang = d.language; document.documentElement.lang = lang; document.title = S.pageTitle; + var drawn = ++liveDrawn; var nav = $('records'); clear(nav); - if (why === null) nav.appendChild(el('a', { href: '/board', text: S.live.toRecords })); + // Asked for with ?live=1: lead back to the records, when there are any. + if (why === null) { + getJson('/api/board').then(function (b) { + var list = b && b.page && b.page.records ? b.page.records : []; + if (drawn === liveDrawn && list.length > 0) nav.appendChild(el('a', { href: '/board', text: S.live.toRecords })); + }).catch(function () {}); + } var root = $('board'); clear(root); setStatus('', false); diff --git a/packages/cli/src/lib/provenance-actions.ts b/packages/cli/src/lib/provenance-actions.ts index a5ce7e8e..7675dd9a 100644 --- a/packages/cli/src/lib/provenance-actions.ts +++ b/packages/cli/src/lib/provenance-actions.ts @@ -100,13 +100,27 @@ export type RefreshActionOptions = { dryRun?: boolean; }; +// The capture swaps the process-global console, so two at once take each +// other's output and the one that finishes last puts back the other's +// stand-in. Captures therefore run one at a time, in the order they are asked +// for, whoever asks: a dry run for a portfolio card or the board page, or an +// import or refresh the view runs. +let capturing: Promise = Promise.resolve(); + /** * Run `fn` (an import command invoked with `json: true`) while capturing its * console output, then return the single machine-readable JSON result line. * The import commands print their result as one JSON object on stdout; that * line is the result contract. `console` is always restored, even on throw. + * Captures in this process run one at a time. */ -async function captureImportJson(fn: () => Promise): Promise> { +function captureImportJson(fn: () => Promise): Promise> { + const turn = capturing.then(() => captureNow(fn)); + capturing = turn.catch(() => undefined); + return turn; +} + +async function captureNow(fn: () => Promise): Promise> { const stdout: string[] = []; const originalLog = console.log; const originalError = console.error; @@ -377,9 +391,9 @@ export type StalenessProbe = { * Returns `null` if the probe could not run (the caller renders "can't confirm" * rather than a false "current"). * - * NOTE: the import capture swaps the process-global console and is NOT - * reentrant, so callers must never run two probes concurrently (e.g. the - * portfolio runs them serially). + * The import capture swaps the process-global console, so probes, like every + * capture, run one at a time (see captureImportJson): callers may start them + * at once. */ export async function probeStaleness(args: { ctx: ImportContext; diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index 82b85597..0a235e9a 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -517,9 +517,9 @@ async function portfolio(deps: ViewServerDeps): Promise> const workspaces = await Promise.all(deps.workspaces.map((ws) => portfolioCard(ws, nowIso))); // Each staleness probe runs a dry-run import, which swaps the process-global // console to capture output (see captureImportJson). That swap is NOT - // reentrant, so the probes must run ONE AT A TIME — running them inside the - // parallel map above let them clobber each other's capture and most failed. - // The summaries above are pure reads and stay parallel; only this loop serializes. + // reentrant: captures run one at a time whoever starts them (the board page + // and the imports included), and this loop also runs the cards' probes in + // turn. The summaries above are pure reads and stay parallel. for (let i = 0; i < deps.workspaces.length; i++) { const card = workspaces[i]; const ws = deps.workspaces[i]; diff --git a/packages/core/src/board/effort.test.ts b/packages/core/src/board/effort.test.ts index c202d2f0..0bb72dc4 100644 --- a/packages/core/src/board/effort.test.ts +++ b/packages/core/src/board/effort.test.ts @@ -156,6 +156,14 @@ describe("measureEffort with no declared start", () => { expect(notFound.map((n) => n.at)).toEqual(["effort"]); }); + it("never starts after today, whatever a session's clock said", async () => { + await placeStarted(SES("S01"), "2026-12-01T00:00:00Z"); + const { effort, notFound } = await measureEffort(undeclared()); + expect(effort.start).toBe("2026-10-05"); + expect(effort.daily?.map((d) => d.date)).toEqual(["2026-10-05"]); + expect(notFound).toEqual([]); + }); + it("keeps a declared start, before or after the first session", async () => { await placeStarted(SES("S01"), "2026-09-20T00:00:00Z"); expect((await measureEffort(input("Asia/Tokyo"))).effort.start).toBe("2026-10-01"); diff --git a/packages/core/src/board/effort.ts b/packages/core/src/board/effort.ts index 4c9bfde4..6bf22b08 100644 --- a/packages/core/src/board/effort.ts +++ b/packages/core/src/board/effort.ts @@ -63,7 +63,9 @@ export type BoardEffort = { /** * The declared start, or, with none declared, the day of the first session * in the section's time zone (today when there is no session, or the - * sessions cannot be read). + * sessions cannot be read). With neither a declared zone nor a name for + * this host's, there are no days to count, and an undeclared start is only + * a label: today in UTC. */ start: string; /** The time zone the days are in: the declared one, or this host's, as named by `Intl`. */ @@ -110,6 +112,7 @@ export type EffortInput = { * The declared start. Without one, the days start on the day of the first * session, or today when there is none: the work the board counts is the * work done with an agent, which an adopted repository's history predates. + * When the days cannot be counted (no zone can be named), it is today in UTC. */ start?: string | undefined; /** The declared time zone, if any. */ diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts index a27274de..03643436 100644 --- a/packages/core/src/board/live.test.ts +++ b/packages/core/src/board/live.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -69,15 +70,24 @@ async function placeStarted(id: string, startedAt: string): Promise { await writeFile(join(dir, "events.jsonl"), ""); } -// Every path under a directory, for telling that nothing was written. -async function tree(dir: string): Promise { - const out: string[] = []; +// Every path under a directory with, for a file, its size, modification time +// and content, for telling that nothing was written, created or touched. +async function snapshot(dir: string): Promise> { + const out: Record = {}; for (const entry of await readdir(dir, { withFileTypes: true })) { const path = join(dir, entry.name); - out.push(path); - if (entry.isDirectory()) out.push(...(await tree(path))); + if (entry.isDirectory()) { + out[path] = "dir"; + Object.assign(out, await snapshot(path)); + } else { + const s = await stat(path, { bigint: true }); + const sha = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + out[path] = `${s.size}:${s.mtimeNs}:${sha}`; + } } - return out.sort(); + return out; } const measure = () => @@ -137,10 +147,13 @@ describe("measureBoardLive", () => { it("writes nothing", async () => { await placeStarted(SES("S01"), "2026-10-01T00:00:00Z"); - const before = await tree(root); + // Before the snapshot: git status itself may refresh the index. const status = git("status", "--porcelain"); + const before = await snapshot(root); await measure(); - expect(await tree(root)).toEqual(before); + // Every file of the repository, its .git and its .basou/ included. + expect(await snapshot(root)).toEqual(before); + expect(Object.keys(before).some((p) => p.endsWith(join(".git", "index")))).toBe(true); expect(git("status", "--porcelain")).toBe(status); }); diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 0572436b..67095498 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -980,6 +980,9 @@ export type BoardPageStrings = { newestSession: string; noSession: string; unimported: string; + unimportedNotMeasured: string; + byStatus: string; + byVerdict: string; tracks: string; noTracks: string; components: string; @@ -1139,6 +1142,9 @@ const BOARD_PAGE_EN: BoardPageStrings = { newestSession: "Newest session", noSession: "no session", unimported: "not imported: {new} new, {updated} updated, {unverifiable} unverifiable", + unimportedNotMeasured: "sessions not imported: not measured", + byStatus: "Sessions by basou verify status: {list}", + byVerdict: "Units of work by basou review-gaps verdict: {list}", tracks: "Open tracks", noTracks: "No open track.", components: "Components", @@ -1298,6 +1304,9 @@ const BOARD_PAGE_JA: BoardPageStrings = { newestSession: "最新の session", noSession: "session なし", unimported: "未取り込み: 新規 {new}・更新 {updated}・確かめられない {unverifiable}", + unimportedNotMeasured: "未取り込み: 測れなかった", + byStatus: "basou verify の状態ごとの session: {list}", + byVerdict: "basou review-gaps の判定ごとの作業の単位: {list}", tracks: "未完トラック", noTracks: "未完トラックはない。", components: "構成要素",