diff --git a/nixos/services/pm-stack/default.nix b/nixos/services/pm-stack/default.nix index d50d48047..c03d7a6ae 100644 --- a/nixos/services/pm-stack/default.nix +++ b/nixos/services/pm-stack/default.nix @@ -139,6 +139,11 @@ in Environment = [ "PM_PROJECTOR_URL=http://127.0.0.1:${toString cfg.projectorPort}" "PM_AGG_COVERAGE_SNAPSHOT_FILE=${aggState}/coverage-snapshot.json" + # Roadmap annotations (#1284) are runtime state too: the + # first annotation would otherwise create + # roadmap-annotations.json inside this checkout and block + # pm-git-sync the same way (Codex P1 on PR #4054). + "PM_AGG_ROADMAP_ANNOTATIONS_FILE=${aggState}/roadmap-annotations.json" ]; Restart = "on-failure"; RestartSec = 5; diff --git a/pm-stack/aggregator/src/config.ts b/pm-stack/aggregator/src/config.ts index bfd06fc86..0b16a4564 100644 --- a/pm-stack/aggregator/src/config.ts +++ b/pm-stack/aggregator/src/config.ts @@ -15,6 +15,8 @@ export interface Config { feedFile: string; /** Dashboard comments store (#906 §2) — the aggregator's own writable data. */ commentsFile: string; + /** Append-only roadmap annotation store (#1284), separate from comments. */ + roadmapAnnotationsFile: string; /** Dashboard actions store (#1152/#1154/#1156) — triage write path. */ actionsFile: string; /** Durable identity-registry store (#906 §3), merged on every read. */ @@ -83,6 +85,7 @@ export function loadConfig(env: Record = process.env .filter(Boolean), feedFile: s(env.PM_AGG_FEED_FILE, join(process.cwd(), "fleet-feed.json")), commentsFile: s(env.PM_AGG_COMMENTS_FILE, join(process.cwd(), "comments.json")), + roadmapAnnotationsFile: s(env.PM_AGG_ROADMAP_ANNOTATIONS_FILE, join(process.cwd(), "roadmap-annotations.json")), actionsFile: s(env.PM_AGG_ACTIONS_FILE, join(process.cwd(), "actions.json")), registryFile: s(env.PM_AGG_REGISTRY_FILE, join(process.cwd(), "registry.json")), snapshotsDir: s(env.PM_AGG_SNAPSHOTS_DIR, join(process.cwd(), "snapshots")), diff --git a/pm-stack/aggregator/src/render.ts b/pm-stack/aggregator/src/render.ts index cc080ceab..fac071512 100644 --- a/pm-stack/aggregator/src/render.ts +++ b/pm-stack/aggregator/src/render.ts @@ -21,6 +21,7 @@ import { windowPercent, } from "./derive.ts"; import type { RoadmapReconciliation } from "./roadmap-reconcile.ts"; +import type { RoadmapAnnotation } from "./roadmap-annotations.ts"; import { diffCoverage, type CoverageSnapshotFile } from "./coverage.ts"; import { registryQueryString, type RegistryView } from "./registry-view.ts"; import { latestInFamily, newClientToken, type DashAction } from "./actions.ts"; @@ -192,6 +193,12 @@ meter { width: 140px; height: 14px; } .cform { border: 1px solid #30363d; border-radius: 8px; padding: 10px 12px; margin: 10px 0; background: #0d1117; } .cform label { display: block; font-size: 12px; color: #8b949e; margin: 8px 0 3px; } .cform textarea, .cform select, .cform input[type="text"] { width: 100%; background: #161b22; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 7px 9px; font: inherit; } +.raform { border: 1px solid #30363d; border-radius: 8px; padding: 10px 12px; margin: 10px 0; background: #0d1117; } +.raform label { display: block; font-size: 12px; color: #8b949e; margin: 8px 0 3px; } +.raform textarea, .raform select, .raform input[type="date"] { width: 100%; background: #161b22; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 7px 9px; font: inherit; } +.raform button { margin-top: 10px; background: #1f6feb; color: #fff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; cursor: pointer; } +.raform .err { color: #f85149; font-size: 12px; margin-top: 6px; } +.roadmap-annotation { border-left: 3px solid #bc8cff; padding: 5px 8px; margin: 5px 0; background: #161b22; border-radius: 0 6px 6px 0; overflow-wrap: anywhere; } .cform .kinds { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 4px; } .cform .kinds label { display: inline-flex; gap: 5px; align-items: center; margin: 0; font-size: 13px; color: #e6edf3; } .cform button { margin-top: 10px; background: #1f6feb; color: #fff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; cursor: pointer; } @@ -349,7 +356,7 @@ body.xp-plan .xpl-costcol { display: none; } .chart .muted { color: #8b949e; } .chart .xpl-ctl button, .chart .xpl-ctl select { background: #161b22; color: #e6edf3; border-color: #30363d; } .chart .xpl-ctl button.active { background: #1f6feb; color: #fff; border-color: #1f6feb; } - .cform textarea, .cform select, .cform input[type="text"], .actbox select, .aform select, .aform input[type="text"], .aform input[type="datetime-local"], nav select, .xpl-filters input[type="search"], .xpl-filters select, .xpl-ctl select { background: #ffffff; color: #1f2328; border-color: #d1d9e0; } + .cform textarea, .cform select, .cform input[type="text"], .raform textarea, .raform select, .raform input[type="date"], .actbox select, .aform select, .aform input[type="text"], .aform input[type="datetime-local"], nav select, .xpl-filters input[type="search"], .xpl-filters select, .xpl-ctl select { background: #ffffff; color: #1f2328; border-color: #d1d9e0; } .cform label, .actbox .ahead label, .aform .why, .optform .effect, .orderctl { color: #59636e; } .cform .kinds label { color: #1f2328; } .aform button { background: #f6f8fa; color: #1f2328; border-color: #d1d9e0; } @@ -501,6 +508,36 @@ document.addEventListener("change", (ev) => { }); `; +const ROADMAP_ANNOTATION_SCRIPT = ``; + /** * The client side of the dashboard action forms (#1152/#1154/#1156): * one delegated submit handler, exactly the COMMENT_SCRIPT pattern — @@ -1789,6 +1826,7 @@ function roadmapCard( items: Map, counts?: Map, drill?: RoadmapDrill, + annotations?: Map, ): string { const refs = allRefs([row]); const refBadges = refs @@ -1799,7 +1837,15 @@ function roadmapCard( const cmt = counts ? commentBadge(row.subject, counts) : ""; const grist = row.gristUrl ? ` Grist ↗` : ""; const expansion = drill ? roadmapDrilldown(drill, row) : ""; - return `
${esc(row.title)}${cmt}
${esc(row.status)} ${esc(row.area)} · ${esc(row.horizon)}${refBadges}${grist}${expansion}
`; + const notes = annotations?.get(row.subject) ?? []; + const annotationHtml = notes.length + ? `
${notes.map(roadmapAnnotationHtml).join("")}
` + : ""; + return `
${esc(row.title)}${cmt}
${esc(row.status)} ${esc(row.area)} · ${esc(row.horizon)}${refBadges}${grist}${annotationHtml}${expansion}
`; +} + +function roadmapAnnotationHtml(annotation: RoadmapAnnotation): string { + return `
${esc(annotation.date)} ${esc(annotation.kind)} ${authorBadge(annotation.author)}
${esc(annotation.body)}
`; } function timelineTable( @@ -1807,6 +1853,7 @@ function timelineTable( items: Map, counts?: Map, drill?: Map, + annotations?: Map, ): string { const head = `Theme${tl.lanes.map((l) => `${esc(l.label)}`).join("")}`; const body = tl.themes @@ -1814,7 +1861,7 @@ function timelineTable( const cells = tl.lanes .map((lane) => { const rs = tl.cells.get(theme)?.get(lane.key) ?? []; - return `${rs.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject))).join("")}`; + return `${rs.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject), annotations)).join("")}`; }) .join(""); return `${esc(theme)}${cells}`; @@ -1832,6 +1879,7 @@ export function renderRoadmap( detail?: ProvenanceDetail, reconciliation?: RoadmapReconciliation, drill?: Map, + annotations?: Map, ): string { // #1144: when the Grist rows and the Project's Kind=Epic pool items // disagree, say so on the page itself — drift the reader can see, @@ -1847,15 +1895,15 @@ export function renderRoadmap( ).join(""); let body: string; if (grain === "quarter") { - body = timelineTable(buildTimeline(rows), items, counts, drill); + body = timelineTable(buildTimeline(rows), items, counts, drill, annotations); } else if (grain === "month") { - body = timelineTable(monthTimeline(rows), items, counts, drill); + body = timelineTable(monthTimeline(rows), items, counts, drill, annotations); } else { const dg = roadmapDayGrain(rows, grain); const sections = dg.lanes .map( (lane) => - `

