Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/cli/test/db-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("backup.sh + reset-db.sh round-trip", () => {

// The restored db is intact: schema migrated, and the seeded row survived.
const db = openAndMigrate(join(home, "db.sqlite3"));
expect(currentSchemaVersion(db)).toBe(9);
expect(currentSchemaVersion(db)).toBe(10);
const row = db.query("SELECT id FROM workflows WHERE id = 'wf-keep'").get();
expect(row).toEqual({ id: "wf-keep" });
db.close();
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ export type BootstrapSettings = {
installedAt: string;
};

/**
* The `[epic_store]` section — selects where a repo's Epics + dispatch state live.
* Absent (or `mode = "github"`) means the default GitHub-backed store (Epics are
* issues, state is a state issue). `mode = "file"` is the file-backed store (#190):
* Epics are Markdown files under `epicsDir` and the ranked dispatch state is
* `stateFile`. Mirrors the DB `repo_config` columns (migration 008) — `mm init`
* writes both; the config-toml copy is what config-only callers (the recommender
* run resolution) read to learn a repo is file-mode without a DB handle (#200).
*/
export type EpicStoreSettings = {
mode: "github" | "file";
/** Repo-relative Epic directory (file mode). */
epicsDir?: string;
/** Repo-relative ranked-state file (file mode). */
stateFile?: string;
};

/**
* The `[staleness]` section — per-repo overrides for the anti-staleness drift
* check. `spec_path` is the repo-relative build-spec path the check reads; omit
Expand All @@ -125,6 +142,7 @@ export type MiddleConfig = {
limits?: LimitsSettings;
recommender?: RecommenderSettings;
stateIssue?: StateIssueSettings;
epicStore?: EpicStoreSettings;
bootstrap?: BootstrapSettings;
docs?: DocsSettings;
staleness?: StalenessSettings;
Expand Down Expand Up @@ -281,6 +299,19 @@ function mapBootstrap(raw: RawTable): BootstrapSettings | undefined {
return { version: b.version as number, installedAt: b.installed_at as string };
}

function mapEpicStore(raw: RawTable): EpicStoreSettings | undefined {
if (!isPlainObject(raw.epic_store)) return undefined;
const e = raw.epic_store;
// Anything other than the explicit "file" string is the github default — a
// typo'd mode must never silently route a repo to the file store.
const mode = e.mode === "file" ? "file" : "github";
return {
mode,
epicsDir: e.epics_dir as string | undefined,
stateFile: e.state_file as string | undefined,
};
}

/**
* Map the `[docs]` section. Unlike the strict per-repo mappers, the bot fields
* default rather than trust presence — a tool/path-only override block (the
Expand Down Expand Up @@ -338,6 +369,7 @@ export function loadConfig(opts: LoadConfigOptions): MiddleConfig {
limits: mapLimits(merged),
recommender: mapRecommender(merged),
stateIssue: mapStateIssue(merged),
epicStore: mapEpicStore(merged),
bootstrap: mapBootstrap(merged),
docs: mapDocs(merged),
staleness: mapStaleness(merged),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type {
LimitsSettings,
RecommenderSettings,
StateIssueSettings,
EpicStoreSettings,
BootstrapSettings,
DocsSettings,
StalenessSettings,
Expand Down
27 changes: 21 additions & 6 deletions packages/dashboard/src/app/components/Epics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import { useState } from "react";
import type { EpicCard } from "../../wire.ts";
import { EpicRef } from "./EpicRef.tsx";

function ProgressBar({ closed, total }: { closed: number; total: number }) {
const pct = total > 0 ? Math.round((closed / total) * 100) : 0;
Expand Down Expand Up @@ -36,7 +37,11 @@ function DispatchControl({
// (it isn't a configured/dispatchable adapter, so the server would reject it anyway).
const slot = card.dispatch.freeSlots.find((s) => s.adapter === adapter);
const noSlot = slot ? !slot.available : true;
const disabled = card.dispatch.inFlight || noSlot;
// A file-mode Epic (null number) has no numeric handle for the dashboard's
// numeric dispatch route; force-dispatch it from the CLI (`mm dispatch <slug>`).
// It's still browsable here — only the in-dashboard dispatch button is gated.
const isFileEpic = card.number === null;
const disabled = card.dispatch.inFlight || noSlot || isFileEpic;
return (
<div className="epic-dispatch">
<select
Expand All @@ -54,10 +59,20 @@ function DispatchControl({
</select>
<button
type="button"
aria-label={`Dispatch Epic #${card.number}`}
aria-label={`Dispatch Epic ${card.ref}`}
disabled={disabled}
title={card.dispatch.inFlight ? "already in flight" : noSlot ? "no free slot" : ""}
onClick={() => onDispatch(card.repo, card.number, adapter)}
title={
isFileEpic
? "file-mode Epic — dispatch from the CLI: mm dispatch " + card.ref
: card.dispatch.inFlight
? "already in flight"
: noSlot
? "no free slot"
: ""
}
onClick={() => {
if (card.number !== null) onDispatch(card.repo, card.number, adapter);
}}
>
dispatch
</button>
Expand All @@ -84,10 +99,10 @@ export function Epics({
) : (
<ul>
{epics.map((card) => (
<li key={`${card.repo}#${card.number}`} className="epic-card" data-epic={card.number}>
<li key={`${card.repo}#${card.ref}`} className="epic-card" data-epic={card.ref}>
<div className="epic-head">
<span className="epic-title">
#{card.number} {card.title}
<EpicRef epicNumber={card.number} epicRef={card.ref} /> {card.title}
</span>
{card.runner ? (
<button
Expand Down
28 changes: 20 additions & 8 deletions packages/dashboard/src/db-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,16 +254,18 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps {
.get(session, session) as WorkflowRow | null;
}

/** The non-terminal implementation workflow owning an Epic, if any. */
function workflowForEpic(repo: string, epicNumber: number): WorkflowRow | null {
/** The non-terminal implementation workflow owning an Epic, if any. Keyed on the
* canonical `epic_ref` (migration 009) so it resolves both github numbers and
* file-mode slugs. */
function workflowForEpic(repo: string, epicRef: string): WorkflowRow | null {
const placeholders = TERMINAL_STATES.map(() => "?").join(", ");
return db
.query(
`SELECT ${WORKFLOW_COLUMNS} FROM workflows
WHERE repo = ? AND epic_number = ? AND kind = 'implementation' AND state NOT IN (${placeholders})
WHERE repo = ? AND epic_ref = ? AND kind = 'implementation' AND state NOT IN (${placeholders})
ORDER BY created_at DESC LIMIT 1`,
)
.get(repo, epicNumber, ...TERMINAL_STATES) as WorkflowRow | null;
.get(repo, epicRef, ...TERMINAL_STATES) as WorkflowRow | null;
}

/** Slot limits for `hasFreeSlot`, from the merged config. */
Expand Down Expand Up @@ -327,11 +329,20 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps {
available: hasFreeSlot(state, adapter),
}));
return rows.map((row) => {
const wf = workflowForEpic(repo, row.number);
const need = parsed?.needsHumanInput.find((i) => i.issue === row.number) ?? null;
const blocked = parsed?.blocked.find((b) => b.issue === row.number) ?? null;
const wf = workflowForEpic(repo, row.ref);
// Decision/ready joins key on the GitHub number; a file-mode Epic (null
// number) carries its own decisions in the file state, not this state
// issue, so these naturally yield null for it (no match).
const need =
row.number === null
? null
: (parsed?.needsHumanInput.find((i) => i.issue === row.number) ?? null);
const blocked =
row.number === null
? null
: (parsed?.blocked.find((b) => b.issue === row.number) ?? null);
const ready = parsed?.readyToDispatch.find(
(r) => Number(r.epic.replace(/^#/, "").split(/\s/)[0]) === row.number,
(r) => r.epic.replace(/^#/, "").split(/\s/)[0] === row.ref,
);
let decision: EpicCard["decision"] = null;
if (need) {
Expand All @@ -349,6 +360,7 @@ export function createDbDeps(opts: DbDepsOptions): DashboardDeps {
}
return {
repo,
ref: row.ref,
number: row.number,
title: row.title,
progress: { closed: row.subClosed, total: row.subTotal },
Expand Down
8 changes: 7 additions & 1 deletion packages/dashboard/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,13 @@ export type AttachResult = {
/** One Epic card in the Epic-centric browse view — cache + workflows + state-issue join. */
export type EpicCard = {
repo: string;
number: number;
/**
* Canonical Epic reference: the numeric string in github mode, the slug in file
* mode. The SPA renders it via `<EpicRef>` (a `#N` label or a `file://` link).
*/
ref: string;
/** GitHub issue number, or null for a file-mode Epic (renders as a file:// slug link). */
number: number | null;
title: string;
/** Sub-issue progress from the cache. */
progress: { closed: number; total: number };
Expand Down
4 changes: 2 additions & 2 deletions packages/dashboard/test/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ test("api.epics reads Epic cards from a live server", async () => {
const { db, cleanup } = makeDb();
try {
db.run(
`INSERT INTO epics (repo, number, title, state, labels_json, sub_total, sub_closed, last_refreshed)
VALUES ('o/r', 247, 'OAuth refresh', 'open', '[]', 4, 2, 0)`,
`INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed)
VALUES ('o/r', '247', 247, 'OAuth refresh', 'open', '[]', 4, 2, 0)`,
);
seedWorkflow(db, {
id: "wf1",
Expand Down
46 changes: 43 additions & 3 deletions packages/dashboard/test/epics-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,25 @@ function seedEpic(
labels: string[] = [],
): void {
db.run(
`INSERT INTO epics (repo, number, title, state, labels_json, sub_total, sub_closed, last_refreshed)
VALUES (?, ?, ?, 'open', ?, ?, ?, 0)`,
[repo, number, title, JSON.stringify(labels), total, closed],
`INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, 0)`,
[repo, String(number), number, title, JSON.stringify(labels), total, closed],
);
}

/** Seed a file-mode Epic row (slug ref, null number) into the browse cache. */
function seedFileEpic(
repo: string,
ref: string,
title: string,
total: number,
closed: number,
labels: string[] = [],
): void {
db.run(
`INSERT INTO epics (repo, ref, number, title, state, labels_json, sub_total, sub_closed, last_refreshed)
VALUES (?, ?, NULL, ?, 'open', ?, ?, ?, 0)`,
[repo, ref, title, JSON.stringify(labels), total, closed],
);
}

Expand Down Expand Up @@ -179,6 +195,30 @@ describe("createDbDeps.listEpics", () => {
});
});

test("surfaces a file-mode Epic (slug ref, null number) and resolves its runner by ref (#200)", async () => {
seedFileEpic("o/r", "rollout-epic-store", "Roll out the store", 5, 2, ["epic"]);
seedWorkflow(db, {
id: "wf-file",
repo: "o/r",
epicRef: "rollout-epic-store", // file-mode slug, no numeric epicNumber
adapter: "claude",
state: "running",
sessionName: "o-r-rollout",
currentSubIssue: 3,
});
const deps = createDbDeps({ db, config: makeConfig() });
const cards = await deps.listEpics("o/r");
expect(cards).toHaveLength(1);
const c = cards[0]!;
// The card carries the slug ref and a null number (renders as a file:// link).
expect(c.ref).toBe("rollout-epic-store");
expect(c.number).toBeNull();
expect(c.progress).toEqual({ closed: 2, total: 5 });
// The runner is resolved by epic_ref, not epic_number.
expect(c.runner).toMatchObject({ adapter: "claude", state: "running", currentSubIssue: 3 });
expect(c.dispatch.inFlight).toBe(true);
});

test("dispatchEpic + refreshEpics delegate to the injected callbacks", async () => {
const deps = createDbDeps({
db,
Expand Down
17 changes: 17 additions & 0 deletions packages/dashboard/test/epics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EpicCard } from "../src/wire.ts";

const card = (over: Partial<EpicCard> = {}): EpicCard => ({
repo: "o/r",
ref: "247",
number: 247,
title: "OAuth refresh",
progress: { closed: 2, total: 4 },
Expand Down Expand Up @@ -38,6 +39,22 @@ describe("Epics", () => {
expect(out).toContain("No open Epics for this repo.");
});

test("a file-mode Epic renders a file:// slug link and disables in-dashboard dispatch (#200)", () => {
const out = html(
card({ number: null, ref: "rollout-epic-store", title: "Roll out the store" }),
);
// Browsable: the slug renders as a file:// link to the on-disk Epic file.
expect(out).toContain('href="file://planning/epics/rollout-epic-store.md"');
expect(out).toContain("rollout-epic-store");
expect(out).toContain("Roll out the store");
// No phantom `#null`.
expect(out).not.toContain("#null");
// Force-dispatch is CLI-only for a file Epic — the button is disabled and
// points at the working path.
expect(out).toContain("disabled");
expect(out).toContain("mm dispatch rollout-epic-store");
});

test("disables dispatch when in flight", () => {
const out = html(
card({
Expand Down
32 changes: 20 additions & 12 deletions packages/dispatcher/src/auto-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ export type AutoDispatchDeps = {
/**
* Enqueue one implementation workflow. Returns the workflow id, or `null` if
* the enqueue was refused (e.g. the Epic already has an active workflow — the
* collision guard). A refused enqueue must NOT consume a local slot.
* collision guard). A refused enqueue must NOT consume a local slot. `epicRef`
* is the dispatch unit: a numeric Epic number (github mode) or a file-mode slug.
*/
enqueue: (input: { repo: string; epicNumber: number; adapter: string }) => Promise<string | null>;
enqueue: (input: { repo: string; epicRef: string; adapter: string }) => Promise<string | null>;
};

/** What an auto-dispatch pass enqueued, and why it stopped. */
export type AutoDispatchResult = {
/** The Epics enqueued this pass, in dispatch order. */
enqueued: { epicNumber: number; adapter: string }[];
/** The Epics enqueued this pass, in dispatch order (`epicRef`: number or slug). */
enqueued: { epicRef: string; adapter: string }[];
/**
* - `disabled` — the repo's auto-dispatch is off (or paused); nothing was read.
* - `slots-exhausted` — the loop stopped because the repo or global total filled.
Expand All @@ -65,10 +66,17 @@ export function didReadState(result: AutoDispatchResult): boolean {
return result.reason !== "disabled";
}

/** Extract the leading `#<n>` Epic number from a Ready row's `epic` cell, or null. */
function parseEpicNumber(epic: string): number | null {
const match = /^#(\d+)\b/.exec(epic.trim());
return match ? Number(match[1]) : null;
/**
* Extract the leading `#<ref>` Epic reference from a Ready row's `epic` cell, or
* null. The cell is `#<ref> <title>`, so `<ref>` is everything up to the first
* whitespace: a numeric Epic number in github mode (`#42`) or a file-mode Epic
* slug (`#rollout-epic-store`, `#v1.2-rollout` — a file stem is not constrained
* to `[\w-]`). The dispatch path is ref-agnostic — `startDispatchImpl` already
* takes an `epicRef` string (#200).
*/
function parseEpicRef(epic: string): string | null {
const match = /^#(\S+)/.exec(epic.trim());
return match ? match[1]! : null;
}

/**
Expand Down Expand Up @@ -127,8 +135,8 @@ export async function autoDispatch(deps: AutoDispatchDeps): Promise<AutoDispatch
const enqueued: AutoDispatchResult["enqueued"] = [];

for (const row of state.readyToDispatch) {
const epicNumber = parseEpicNumber(row.epic);
if (epicNumber === null) continue; // a malformed / empty-state cell — never dispatch it
const epicRef = parseEpicRef(row.epic);
if (epicRef === null) continue; // a malformed / empty-state cell — never dispatch it
// Repo or global full → no further row (for any adapter) can dispatch; stop.
if (slots.global.available <= 0 || slots.repo.available <= 0) {
return { enqueued, reason: "slots-exhausted" };
Expand All @@ -137,9 +145,9 @@ export async function autoDispatch(deps: AutoDispatchDeps): Promise<AutoDispatch
if (rateLimited.has(row.adapter)) continue;
if (!hasFreeSlot(slots, row.adapter)) continue; // adapter cap exhausted (repo/global checked)

const workflowId = await deps.enqueue({ repo: deps.repo, epicNumber, adapter: row.adapter });
const workflowId = await deps.enqueue({ repo: deps.repo, epicRef, adapter: row.adapter });
if (workflowId === null) continue; // refused (collision) → don't charge a local slot
enqueued.push({ epicNumber, adapter: row.adapter });
enqueued.push({ epicRef, adapter: row.adapter });
slots = reserveSlot(slots, row.adapter); // local decrement so the next row sees fresh headroom
}

Expand Down
Loading