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
91 changes: 53 additions & 38 deletions pm-stack/aggregator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,55 +54,69 @@ bun test

Everything defaults to **fixtures** and works with no configuration:

| Env var | Default | Purpose |
| ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------- |
| `PM_AGG_SOURCE` | `fixtures` | `live` reads Kaneo/Grist/projector, falling back to fixtures per facet |
| `PM_AGG_PORT` | `4870` | HTTP port |
| `KANEO_URL` | `http://127.0.0.1:5173` | Kaneo base URL (board reader) |
| `KANEO_ADMIN_EMAIL` | — | Kaneo admin email (secret `kaneo.admin-password` pairs with it) |
| `KANEO_ADMIN_PASSWORD` | — | Kaneo admin password (Bitwarden `kaneo.admin-password`) |
| `GRIST_URL` | `http://127.0.0.1:8484` | Grist base URL |
| `GRIST_API_KEY` | — | Grist API key (Bitwarden `grist.api-key`) |
| `GRIST_ROADMAP_DOC` | `czYHYKgG9SHcm9CPwiKEq1` | The `Program roadmap` document id (Phase 2 seed) |
| `PM_PROJECTOR_URL` | `http://127.0.0.1:7801` is J's planned default | The projector + link registry base URL (for `/items`, `/comments`) |
| Env var | Default | Purpose |
| ---------------------- | ------------------------ | --------------------------------------------------------------------------------------- |
| `PM_AGG_SOURCE` | `fixtures` | `live` reads Kaneo/Grist/projector, falling back to fixtures per facet |
| `PM_AGG_PORT` | `4870` | HTTP port |
| `KANEO_URL` | `http://127.0.0.1:5173` | Kaneo base URL (board reader) |
| `KANEO_ADMIN_EMAIL` | — | Kaneo admin email (secret `kaneo.admin-password` pairs with it) |
| `KANEO_ADMIN_PASSWORD` | — | Kaneo admin password (Bitwarden `kaneo.admin-password`) |
| `GRIST_URL` | `http://127.0.0.1:8484` | Grist base URL |
| `GRIST_API_KEY` | — | Grist API key (Bitwarden `grist.api-key`) |
| `GRIST_ROADMAP_DOC` | `czYHYKgG9SHcm9CPwiKEq1` | The `Program roadmap` document id (Phase 2 seed) |
| `PM_PROJECTOR_URL` | `http://127.0.0.1:7801` | Projector + link registry base URL (`/items`, `/comments`, `/links`); `off` disables it |

Secrets are read from the environment only and are never committed or
printed. Names here reference Bitwarden entries (`kaneo.*`, `grist.*`).

## What each facet reads