${esc(lane.label)}

${lane.rows.length ? lane.rows.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject))).join("") : '

Nothing dated this day.

'}`, + `

${esc(lane.label)}

${lane.rows.length ? lane.rows.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject), annotations)).join("") : '

Nothing dated this day.

'}`, ) .join(""); const notes: string[] = []; @@ -1882,6 +1930,8 @@ export function renderRoadmap( export interface ItemExtras { /** Dashboard comments (#906 §2) across this item's object refs. */ dashComments: DashComment[]; + /** Present only on roadmap rows; undefined on ordinary board items. */ + roadmapAnnotations?: RoadmapAnnotation[]; /** The object ref the inline form posts to (the item's subject). */ formRef: string; formOpts: CommentFormOpts; @@ -1897,6 +1947,15 @@ export interface ItemExtras { epicRollup?: EpicRollup; } +function roadmapAnnotationFormHtml(objectRef: string, identities: AuthorIdentity[]): string { + return `
+ + + + +
`; +} + function epicRollupHtml(rollup: EpicRollup): string { const statusMix = rollup.statusMix.length ? rollup.statusMix.map(({ status, count }) => `${esc(status)} ${count}`).join(" · ") @@ -1940,6 +1999,9 @@ export function renderItem( : `

No dashboard comments yet — notes are read when this is picked up; messages are triaged by the orchestrator.

` }${commentFormHtml(extras.formRef, extras.formOpts)}` : ""; + const roadmapAnnotationSection = extras?.roadmapAnnotations !== undefined + ? `

Roadmap annotations

${extras.roadmapAnnotations.length ? extras.roadmapAnnotations.map(roadmapAnnotationHtml).join("") : '

No roadmap annotations yet.

'}${roadmapAnnotationFormHtml(extras.formRef, extras.formOpts.identities)}
` + : ""; const sessionSection = extras && extras.sessions.length ? `

Sessions that worked this

${extras.sessions.map((r) => sessionRecordLine(r)).join("")}` @@ -1958,10 +2020,12 @@ export function renderItem(

Linked issues / PRs

${refRows || ''}
Linked issues and pull requests
RefTitleLabelsUpdated
none resolved
${noteList ? `

Planning notes

    ${noteList}
` : ""}

Activity stream

${commentRows || ''}
Activity stream
WhenKindAuthorBody
no comments
-${sessionSection}${statusSection}${dashSection}`, +${sessionSection}${statusSection}${roadmapAnnotationSection}${dashSection}`, provenance, "/board", - extras ? COMMENT_SCRIPT + (extras.statusRequest ? ACTION_SCRIPT : "") : "", + extras + ? COMMENT_SCRIPT + (extras.roadmapAnnotations !== undefined ? ROADMAP_ANNOTATION_SCRIPT : "") + (extras.statusRequest ? ACTION_SCRIPT : "") + : "", ); } diff --git a/pm-stack/aggregator/src/roadmap-annotations.ts b/pm-stack/aggregator/src/roadmap-annotations.ts new file mode 100644 index 000000000..a61bf28f2 --- /dev/null +++ b/pm-stack/aggregator/src/roadmap-annotations.ts @@ -0,0 +1,198 @@ +// Append-only, attributed annotations for roadmap rows. Row subjects are +// feed/source identifiers; aliases let a reshuffled Grist row continue to +// resolve to the original stable subject without rewriting annotation history. +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { isObjectRef, validateAuthor } from "./comments.ts"; +import type { DashAuthor } from "./model.ts"; + +export type RoadmapAnnotationKind = "decision" | "outcome" | "context"; + +export interface RoadmapAnnotation { + id: string; + /** Canonical stable ref after resolving any source-row aliases. */ + stableRef: string; + kind: RoadmapAnnotationKind; + /** User-supplied calendar date; separate from the immutable write time. */ + date: string; + author: DashAuthor; + body: string; + createdAt: string; +} + +export interface NewRoadmapAnnotation { + objectRef: string; + kind: RoadmapAnnotationKind; + date: string; + author: DashAuthor; + body: string; +} + +interface RoadmapAnnotationsFile { + version: 1; + annotations: RoadmapAnnotation[]; + aliases: Record; +} + +export function validRoadmapDate(value: unknown): value is string { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +export function validateNewRoadmapAnnotation( + input: unknown, +): { ok: true; value: NewRoadmapAnnotation } | { ok: false; error: string } { + if (typeof input !== "object" || input === null) return { ok: false, error: "body must be a JSON object" }; + const value = input as Record; + if (!isObjectRef(value.objectRef)) return { ok: false, error: "objectRef must be a non-empty ref string" }; + if (value.kind !== "decision" && value.kind !== "outcome" && value.kind !== "context") { + return { ok: false, error: "kind must be decision | outcome | context" }; + } + if (!validRoadmapDate(value.date)) return { ok: false, error: "date must be a valid YYYY-MM-DD calendar date" }; + const author = validateAuthor(value.author); + if (!author) return { ok: false, error: "author must be {kind: drew|muse|agent, id, label}" }; + if (typeof value.body !== "string" || value.body.trim() === "") { + return { ok: false, error: "body must be non-empty text" }; + } + return { + ok: true, + value: { + objectRef: value.objectRef, + kind: value.kind, + date: value.date, + author, + body: value.body.trim().slice(0, 20_000), + }, + }; +} + +function isAnnotation(value: unknown): value is RoadmapAnnotation { + if (typeof value !== "object" || value === null) return false; + const row = value as Partial; + return ( + typeof row.id === "string" && + isObjectRef(row.stableRef) && + (row.kind === "decision" || row.kind === "outcome" || row.kind === "context") && + validRoadmapDate(row.date) && + validateAuthor(row.author) !== null && + typeof row.body === "string" && + typeof row.createdAt === "string" + ); +} + +export class RoadmapAnnotationsStore { + private annotations: RoadmapAnnotation[] = []; + private aliases: Record = {}; + private loadPromise: Promise | null = null; + private writeChain: Promise = Promise.resolve(); + private writeSeq = 0; + + constructor(private readonly file: string) {} + + async load(): Promise { + if (!this.loadPromise) this.loadPromise = this.doLoad(); + return this.loadPromise; + } + + private async doLoad(): Promise { + let raw: string; + try { + raw = await readFile(this.file, "utf8"); + } catch (err) { + if ((err as { code?: unknown })?.code === "ENOENT") return; + throw err; + } + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) throw new Error("not an object"); + const file = parsed as Partial; + if (file.version !== 1 || !Array.isArray(file.annotations) || typeof file.aliases !== "object" || file.aliases === null) { + throw new Error("unexpected roadmap annotation store shape"); + } + this.annotations = file.annotations.filter(isAnnotation); + this.aliases = Object.fromEntries( + Object.entries(file.aliases).filter(([alias, stable]) => isObjectRef(alias) && isObjectRef(stable)), + ); + } catch (err) { + const quarantined = `${this.file}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`; + await rename(this.file, quarantined).catch(() => {}); + console.error( + `[roadmap-annotations] invalid store ${this.file}; quarantined original bytes to ${quarantined}: ${err instanceof Error ? err.message : String(err)}`, + ); + this.annotations = []; + this.aliases = {}; + } + } + + private persist(): Promise { + const run = this.writeChain.then(async () => { + const data: RoadmapAnnotationsFile = { version: 1, annotations: this.annotations, aliases: this.aliases }; + await mkdir(dirname(this.file), { recursive: true }); + const tmp = `${this.file}.tmp-${process.pid}-${++this.writeSeq}`; + await writeFile(tmp, JSON.stringify(data, null, 2)); + await rename(tmp, this.file); + }); + this.writeChain = run.catch(() => {}); + return run; + } + + private resolveRef(ref: string): string { + const seen = new Set(); + let current = ref; + while (this.aliases[current]) { + if (seen.has(current)) throw new Error("roadmap annotation alias cycle"); + seen.add(current); + current = this.aliases[current]; + } + return current; + } + + async all(): Promise { + await this.load(); + return [...this.annotations].sort((a, b) => a.date.localeCompare(b.date) || a.createdAt.localeCompare(b.createdAt)); + } + + async forObject(objectRef: string): Promise { + await this.load(); + const stableRef = this.resolveRef(objectRef); + return (await this.all()).filter((annotation) => annotation.stableRef === stableRef); + } + + async add(input: NewRoadmapAnnotation): Promise { + await this.load(); + const annotation: RoadmapAnnotation = { + id: `ra-${randomUUID()}`, + stableRef: this.resolveRef(input.objectRef), + kind: input.kind, + date: input.date, + author: input.author, + body: input.body, + createdAt: new Date().toISOString(), + }; + this.annotations.push(annotation); + await this.persist(); + return annotation; + } + + /** Map a current source ref to an existing canonical ref; history stays immutable. */ + async addAlias(aliasRef: string, stableRef: string): Promise { + await this.load(); + const canonical = this.resolveRef(stableRef); + if (aliasRef === canonical) throw new Error("aliasRef and stableRef already identify the same item"); + if (this.annotations.some((annotation) => annotation.stableRef === aliasRef)) { + throw new Error("aliasRef already owns annotation history; choose it as stableRef instead"); + } + if (this.aliases[aliasRef] && this.resolveRef(aliasRef) !== canonical) { + throw new Error("aliasRef already points to a different stable ref"); + } + this.aliases[aliasRef] = canonical; + await this.persist(); + } + + async aliasEntries(): Promise> { + await this.load(); + return { ...this.aliases }; + } +} diff --git a/pm-stack/aggregator/src/server.ts b/pm-stack/aggregator/src/server.ts index cd78a4580..c960d0937 100644 --- a/pm-stack/aggregator/src/server.ts +++ b/pm-stack/aggregator/src/server.ts @@ -12,6 +12,7 @@ import { errorBoundaryResponse, notFoundResponse } from "./error-boundary.ts"; import { ActionsStore, validateNewAction } from "./actions.ts"; import { checkActionAgainstItem, effectiveNeedsOrder, effectiveStandingAuto, findTierItem } from "./tier-items.ts"; import { commentKindCounts, CommentsStore, queuedComments, validateNewComment } from "./comments.ts"; +import { RoadmapAnnotationsStore, validateNewRoadmapAnnotation } from "./roadmap-annotations.ts"; import { computeCoverage, CoverageSnapshotStore, @@ -100,6 +101,7 @@ import { liveSource } from "./sources/live.ts"; const cfg = loadConfig(); const commentsStore = new CommentsStore(cfg.commentsFile); +const roadmapAnnotationsStore = new RoadmapAnnotationsStore(cfg.roadmapAnnotationsFile); const coverageStore = new CoverageSnapshotStore(cfg.coverageSnapshotFile); /** #1220: the coverage snapshot behind the /projects panel — computed @@ -530,6 +532,44 @@ async function handler(req: Request): Promise { } // --- Dashboard comment writes (#906 §2) -------------------------------- + if (req.method === "POST" && path === "/api/roadmap/annotations") { + try { + const parsed = validateNewRoadmapAnnotation(await readJsonBody(req)); + if (!parsed.ok) return json({ error: parsed.error }, 400); + const rows = await source.roadmap().catch(() => []); + if (!rows.some((row) => row.subject === parsed.value.objectRef)) { + return json({ error: "objectRef is not a current roadmap row" }, 404); + } + try { + const annotation = await roadmapAnnotationsStore.add(parsed.value); + return json({ annotation }, 201); + } catch (err) { + return json({ error: `annotation persistence failed: ${err instanceof Error ? err.message : err}` }, 500); + } + } catch (err) { + return json({ error: `invalid annotation: ${err instanceof Error ? err.message : err}` }, 400); + } + } + if (req.method === "POST" && path === "/api/roadmap/annotations/alias") { + try { + const body = (await readJsonBody(req)) as { aliasRef?: unknown; stableRef?: unknown }; + if (typeof body.aliasRef !== "string" || typeof body.stableRef !== "string") { + return json({ error: "aliasRef and stableRef are required" }, 400); + } + const rows = await source.roadmap().catch(() => []); + if (!rows.some((row) => row.subject === body.aliasRef)) { + return json({ error: "aliasRef is not a current roadmap row" }, 404); + } + if (!(await roadmapAnnotationsStore.forObject(body.stableRef)).length) { + return json({ error: "stableRef has no existing annotation history" }, 404); + } + await roadmapAnnotationsStore.addAlias(body.aliasRef, body.stableRef); + return json({ aliases: await roadmapAnnotationsStore.aliasEntries() }); + } catch (err) { + return json({ error: `invalid annotation alias: ${err instanceof Error ? err.message : err}` }, 400); + } + } + if (req.method === "POST" && path === "/api/comments") { try { const parsed = validateNewComment(await readJsonBody(req)); @@ -696,8 +736,11 @@ async function handler(req: Request): Promise { const { rows, items, reconciliation, drill } = await roadmapContext(); const g = url.searchParams.get("h") ?? "quarter"; const grain: RoadmapGrain = GRAINS.has(g) ? (g as RoadmapGrain) : "quarter"; + const annotations = new Map( + await Promise.all(rows.map(async (row) => [row.subject, await roadmapAnnotationsStore.forObject(row.subject)] as const)), + ); return html( - renderRoadmap(rows, items, prov(), grain, await commentsStore.countsDetailed(), provDetail(), reconciliation, drill), + renderRoadmap(rows, items, prov(), grain, await commentsStore.countsDetailed(), provDetail(), reconciliation, drill, annotations), ); } @@ -922,7 +965,15 @@ async function handler(req: Request): Promise { } if (path === "/api/roadmap.json") { const { rows, items, reconciliation } = await roadmapContext(); - return json({ rows, items: [...items.values()], drift: roadmapDriftReport(reconciliation) }); + const annotatedRows = await Promise.all( + rows.map(async (row) => ({ ...row, annotations: await roadmapAnnotationsStore.forObject(row.subject) })), + ); + return json({ + rows: annotatedRows, + items: [...items.values()], + drift: roadmapDriftReport(reconciliation), + annotationAliases: await roadmapAnnotationsStore.aliasEntries(), + }); } if (path === "/api/quota.json") { // Extended in place for the hub (design §1.1): the pools/burn/choices @@ -1158,19 +1209,21 @@ async function handler(req: Request): Promise { return json({ error: `malformed percent-encoding in path segment: ${itemMatch[1]}` }, 400); } const [board, rows] = await Promise.all([source.board().catch(() => []), source.roadmap().catch(() => [])]); - const subject = board.find((b) => b.id === id) ?? rows.find((r) => r.subject === id); + const roadmapRow = rows.find((r) => r.subject === id); + const subject = board.find((b) => b.id === id) ?? roadmapRow; if (subject) { const title = subject.title; const links = subject.links; const notes = "repo" in subject ? subject.notes : undefined; const refs = allRefs([subject]); - const [items, comments, allComments, records, pool, allActions] = await Promise.all([ + const [items, comments, allComments, records, pool, allActions, roadmapAnnotations] = await Promise.all([ source.items(refs).catch(() => new Map()), source.comments(refs).catch(() => []), commentsStore.all(), source.registry().catch(() => []), source.projects().catch(() => []), actionsStore.all(), + roadmapRow ? roadmapAnnotationsStore.forObject(id) : Promise.resolve(undefined), ]); // Dashboard comments for this item live on its own subject ref // and on each linked GitHub ref (gh:) — one merged thread. @@ -1183,6 +1236,7 @@ async function handler(req: Request): Promise { const epicRollup = pooled?.kind === "Epic" ? buildEpicRollups(pool).get(pooled.ref) : undefined; const extras = { dashComments, + ...(roadmapRow ? { roadmapAnnotations } : {}), formRef: id, serviceLinks: itemServiceLinks(subject), formOpts: await commentFormOpts(refs), diff --git a/pm-stack/aggregator/test/roadmap-annotations.test.ts b/pm-stack/aggregator/test/roadmap-annotations.test.ts new file mode 100644 index 000000000..8fc14f718 --- /dev/null +++ b/pm-stack/aggregator/test/roadmap-annotations.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RoadmapAnnotationsStore, + validateNewRoadmapAnnotation, + type NewRoadmapAnnotation, +} from "../src/roadmap-annotations.ts"; + +const author = { kind: "drew" as const, id: "drew", label: "Drew" }; +const input = (objectRef: string, date: string, body: string): NewRoadmapAnnotation => ({ + objectRef, + kind: "decision", + date, + author, + body, +}); + +describe("roadmap annotations (#1284)", () => { + test("validates typed, dated, attributed notes and rejects invalid calendar dates", () => { + expect(validateNewRoadmapAnnotation(input("grist:doc/Roadmap/1", "2026-11-01", "slipped"))).toMatchObject({ + ok: true, + }); + expect(validateNewRoadmapAnnotation(input("grist:doc/Roadmap/1", "2026-02-30", "slipped"))).toMatchObject({ + ok: false, + }); + expect(validateNewRoadmapAnnotation({ ...input("grist:doc/Roadmap/1", "2026-11-01", "slipped"), kind: "note" })).toMatchObject({ + ok: false, + }); + }); + + test("persists append-only history and resolves a reshuffled row through an alias after restart", async () => { + const dir = mkdtempSync(join(tmpdir(), "roadmap-annotations-")); + const file = join(dir, "annotations.json"); + const store = new RoadmapAnnotationsStore(file); + const stableRef = "grist:doc/Roadmap/old-row"; + const currentRef = "grist:doc/Roadmap/new-row"; + await store.add(input(stableRef, "2026-10-01", "Initial decision")); + await store.addAlias(currentRef, stableRef); + await store.add(input(currentRef, "2026-10-03", "Corrected with a follow-up")); + + const restarted = new RoadmapAnnotationsStore(file); + const history = await restarted.forObject(currentRef); + expect(history.map((annotation) => annotation.body)).toEqual(["Initial decision", "Corrected with a follow-up"]); + expect(history[0].stableRef).toBe(stableRef); + expect(await restarted.aliasEntries()).toEqual({ [currentRef]: stableRef }); + }); +}); diff --git a/pm-stack/aggregator/test/slice3.test.ts b/pm-stack/aggregator/test/slice3.test.ts index 8169a233c..c511305e0 100644 --- a/pm-stack/aggregator/test/slice3.test.ts +++ b/pm-stack/aggregator/test/slice3.test.ts @@ -448,6 +448,7 @@ describe("opencode session-identity tool", () => { let proc: Subprocess<"ignore", "pipe", "inherit"> | null = null; let port = 0; const tmp = mkdtempSync(join(tmpdir(), "agg-slice3-")); +const annotationsFile = join(tmp, "roadmap-annotations.json"); beforeAll(async () => { port = 5200 + Math.floor(Math.random() * 800); @@ -461,6 +462,7 @@ beforeAll(async () => { PM_AGG_PORT: String(port), PM_AGG_SOURCE: "fixtures", PM_AGG_COMMENTS_FILE: commentsFile, + PM_AGG_ROADMAP_ANNOTATIONS_FILE: annotationsFile, PM_AGG_REGISTRY_FILE: join(tmp, "registry.json"), }, stdout: "pipe", @@ -580,4 +582,53 @@ describe("slice-3 routes", () => { expect(html).toContain("Dashboard comments"); expect(html).toContain("cform"); }); + + test("roadmap annotations round-trip through API, timeline, drill-down, and JSON; aliases survive reshuffles", async () => { + const roadmap = (await (await fetch(`http://127.0.0.1:${port}/api/roadmap.json`)).json()) as { + rows: { subject: string; annotations: { body: string }[] }[]; + }; + expect(roadmap.rows.length).toBeGreaterThan(1); + const stableRef = roadmap.rows[0].subject; + const reshuffledRef = roadmap.rows[1].subject; + const post = (objectRef: string, body: string) => + fetch(`http://127.0.0.1:${port}/api/roadmap/annotations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + objectRef, + kind: "decision", + date: "2026-10-12", + author: { kind: "drew", id: "drew", label: "Drew" }, + body, + }), + }); + + expect((await post(stableRef, "Moved after review")).status).toBe(201); + const alias = await fetch(`http://127.0.0.1:${port}/api/roadmap/annotations/alias`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aliasRef: reshuffledRef, stableRef }), + }); + expect(alias.status).toBe(200); + expect((await post(reshuffledRef, "Follow-up under the new row id")).status).toBe(201); + + const json = (await (await fetch(`http://127.0.0.1:${port}/api/roadmap.json`)).json()) as { + rows: { subject: string; annotations: { body: string; date: string; kind: string; author: { label: string } }[] }[]; + annotationAliases: Record; + }; + const reshuffledRow = json.rows.find((row) => row.subject === reshuffledRef)!; + expect(reshuffledRow.annotations.map((annotation) => annotation.body)).toEqual([ + "Moved after review", + "Follow-up under the new row id", + ]); + expect(reshuffledRow.annotations[0]).toMatchObject({ date: "2026-10-12", kind: "decision", author: { label: "Drew" } }); + expect(json.annotationAliases[reshuffledRef]).toBe(stableRef); + + const detail = await (await fetch(`http://127.0.0.1:${port}/item/${encodeURIComponent(reshuffledRef)}`)).text(); + expect(detail).toContain("Roadmap annotations"); + expect(detail).toContain("Follow-up under the new row id"); + expect(detail).toContain("name=\"date\""); + const timeline = await (await fetch(`http://127.0.0.1:${port}/roadmap`)).text(); + expect(timeline).toContain("Moved after review"); + }); });