Public: anyone can read this; the crew can change it.
A pure read counts the issues the crew parked in the last 24 hours and 7 days, and names the newest three #6486
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6471-a-pure-read-counts-the-issues-the-crew-p → main 4 commits fork 3 threads · 3 unresolved
6 files
changed +194
−0
README.md +1
| @@ -590,6 +590,7 @@ work here. | ||
| 590 | 590 | A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap |
| 591 | 591 | (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`, and each panel's data as JSON or CSV at `GET /api/v1/boards/:slug/panels/:id?from=&to=&step=5m&format=csv`, bucketed in SQL to the step and never cut short (`docs/APPS.md`); each panel's card links `json` and `csv` (a plain download link) beside `explore` |
| 592 | 592 | (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one, and with a `panel` too that panel's data, with the same `from`, `to` and `step`) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text, each with what its data holds now (a time panel's last bucket and step, another's row count). No dependency was added. |
| 593 | - **Parked work** — the terminal's Flow dialog says how many issues the crew parked (an open issue whose newest comment is the crew's `Parked by the crew` note) in the last 24 h and 7 d, and names the newest three; the MCP `flow` tool returns the same as `parked`. See `docs/TOUR.md`. | |
| 593 | 594 | - **Issues** — labels, threaded comments, close/reopen, markdown. |
| 594 | 595 | - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax |
| 595 | 596 | highlighting composed with intra-line word highlighting, mobile-first, |
docs/TOUR.md +2
| @@ -76,6 +76,8 @@ with the same lines, worded by the same helpers (`src/core/client-apps.ts`). | ||
| 76 | 76 | |
| 77 | 77 | Under the figures, an admin's home, the terminal's dashboard and its Flow dialog carry one vitals line (#5935, `src/flow/vitals.ts`): open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets; the autonomy tick line says the same as `pulls` (and `offTarget` when a target is missed). The MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `vitals: { says, misses, … }`; the tool's description is unchanged because `tools/list` is at its size ceiling, so an agent finds `vitals` in the result, not the listing. An agent reader's counts cover public repositories only. |
| 78 | 78 | |
| 79 | Below it, one parked line (#6471, `src/flow/parked.ts`): the open issues whose newest comment is the crew's `Parked by the crew` note, counted by that note's time over the last 24 h and 7 d, with the newest three named. The terminal's Flow dialog shows it, and the MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `parked: { says, day, week, newest }`; an agent reader's counts cover public repositories only. | |
| 80 | ||
| 79 | 81 | Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with |
| 80 | 82 | a 24 h sparkline), the live version and since when, the median check time, the crew builders |
| 81 | 83 | running of their slots, and the crew's spend today of the forge's daily |
src/flow/parked.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * The parked count (#6471): open issues the crew tried and could not finish. | |
| 3 | * An issue is parked when its newest comment starts with the park note, and | |
| 4 | * it counts by that comment's time, and only the crew's note is one (`BY_CREW`): any later word, the crew's or a | |
| 5 | * person's, takes it off. `newest` names the three most recently parked. | |
| 6 | * `publicOnly` is `readVitals`'s: public repositories alone, and not their | |
| 7 | * sealed issues (security reports, seen only by writers, the crew and the reporter). | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { BY_CREW, LATER, PARK_LIKE } from "../core/done_on_main.ts"; | |
| 12 | ||
| 13 | const DAY_MS = 86_400_000; | |
| 14 | const NAMED = 3; | |
| 15 | ||
| 16 | export interface Parked { | |
| 17 | /** Parked within the last 24 hours. */ | |
| 18 | readonly day: number; | |
| 19 | /** Parked within the last 7 days (the day's included). */ | |
| 20 | readonly week: number; | |
| 21 | readonly newest: readonly { repo: string; issue: number; title: string }[]; | |
| 22 | } | |
| 23 | ||
| 24 | interface Row { | |
| 25 | readonly owner: string; | |
| 26 | readonly name: string; | |
| 27 | readonly number: number; | |
| 28 | readonly title: string; | |
| 29 | readonly at: string; | |
| 30 | } | |
| 31 | ||
| 32 | export function readParked(db: Database, now = new Date(), publicOnly = false): Parked { | |
| 33 | const rows = db | |
| 34 | .query<Row, [number, string]>( | |
| 35 | `SELECT u.username AS owner, r.name AS name, i.number AS number, i.title AS title, n.created_at AS at | |
| 36 | FROM issue i | |
| 37 | JOIN repo r ON r.id = i.repo_id | |
| 38 | JOIN user u ON u.id = r.owner_id | |
| 39 | JOIN comment n ON n.issue_id = i.id | |
| 40 | WHERE i.kind = 'issue' AND i.state = 'open' AND (?1 = 0 OR (r.visibility = 'public' AND i.sealed = 0)) | |
| 41 | AND n.body LIKE '${PARK_LIKE}' AND ${BY_CREW} AND n.created_at >= ?2 | |
Non-blocking: nothing indexes |
||
| 42 | AND NOT EXISTS (SELECT 1 FROM comment l WHERE l.issue_id = i.id AND ${LATER("l", "n")}) | |
| 43 | ORDER BY n.created_at DESC, n.id DESC`, | |
| 44 | ) | |
| 45 | .all(publicOnly ? 1 : 0, new Date(now.getTime() - 7 * DAY_MS).toISOString()); | |
| 46 | const dayFrom = now.getTime() - DAY_MS; | |
| 47 | return { | |
| 48 | day: rows.filter((r) => Date.parse(r.at) >= dayFrom).length, | |
| 49 | week: rows.length, | |
| 50 | newest: rows.slice(0, NAMED).map((r) => ({ repo: `${r.owner}/${r.name}`, issue: r.number, title: r.title })), | |
| 51 | }; | |
| 52 | } | |
| 53 | ||
| 54 | /** The one line for the Flow dialog and the MCP `flow` tool: `3 parked in 24 h, 5 in 7 d; newest owner/repo#12 Title`. */ | |
| 55 | export function parkedSays(p: Parked): string { | |
| 56 | if (p.week === 0) return "Nothing parked by the crew in the last 7 days."; | |
| 57 | const named = p.newest.map((n) => `${n.repo}#${n.issue} ${n.title}`).join("; "); | |
| 58 | return `${p.day} parked in 24 h, ${p.week} in 7 d; newest ${named}`; | |
| 59 | } |
src/system/telemetry.ts +4
| @@ -10,6 +10,7 @@ import { SOURCES } from "../boards/sources.ts"; | ||
| 10 | 10 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts"; |
| 11 | 11 | import { firedAlarms } from "../flow/alarms.ts"; |
| 12 | 12 | import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; |
| 13 | import { parkedSays, readParked } from "../flow/parked.ts"; | |
| 13 | 14 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 14 | 15 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| 15 | 16 | import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts"; |
| @@ -33,8 +34,11 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 33 | 34 | const requested = requestedWaits(app.db, reader.kind === "agent"); |
| 34 | 35 | const busy = reviewersBusy(app.db, reader.kind === "agent"); |
| 35 | 36 | const vitals = readVitals(app.db, now, reader.kind === "agent"); |
| 37 | const parked = readParked(app.db, now, reader.kind === "agent"); | |
| 36 | 38 | return { |
| 37 | 39 | says: verdict(stages, now.getTime()), |
| 40 | // Issues the crew parked in the last 24 h and 7 d, the newest three named (#6471). | |
| 41 | parked: { says: parkedSays(parked), ...parked }, | |
| 38 | 42 | // Open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets (#5935). |
| 39 | 43 | vitals: { says: vitalsLine(vitals), misses: vitalsMisses(vitals), ...vitals }, |
| 40 | 44 | // The four alarms (#5930) that are firing now; empty when none is. |
src/tui/screens/flow.ts +3
| @@ -18,6 +18,7 @@ import { HOLDS } from "../../crew/held.ts"; | ||
| 18 | 18 | import type { HeldRow } from "../../flow/held.ts"; |
| 19 | 19 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 20 | 20 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 21 | import { parkedSays, readParked } from "../../flow/parked.ts"; | |
| 21 | 22 | import { firedAlarms } from "../../flow/alarms.ts"; |
| 22 | 23 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; |
| 23 | 24 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| @@ -66,6 +67,8 @@ export function flowLines( | ||
| 66 | 67 | ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner), |
| 67 | 68 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)), |
| 68 | 69 | "", |
| 70 | ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner), | |
| 71 | "", | |
| 69 | 72 | ...wrap(throughputSays(flow), inner), |
| 70 | 73 | "", |
| 71 | 74 | `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`, |
test/flow-parked.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** The parked count (#6471): by the park note's time, only while it is the newest word on an open issue. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { id } from "../src/kernel/id.ts"; | |
| 10 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 11 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 12 | import { parkedSays, readParked } from "../src/flow/parked.ts"; | |
| 13 | ||
| 14 | const NOW = new Date(); | |
| 15 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 16 | ||
| 17 | async function world() { | |
| 18 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-parked-")), port: 0 }); | |
| 19 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 20 | if (!made.ok) throw new Error(made.error); | |
| 21 | const alice = made.value; | |
| 22 | const repo = async (name: string, visibility: "public" | "private") => { | |
| 23 | const r = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name, visibility, description: name }); | |
| 24 | if (!r.ok) throw new Error(r.error); | |
| 25 | // The person who set the crew going speaks for it (`BY_CREW`). | |
| 26 | app.db.query("UPDATE repo SET autonomy_by = ?1 WHERE id = ?2").run(alice.id, r.value.id); | |
| 27 | return r.value; | |
| 28 | }; | |
| 29 | let n = 0; | |
| 30 | const issue = (repoId: string, title: string, state = "open") => { | |
| 31 | const issueId = id(); | |
| 32 | n++; | |
| 33 | app.db | |
| 34 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, 'issue', ?4, '', ?5, ?6)`) | |
| 35 | .run(issueId, repoId, n, title, alice.id, state); | |
| 36 | return issueId; | |
| 37 | }; | |
| 38 | const say = (issueId: string, body: string, at: string) => | |
| 39 | app.db.query(`INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)`).run(id(), issueId, alice.id, body, at); | |
| 40 | const mallory = async () => { | |
| 41 | const m = await createUser(app.db, { username: "mallory", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true }); | |
| 42 | if (!m.ok) throw new Error(m.error); | |
| 43 | return m.value; | |
| 44 | }; | |
| 45 | const park = (issueId: string, at: string) => say(issueId, "Parked by the crew: could not finish.", at); | |
| 46 | return { app, alice, repo, issue, say, park, mallory }; | |
| 47 | } | |
| 48 | ||
| 49 | describe("flow parked", () => { | |
| 50 | test("counts by the park note's time; closed and spoken-after issues are left out", async () => { | |
| 51 | const w = await world(); | |
| 52 | const r = await w.repo("p", "public"); | |
| 53 | expect(readParked(w.app.db, NOW)).toEqual({ day: 0, week: 0, newest: [] }); | |
| 54 | w.park(w.issue(r.id, "today"), ago(2)); | |
| 55 | w.park(w.issue(r.id, "three days"), ago(72)); | |
| 56 | w.park(w.issue(r.id, "ten days"), ago(240)); | |
| 57 | w.park(w.issue(r.id, "closed", "closed"), ago(1)); | |
| 58 | const spoken = w.issue(r.id, "spoken after"); | |
| 59 | w.park(spoken, ago(5)); | |
| 60 | w.say(spoken, "Try it smaller.", ago(3)); | |
| 61 | const got = readParked(w.app.db, NOW); | |
| 62 | expect(got.day).toBe(1); | |
| 63 | expect(got.week).toBe(2); | |
| 64 | expect(got.newest.map((x) => x.title)).toEqual(["today", "three days"]); | |
| 65 | expect(got.newest[0]).toEqual({ repo: "alice/p", issue: 1, title: "today" }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a park after a person's word counts; newest holds at most three", async () => { | |
| 69 | const w = await world(); | |
| 70 | const r = await w.repo("p", "public"); | |
| 71 | const again = w.issue(r.id, "again"); | |
| 72 | w.park(again, ago(30)); | |
| 73 | w.say(again, "Try this.", ago(20)); | |
| 74 | w.park(again, ago(10)); | |
| 75 | for (const h of [1, 2, 3, 4]) w.park(w.issue(r.id, `i${h}`), ago(h)); | |
| 76 | const got = readParked(w.app.db, NOW); | |
| 77 | expect(got.week).toBe(5); | |
| 78 | expect(got.newest.map((x) => x.title)).toEqual(["i1", "i2", "i3"]); | |
| 79 | }); | |
| 80 | ||
| 81 | test("publicOnly hides a private repository's issue", async () => { | |
| 82 | const w = await world(); | |
| 83 | const pub = await w.repo("pub", "public"); | |
| 84 | const priv = await w.repo("priv", "private"); | |
| 85 | w.park(w.issue(pub.id, "open one"), ago(4)); | |
| 86 | w.park(w.issue(priv.id, "secret"), ago(1)); | |
| 87 | expect(readParked(w.app.db, NOW).week).toBe(2); | |
| 88 | const only = readParked(w.app.db, NOW, true); | |
| 89 | expect(only.week).toBe(1); | |
| 90 | expect(only.newest.map((x) => x.title)).toEqual(["open one"]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a non-crew commenter's park note is not a park", async () => { | |
| 94 | const w = await world(); | |
| 95 | const r = await w.repo("p", "public"); | |
| 96 | const m = await w.mallory(); | |
| 97 | const issueId = w.issue(r.id, "faked"); | |
| 98 | w.app.db | |
| 99 | .query(`INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)`) | |
| 100 | .run(id(), issueId, m.id, "Parked by the crew: not really.", ago(1)); | |
| 101 | expect(readParked(w.app.db, NOW)).toEqual({ day: 0, week: 0, newest: [] }); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the Flow dialog and the MCP flow reading say the same line", async () => { | |
| 105 | const w = await world(); | |
| 106 | const r = await w.repo("p", "public"); | |
| 107 | expect(flowLines(w.app.db, NOW, []).join(" ")).toContain("Nothing parked by the crew"); | |
| 108 | w.park(w.issue(r.id, "stuck"), ago(2)); | |
| 109 | const says = parkedSays(readParked(w.app.db, NOW)); | |
| 110 | expect(says).toBe("1 parked in 24 h, 1 in 7 d; newest alice/p#1 stuck"); | |
| 111 | expect(flowLines(w.app.db, NOW, []).join(" ")).toContain("1 parked in 24 h, 1 in 7 d;"); | |
| 112 | const f = flowFor(w.app, { kind: "owner" }, NOW); | |
| 113 | expect(f.parked).toMatchObject({ says, day: 1, week: 1 }); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a sealed parked issue on a public repository is the owner's, not an agent's", async () => { | |
| 117 | const w = await world(); | |
| 118 | const r = await w.repo("p", "public"); | |
| 119 | const sealed = w.issue(r.id, "security report"); | |
| 120 | w.app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(sealed); | |
| 121 | w.park(sealed, ago(1)); | |
| 122 | expect(readParked(w.app.db, NOW).week).toBe(1); | |
| 123 | expect(readParked(w.app.db, NOW, true)).toEqual({ day: 0, week: 0, newest: [] }); | |
| 124 | }); | |
| 125 | }); |
new above
offline
High: sealed issues (security reports on public repos) pass
publicOnly, and their titles go out innewestto a granted agent. AddAND (?1 = 0 OR i.sealed = 0)here, plus a test with a sealed parked issue that is counted for the owner and hidden withpublicOnly.