| Facet | Live reader (when `PM_AGG_SOURCE=live`) | Fallback |
| ---------------- | ----------------------------------------------------------- | ----------------------------------- |
| Board | Kaneo REST (`/api/task/tasks/<project>`, all four projects) | fixtures |
| Roadmap | Grist REST (`/api/docs/<doc>/tables/Roadmap/records`) | fixtures (the real Grist seed rows) |
| Items (refs) | Projector `GET /items?refs=…` | fixtures |
| Comments | Projector `GET /comments?refs=…` | fixtures |
| Quota (#851) | (projector concern — not yet projected) | fixtures |
| Attention (#852) | (projector/watch-cron concern — not yet projected) | fixtures |
| Facet | Live reader (when `PM_AGG_SOURCE=live`) | Fallback |
| ---------------- | ---------------------------------------------------------------------------- | ----------------------------------- |
| Board | Kaneo REST (`/api/task/tasks/<project>`, all four projects) + registry links | fixtures |
| Roadmap | Grist REST (`/api/docs/<doc>/tables/Roadmap/records`) + registry links | fixtures (the real Grist seed rows) |
| Registry links | Projector `GET /links?subject=…`, unioned into board cards and roadmap rows | Kaneo external links / text refs |
| Items (refs) | Projector `GET /items?refs=…` | fixtures |
| Comments | Projector `GET /comments?refs=…` | fixtures |
| Quota (#851) | (projector concern — not yet projected) | fixtures |
| Attention (#852) | (projector/watch-cron concern — not yet projected) | fixtures |

The projector's wire schema is its `schema.sql` (Session J, PR #859):
snake_case fields, `state` is only `open|closed` with `merged` as a
separate 0/1 flag, `labels`/`assignees` are JSON array strings, and
comment rows carry `created_at` / `review_state`. The mapping to this
app's read model lives in `src/sources/live.ts`
(`mapProjectorItem` / `mapProjectorComment` / `parseProjectorLabels`),
covered by `test/projector.test.ts` — including a stub-server test that
refs survive the query string (`#` is percent-encoded, otherwise it
would start a URL fragment and the projector would never see the
number). Registry subjects are `kaneo:<projectSlug>/<taskId>` and
`grist:<docId>/<tableId>/<rowId>`; board cards and roadmap rows are
enriched with `GET /links?subject=…` so cross-repo links (the
requirement Kaneo cannot provide) render live.

The footer of every page states, facet by facet, whether it is live or
fixture-backed. When a live reader is unreachable, that facet alone
falls back to fixtures and the footer says why.

## What is fixture-backed vs live

- **Fixture-backed today:** items, comments, quota, and attention. The
fixtures are shaped exactly like the live readers' output and sit
behind the same `Source` interface (`src/source.ts`), so swapping is a
config change, not a code change. Roadmap fixtures are the real Grist
seed rows; the design record's known refs (#752, #754, #762, #763,
nix #501–#508, opencode #365) are real; the rest are illustrative.
- **Live-capable today:** board (Kaneo) and roadmap (Grist) when the
services are reachable on this host and the env vars are set. The
projector's `/items` and `/comments` are wired but require Session J's
service (`pm/projector`) to be running and `PM_PROJECTOR_URL` set.
The live board is the curated in-flight set (in-progress / in-review /
recent done columns; the to-do backlog is excluded). It is genuinely
cross-repo only once the remaining repos are connected as Kaneo
projects (Session H, `pm/connect-repos`) — today Kaneo has the `code`
project, so GitHub-only work from the other three repos appears on the
board through the fixtures until the projector/registry or H's
connections feed them.
- **Fixture-backed today:** quota and attention (their feeds are not
projected yet). The fixtures are shaped like the live readers' output
and sit behind the same `Source` interface (`src/source.ts`), so
swapping is a config change, not a code change. Roadmap fixtures are
the real Grist seed rows; the design record's known refs (#752, #754,
#762, #763, nix #501–#508, opencode #365) are real; the rest are
illustrative.
- **Live-capable today:** board (Kaneo), roadmap (Grist), and — since
Session J's projector landed (PR #859) — registry links, live item
state, and the comment projection via `PM_PROJECTOR_URL`
(default `http://127.0.0.1:7801`). Each facet falls back to fixtures
independently when its service is unreachable. The live board is the
curated in-flight set (in-progress / in-review / recent done columns;
the to-do backlog is excluded). Kaneo project slugs map to repos as
in `pm-stack/kaneo-projects.json` (Session H): `code`, `opencode`,
`openchamber` under `developing-today/`, `nix` under `dezren39/`.

## Design notes — the two new views (#851, #852)

Expand Down Expand Up @@ -145,6 +159,7 @@ src/
fixtures.ts fixture data (real Grist seed + design-record refs)
live.ts Kaneo/Grist/projector readers with per-facet fallback
test/
render.test.ts unit derivations + render smoke
boot.test.ts boots the server, serves all views from fixtures
render.test.ts unit derivations + render smoke
boot.test.ts boots the server, serves all views from fixtures
projector.test.ts projector wire-schema mapping + stub-server reads
```
7 changes: 6 additions & 1 deletion pm-stack/aggregator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
docId: s(env.GRIST_ROADMAP_DOC, "czYHYKgG9SHcm9CPwiKEq1"),
},
projector: {
baseUrl: env.PM_PROJECTOR_URL ?? null,
// Session J's projector serves on 127.0.0.1:7801 by default; set
// PM_PROJECTOR_URL=off to disable the live registry/item/comment reads.
baseUrl:
env.PM_PROJECTOR_URL === "off" || env.PM_PROJECTOR_URL === "none"
? null
: s(env.PM_PROJECTOR_URL, "http://127.0.0.1:7801"),
},
};
}
185 changes: 174 additions & 11 deletions pm-stack/aggregator/src/sources/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,18 @@ interface KaneoColumn {
tasks: KaneoTask[];
}

async function kaneoProjects(cfg: Config, cookie: string): Promise<{ id: string; name: string; repo: string }[]> {
/** Kaneo project slug -> GitHub repo (pm-stack/kaneo-projects.json, Session H). */
const KANEO_PROJECT_REPOS: Record<string, string> = {
code: "developing-today/code",
opencode: "developing-today/opencode",
openchamber: "developing-today/openchamber",
nix: "dezren39/nix",
};

async function kaneoProjects(
cfg: Config,
cookie: string,
): Promise<{ id: string; slug: string; name: string; repo: string }[]> {
const ws = await getJson(`${cfg.kaneo.baseUrl}/api/workspace`, { Cookie: cookie });
if (!ws.ok) throw new Error("kaneo workspace read failed");
const workspaces = ws.json as { id: string }[];
Expand All @@ -71,8 +82,9 @@ async function kaneoProjects(cfg: Config, cookie: string): Promise<{ id: string;
if (!proj.ok) throw new Error("kaneo project read failed");
return (proj.json as { id: string; slug: string; name: string }[]).map((p) => ({
id: p.id,
slug: p.slug,
name: p.name,
repo: `developing-today/${p.slug}`,
repo: KANEO_PROJECT_REPOS[p.slug] ?? `developing-today/${p.slug}`,
}));
}

Expand Down Expand Up @@ -104,7 +116,8 @@ async function kaneoBoard(cfg: Config, cookie: string): Promise<BoardItem[]> {
return { ref: `${repo}#${l.externalId}`, relation: "tracks" as const };
});
out.push({
id: `kaneo:${p.name}/${t.id}`,
// Registry subject: kaneo:<projectSlug>/<taskId> (Session J).
id: `kaneo:${p.slug}/${t.id}`,
title: t.title,
repo: p.repo,
source: "kaneo",
Expand Down Expand Up @@ -154,19 +167,159 @@ async function gristRoadmap(cfg: Config): Promise<RoadmapRow[]> {
});
}

// --- Projector (J's service) --------------------------------------------
// --- Projector (J's service, pm-stack/projector) -------------------------
// The wire schema is the projector's schema.sql: snake_case fields, `state`
// is only open|closed with `merged` as a separate 0/1 flag, and
// `labels`/`assignees` are JSON array *strings*. Map rows to the read model
// here; nothing else in the app sees the wire shape.

export interface ProjectorItemRow {
ref: string;
repo: string;
number: number;
kind: "issue" | "pr";
url: string;
title: string;
state: "open" | "closed";
state_reason?: string | null;
draft?: number | boolean;
merged?: number | boolean;
author?: string | null;
labels?: string | string[];
assignees?: string | string[];
created_at: string;
updated_at: string;
closed_at?: string | null;
merged_at?: string | null;
}

async function projectorItems(baseUrl: string, refs: RefKey[]): Promise<Map<RefKey, GhItem> | null> {
const r = await getJson(`${baseUrl}/items?refs=${refs.join(",")}`);
export interface ProjectorCommentRow {
id: string;
ref: string;
kind: Comment["kind"];
author?: string | null;
body?: string | null;
created_at: string;
url?: string;
path?: string | null;
line?: number | null;
review_state?: string | null;
commit_id?: string | null;
deleted?: number | boolean;
}

export interface ProjectorLinkRow {
subject: string;
ref: string;
relation: string;
note?: string | null;
created_by?: string | null;
created_at?: string;
}

const asBool = (v: number | boolean | undefined): boolean => v === 1 || v === true;

export function parseProjectorLabels(labels: string | string[] | undefined): string[] {
if (Array.isArray(labels)) return labels;
if (typeof labels !== "string" || labels === "") return [];
try {
const parsed: unknown = JSON.parse(labels);
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}

export function mapProjectorItem(row: ProjectorItemRow): GhItem {
return {
ref: row.ref,
kind: row.kind,
url: row.url,
title: row.title,
state: asBool(row.merged) ? "merged" : row.state,
draft: asBool(row.draft),
labels: parseProjectorLabels(row.labels),
author: row.author ?? "",
updatedAt: row.updated_at,
};
}

export function mapProjectorComment(row: ProjectorCommentRow): Comment {
return {
id: String(row.id),
ref: row.ref,
kind: row.kind,
author: row.author ?? "",
body: row.body ?? "",
createdAt: row.created_at,
url: row.url ?? refUrl(row.ref, row.kind === "issue_comment" ? "issue" : "pr"),
path: row.path ?? undefined,
line: row.line ?? undefined,
reviewState: row.review_state ?? undefined,
deleted: asBool(row.deleted),
};
}

/** Union two link lists, deduped by ref+relation (first relation wins). */
export function mergeLinks(existing: Link[], extra: Link[]): Link[] {
const seen = new Set(existing.map((l) => `${l.ref}|${l.relation}`));
const out = [...existing];
for (const l of extra) {
const key = `${l.ref}|${l.relation}`;
if (!seen.has(key)) {
seen.add(key);
out.push(l);
}
}
return out;
}

const RELATIONS = new Set<string>(["tracks", "implements", "relates"]);
const asRelation = (v: string): Link["relation"] => (RELATIONS.has(v) ? (v as Link["relation"]) : "relates");

export async function projectorItems(baseUrl: string, refs: RefKey[]): Promise<Map<RefKey, GhItem> | null> {
if (refs.length === 0) return new Map();
// Refs contain `#` — they must be percent-encoded or the `#` starts a
// fragment and the projector never sees the number.
const r = await getJson(`${baseUrl}/items?refs=${encodeURIComponent(refs.join(","))}`);
if (!r.ok) return null;
const items = (r.json as { items?: GhItem[] }).items ?? [];
return new Map(items.map((i) => [i.ref, i]));
const rows = (r.json as { items?: ProjectorItemRow[] }).items ?? [];
return new Map(rows.map((row) => [row.ref, mapProjectorItem(row)] as const));
}

async function projectorComments(baseUrl: string, refs: RefKey[]): Promise<Comment[] | null> {
const r = await getJson(`${baseUrl}/comments?refs=${refs.join(",")}`);
export async function projectorComments(baseUrl: string, refs: RefKey[]): Promise<Comment[] | null> {
if (refs.length === 0) return [];
const r = await getJson(`${baseUrl}/comments?refs=${encodeURIComponent(refs.join(","))}`);
if (!r.ok) return null;
return ((r.json as { comments?: Comment[] }).comments ?? []).filter((c) => !c.deleted);
return ((r.json as { comments?: ProjectorCommentRow[] }).comments ?? [])
.map(mapProjectorComment)
.filter((c) => !c.deleted);
}

/** Registry links for one subject (kaneo:/grist:/gh:), from GET /links. */
export async function projectorLinks(baseUrl: string, subject: string): Promise<Link[]> {
const r = await getJson(`${baseUrl}/links?subject=${encodeURIComponent(subject)}`);
if (!r.ok) return [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve registry-fetch failures in source provenance

When /links returns an error or the projector is unreachable, this returns the same empty list as a successful lookup with no links. Both enrichment callers then unconditionally report + projector links in their provenance. Verified with a Grist stub returning one row and /links returning HTTP 503: the roadmap had no registry links but reported live (Grist + projector links). This silently hides cross-repository references while the footer and /api/health indicate successful enrichment. Preserve the failure signal and report degraded registry availability while retaining the live Kaneo/Grist data.

Useful? React with 👍 / 👎.

const rows = (r.json as { links?: ProjectorLinkRow[] }).links ?? [];
return rows.map((row) => ({ ref: row.ref, relation: asRelation(row.relation) }));
}

/**
* Enrich board items / roadmap rows with their cross-repo registry links.
* This is the first-class requirement Kaneo cannot provide: the registry is
* where a `code` task's link to an `opencode` PR lives.
*/
async function enrichWithRegistryLinks<T extends { links: Link[] }>(
baseUrl: string,
entries: T[],
subjectOf: (entry: T) => string,
): Promise<T[]> {
return Promise.all(
entries.map(async (entry) => {
const extra = await projectorLinks(baseUrl, subjectOf(entry));
return extra.length ? { ...entry, links: mergeLinks(entry.links, extra) } : entry;
}),
);
}

// --- Composition ----------------------------------------------------------
Expand Down Expand Up @@ -212,6 +365,11 @@ export function liveSource(cfg: Config): Source {
const board = await kaneoBoard(cfg, cookie);
if (board.length > 0) {
provenance.board = "live (Kaneo)";
if (cfg.projector.baseUrl) {
const enriched = await enrichWithRegistryLinks(cfg.projector.baseUrl, board, (b) => b.id);
provenance.board = "live (Kaneo + projector links)";
return enriched;
}
return board;
}
} catch (err) {
Expand All @@ -224,6 +382,11 @@ export function liveSource(cfg: Config): Source {
const rows = await gristRoadmap(cfg);
if (rows.length > 0) {
provenance.roadmap = "live (Grist)";
if (cfg.projector.baseUrl) {
const enriched = await enrichWithRegistryLinks(cfg.projector.baseUrl, rows, (r) => r.subject);
provenance.roadmap = "live (Grist + projector links)";
return enriched;
}
return rows;
}
} catch (err) {
Expand Down
Loading
Loading