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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions nixos/services/pm-stack/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions pm-stack/aggregator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -83,6 +85,7 @@ export function loadConfig(env: Record<string, string | undefined> = 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")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep annotation state outside the deployed Git checkout

On the NixOS deployment, the first annotation creates an unignored pm-stack/aggregator/roadmap-annotations.json inside the primary checkout: nixos/services/pm-stack/default.nix sets that directory as WorkingDirectory but does not set the new environment variable. pm-stack/scripts/pm-git-sync.sh:42–47 explicitly exits without pulling whenever the working tree contains dirt, so using this feature blocks subsequent automatic updates and deployments until someone manually relocates or ignores the file. Wire the annotation path to the aggregator state directory in the deployed service, as already done for the coverage snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the deployed aggregator service in nixos/services/pm-stack/default.nix now sets PM_AGG_ROADMAP_ANNOTATIONS_FILE=${aggState}/roadmap-annotations.json, wired exactly like PM_AGG_COVERAGE_SNAPSHOT_FILE, so annotation state lives in ~/.local/state/pm-aggregator/ and never dirties the deployed checkout / blocks pm-git-sync. Verified: nix file parses (nix-instantiate --parse), bun test test/roadmap-annotations.test.ts 2/2 pass, slice3 annotation coverage passes (the one slice3 failure is the pre-existing env-dependent routes/registry baseline reproduced on clean main by earlier shifts, unrelated — the bun suites do not read the .nix file). — task 5002, lane F

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")),
Expand Down
80 changes: 72 additions & 8 deletions pm-stack/aggregator/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -501,6 +508,36 @@ document.addEventListener("change", (ev) => {
});
</script>`;

const ROADMAP_ANNOTATION_SCRIPT = `<script>${DASH_AFTER_JS}
document.addEventListener("submit", async (ev) => {
const form = ev.target;
if (!(form instanceof HTMLFormElement) || !form.classList.contains("raform")) return;
ev.preventDefault();
const err = form.querySelector(".err");
const data = new FormData(form);
const raw = String(data.get("author") || "drew|drew|");
const [kind, id, sessionId] = raw.split("|");
const option = form.querySelector('option[value="' + CSS.escape(raw) + '"]');
const payload = {
objectRef: form.dataset.object,
kind: String(data.get("kind") || "context"),
date: String(data.get("date") || ""),
author: { kind, id, label: option ? option.textContent : id, ...(sessionId ? { sessionId } : {}) },
body: String(data.get("body") || "")
};
const button = form.querySelector("button[type=submit]");
if (button) button.disabled = true;
try {
const res = await fetch("/api/roadmap/annotations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || ("HTTP " + res.status)); }
window.location.reload();
} catch (e) {
if (err) err.textContent = "Not saved: " + e.message;
if (button) button.disabled = false;
}
});
</script>`;

/**
* The client side of the dashboard action forms (#1152/#1154/#1156):
* one delegated submit handler, exactly the COMMENT_SCRIPT pattern —
Expand Down Expand Up @@ -1789,6 +1826,7 @@ function roadmapCard(
items: Map<RefKey, GhItem>,
counts?: Map<string, number | CommentKindCounts>,
drill?: RoadmapDrill,
annotations?: Map<string, RoadmapAnnotation[]>,
): string {
const refs = allRefs([row]);
const refBadges = refs
Expand All @@ -1799,22 +1837,31 @@ function roadmapCard(
const cmt = counts ? commentBadge(row.subject, counts) : "";
const grist = row.gristUrl ? ` <a class="badge" href="${esc(row.gristUrl)}">Grist ↗</a>` : "";
const expansion = drill ? roadmapDrilldown(drill, row) : "";
return `<div class="card"><a href="/item/${encodeURIComponent(row.subject)}">${esc(row.title)}</a>${cmt}<br><span class="badge ${esc(row.status.toLowerCase().replace(/[^a-z]/g, "-"))}">${esc(row.status)}</span> <span class="muted">${esc(row.area)} · ${esc(row.horizon)}</span>${refBadges}${grist}${expansion}</div>`;
const notes = annotations?.get(row.subject) ?? [];
const annotationHtml = notes.length
? `<div class="roadmap-annotations">${notes.map(roadmapAnnotationHtml).join("")}</div>`
: "";
return `<div class="card"><a href="/item/${encodeURIComponent(row.subject)}">${esc(row.title)}</a>${cmt}<br><span class="badge ${esc(row.status.toLowerCase().replace(/[^a-z]/g, "-"))}">${esc(row.status)}</span> <span class="muted">${esc(row.area)} · ${esc(row.horizon)}</span>${refBadges}${grist}${annotationHtml}${expansion}</div>`;
}

function roadmapAnnotationHtml(annotation: RoadmapAnnotation): string {
return `<div class="roadmap-annotation"><span class="muted">${esc(annotation.date)}</span> <span class="badge">${esc(annotation.kind)}</span> ${authorBadge(annotation.author)}<br>${esc(annotation.body)}</div>`;
}

function timelineTable(
tl: ReturnType<typeof buildTimeline>,
items: Map<RefKey, GhItem>,
counts?: Map<string, number | CommentKindCounts>,
drill?: Map<string, RoadmapDrill>,
annotations?: Map<string, RoadmapAnnotation[]>,
): string {
const head = `<tr><th scope="col">Theme</th>${tl.lanes.map((l) => `<th scope="col">${esc(l.label)}</th>`).join("")}</tr>`;
const body = tl.themes
.map((theme) => {
const cells = tl.lanes
.map((lane) => {
const rs = tl.cells.get(theme)?.get(lane.key) ?? [];
return `<td class="tl-cell">${rs.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject))).join("")}</td>`;
return `<td class="tl-cell">${rs.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject), annotations)).join("")}</td>`;
})
.join("");
return `<tr><th scope="row">${esc(theme)}</th>${cells}</tr>`;
Expand All @@ -1832,6 +1879,7 @@ export function renderRoadmap(
detail?: ProvenanceDetail,
reconciliation?: RoadmapReconciliation,
drill?: Map<string, RoadmapDrill>,
annotations?: Map<string, RoadmapAnnotation[]>,
): 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,
Expand All @@ -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) =>
`<h2>${esc(lane.label)}</h2>${lane.rows.length ? lane.rows.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject))).join("") : '<p class="muted">Nothing dated this day.</p>'}`,
`<h2>${esc(lane.label)}</h2>${lane.rows.length ? lane.rows.map((r) => roadmapCard(r, items, counts, drill?.get(r.subject), annotations)).join("") : '<p class="muted">Nothing dated this day.</p>'}`,
)
.join("");
const notes: string[] = [];
Expand All @@ -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;
Expand All @@ -1897,6 +1947,15 @@ export interface ItemExtras {
epicRollup?: EpicRollup;
}

function roadmapAnnotationFormHtml(objectRef: string, identities: AuthorIdentity[]): string {
return `<form class="raform" data-object="${esc(objectRef)}">
<label>Annotation kind</label><select name="kind"><option value="decision">Decision</option><option value="outcome">Outcome</option><option value="context" selected>Context</option></select>
<label>Date</label><input type="date" name="date" required>
<label>Author</label><select name="author" data-author-picker>${authorOptionsHtml(identities)}</select>
<label>Annotation</label><textarea name="body" rows="3" required placeholder="Why did this change, slip, or get dropped?"></textarea>
<button type="submit">Add annotation</button><div class="err"></div></form>`;
}

function epicRollupHtml(rollup: EpicRollup): string {
const statusMix = rollup.statusMix.length
? rollup.statusMix.map(({ status, count }) => `${esc(status)} ${count}`).join(" · ")
Expand Down Expand Up @@ -1940,6 +1999,9 @@ export function renderItem(
: `<p class="muted">No dashboard comments yet — notes are read when this is picked up; messages are triaged by the orchestrator.</p>`
}${commentFormHtml(extras.formRef, extras.formOpts)}`
: "";
const roadmapAnnotationSection = extras?.roadmapAnnotations !== undefined
? `<section class="roadmap-annotation-section"><h2>Roadmap annotations</h2>${extras.roadmapAnnotations.length ? extras.roadmapAnnotations.map(roadmapAnnotationHtml).join("") : '<p class="muted">No roadmap annotations yet.</p>'}${roadmapAnnotationFormHtml(extras.formRef, extras.formOpts.identities)}</section>`
: "";
const sessionSection =
extras && extras.sessions.length
? `<h2>Sessions that worked this</h2>${extras.sessions.map((r) => sessionRecordLine(r)).join("")}`
Expand All @@ -1958,10 +2020,12 @@ export function renderItem(
<h2>Linked issues / PRs</h2><div class="tblwrap"><table><caption>Linked issues and pull requests</caption><tr><th scope="col">Ref</th><th scope="col">Title</th><th scope="col">Labels</th><th scope="col">Updated</th></tr>${refRows || '<tr><td colspan="4" class="muted">none resolved</td></tr>'}</table></div>
${noteList ? `<h2>Planning notes</h2><ul>${noteList}</ul>` : ""}
<h2>Activity stream</h2><div class="tblwrap"><table><caption>Activity stream</caption><tr><th scope="col">When</th><th scope="col">Kind</th><th scope="col">Author</th><th scope="col">Body</th></tr>${commentRows || '<tr><td colspan="4" class="muted">no comments</td></tr>'}</table></div>
${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 : "")
: "",
);
}

Expand Down
Loading