feat(markdown-backlog): close the sync loop with DB → frontmatter export
Until now the markdown importer was one-way (plans/*.md → DB). Any
agent-driven status flip via claim_task / complete_task would be
clobbered on the next importer sweep. This change closes the loop: the
DB now projects status, priority, agent_prompt, and updated_at back
into the file's frontmatter, preserving body bytes, key order, and
every other frontmatter key.
New: packages/database/src/markdown-backlog/export.ts
- `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases
(round-trip identity, status flip, priority flip, agent_prompt
null/block-scalar/single-line variants, body preservation,
trailing-newline preservation, idempotent re-application).
- `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic
write (tmp + rename) and tenant fencing. Returns a structured
result so callers can surface what happened in their response.
Wired into:
- `claim_task` MCP tool — exports on the ready → in_progress flip.
- `complete_task` MCP tool — exports on any finalStatus transition.
- `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt
edits made through the app UI.
Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`,
plus a copy in `import-markdown-backlog.ts`): walk up from the source
file looking for `pnpm-workspace.yaml`, falling back to env var or cwd.
This fixes a class of bug where `pnpm --filter <pkg>` cd's into the
package directory and breaks naive cwd-based path resolution — the
importer was deleting all 44 rows during smoke testing before this fix
because it found zero files in `packages/database/plans/`.
`config/CursorSync.md`: documents the new two-way contract, the
DB-wins-on-allow-list conflict policy, and the
MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments
where `plans/` isn't checked out.
Smoke verified end-to-end against the homelab DB: claim flips file
status to in_progress, complete flips it back to ready, importer
round-trips with stable content_hash (true no-op), agent identity
preserved throughout.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fc2235a346
commit
56b697b81c
12 changed files with 764 additions and 7 deletions
48
apps/mcp-server/src/lib/repo-root.ts
Normal file
48
apps/mcp-server/src/lib/repo-root.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Resolve the repo root for the markdown exporter. Three tiers in order:
|
||||
*
|
||||
* 1. `MARKDOWN_BACKLOG_REPO_ROOT` env var — explicit override. Setting
|
||||
* it to `off` / `0` / empty disables the export side entirely
|
||||
* (used in production where `plans/` isn't checked out).
|
||||
* 2. Walk up from this source file looking for `pnpm-workspace.yaml`.
|
||||
* Stable in both tsx (`src/`) and tsup (`dist/`) layouts.
|
||||
* 3. `process.cwd()` — last-ditch fallback for unusual launch contexts.
|
||||
*
|
||||
* Why not just `process.cwd()`? Pnpm filters change into the workspace
|
||||
* directory before invoking the script (`apps/mcp-server` for us), so
|
||||
* cwd-based resolution would look for `plans/` inside the MCP server's
|
||||
* own folder.
|
||||
*/
|
||||
// @ts-nocheck — Node built-ins; this package's tsconfig lacks @types/node.
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const WORKSPACE_MARKER = "pnpm-workspace.yaml";
|
||||
const MAX_WALK = 10;
|
||||
|
||||
function findByMarker(start: string): string | null {
|
||||
let dir = start;
|
||||
for (let i = 0; i < MAX_WALK; i++) {
|
||||
if (existsSync(join(dir, WORKSPACE_MARKER))) return dir;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveRepoRoot(): string | null {
|
||||
const env = process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim();
|
||||
if (env === "" || env === "0" || env?.toLowerCase() === "off") return null;
|
||||
if (env && env.length > 0) return env;
|
||||
|
||||
const fileDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fromFile = findByMarker(fileDir);
|
||||
if (fromFile) return fromFile;
|
||||
|
||||
const fromCwd = findByMarker(process.cwd());
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
return process.cwd();
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
} from "../schema.js";
|
||||
import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js";
|
||||
import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts";
|
||||
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
|
||||
import { resolveRepoRoot } from "../lib/repo-root.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
/**
|
||||
|
|
@ -254,6 +256,42 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
backlogItemId: input.backlogItemId,
|
||||
});
|
||||
|
||||
// Only export when the row's status actually changed. On a
|
||||
// no-transition claim (already in_progress / blocked / done)
|
||||
// the file already matches the DB and a write would just bump
|
||||
// updated_at for no reason.
|
||||
let exportSummary:
|
||||
| { changed: boolean; repoPath: string }
|
||||
| { error: string }
|
||||
| null = null;
|
||||
const repoRoot = resolveRepoRoot();
|
||||
if (repoRoot && shouldTransition) {
|
||||
try {
|
||||
const exportResult = await exportBacklogItemToMarkdown(db, {
|
||||
workspaceId: ws.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
repoRootAbs: repoRoot,
|
||||
});
|
||||
if (exportResult.ok) {
|
||||
exportSummary = {
|
||||
changed: exportResult.changed,
|
||||
repoPath: exportResult.repoPath,
|
||||
};
|
||||
} else {
|
||||
exportSummary = {
|
||||
error: `${exportResult.reason}: ${exportResult.detail}`,
|
||||
};
|
||||
console.error(
|
||||
`[claim_task] markdown export failed (${exportResult.reason}): ${exportResult.detail}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
exportSummary = { error: msg };
|
||||
console.error("[claim_task] markdown export threw:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
runId: result.id,
|
||||
reused: false,
|
||||
|
|
@ -266,6 +304,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
|||
status: shouldTransition ? "in_progress" : priorStatus,
|
||||
},
|
||||
workspace: { id: ws.id, slug: ws.slug, name: ws.name },
|
||||
export: exportSummary,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { z } from "zod";
|
|||
import { db } from "../db.js";
|
||||
import { eq } from "../drizzle.js";
|
||||
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
|
||||
import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts";
|
||||
import { resolveRepoRoot } from "../lib/repo-root.js";
|
||||
import { toolCatch, toolErr, toolOk } from "./tool-result.js";
|
||||
|
||||
const outcomeSchema = z.enum(["succeeded", "failed", "cancelled", "stalled"]);
|
||||
|
|
@ -156,12 +158,50 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
|||
});
|
||||
});
|
||||
|
||||
// Project the new state back to the markdown file. This closes
|
||||
// the sync loop so a subsequent importer pass is a no-op. We do
|
||||
// this OUTSIDE the txn because writing to disk inside a DB
|
||||
// transaction would hold locks across an I/O call; the worst
|
||||
// case here is "DB updated but file write failed," which the
|
||||
// operator can reconcile by re-running the importer in either
|
||||
// direction.
|
||||
let exportSummary:
|
||||
| { changed: boolean; repoPath: string }
|
||||
| { error: string }
|
||||
| null = null;
|
||||
const repoRoot = resolveRepoRoot();
|
||||
if (repoRoot && finalStatus !== null) {
|
||||
try {
|
||||
const result = await exportBacklogItemToMarkdown(db, {
|
||||
workspaceId: run.workspaceId,
|
||||
backlogItemId: run.backlogItemId,
|
||||
repoRootAbs: repoRoot,
|
||||
});
|
||||
if (result.ok) {
|
||||
exportSummary = {
|
||||
changed: result.changed,
|
||||
repoPath: result.repoPath,
|
||||
};
|
||||
} else {
|
||||
exportSummary = { error: `${result.reason}: ${result.detail}` };
|
||||
console.error(
|
||||
`[complete_task] markdown export failed (${result.reason}): ${result.detail}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
exportSummary = { error: msg };
|
||||
console.error("[complete_task] markdown export threw:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return toolOk({
|
||||
runId: input.runId,
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
finalStatus: finalStatus ?? null,
|
||||
tokensTotal,
|
||||
outcome: input.outcome,
|
||||
export: exportSummary,
|
||||
});
|
||||
} catch (e) {
|
||||
return toolCatch(e);
|
||||
|
|
|
|||
44
apps/web/server/lib/repo-root.ts
Normal file
44
apps/web/server/lib/repo-root.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Resolve the repo root for the markdown exporter when called from tRPC
|
||||
* mutations. Sibling of `apps/mcp-server/src/lib/repo-root.ts`; same
|
||||
* contract (env var > pnpm-workspace.yaml walk > cwd), but lives here
|
||||
* because the web app's tsconfig and bundle layout differ enough from
|
||||
* the MCP server's that a single shared helper would need its own
|
||||
* package. Duplication is cheap; the function is twenty lines.
|
||||
*
|
||||
* In production (Coolify) there is no `plans/` directory and no
|
||||
* `pnpm-workspace.yaml` in the bundled image, so the walk returns null
|
||||
* and the exporter is skipped cleanly.
|
||||
*/
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const WORKSPACE_MARKER = "pnpm-workspace.yaml";
|
||||
const MAX_WALK = 10;
|
||||
|
||||
function findByMarker(start: string): string | null {
|
||||
let dir = start;
|
||||
for (let i = 0; i < MAX_WALK; i++) {
|
||||
if (existsSync(join(dir, WORKSPACE_MARKER))) return dir;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveRepoRoot(): string | null {
|
||||
const env = process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim();
|
||||
if (env === "" || env === "0" || env?.toLowerCase() === "off") return null;
|
||||
if (env && env.length > 0) return env;
|
||||
|
||||
const fileDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fromFile = findByMarker(fileDir);
|
||||
if (fromFile) return fromFile;
|
||||
|
||||
const fromCwd = findByMarker(process.cwd());
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -6,10 +6,12 @@ import { markdownBacklogItems } from "@tasks/database/schema";
|
|||
import {
|
||||
resolveWorkflowPrompt,
|
||||
DEFAULT_AGENT_PROMPT,
|
||||
exportBacklogItemToMarkdown,
|
||||
} from "@tasks/database/markdown-backlog";
|
||||
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
import { recordAudit } from "@/server/lib/audit";
|
||||
import { resolveRepoRoot } from "@/server/lib/repo-root";
|
||||
|
||||
const MAX_PROMPT_LENGTH = 20_000;
|
||||
|
||||
|
|
@ -125,6 +127,30 @@ export const backlogRouter = router({
|
|||
metadata: { length: next?.length ?? 0 },
|
||||
});
|
||||
|
||||
// Project the prompt change back to the markdown file in dev. We
|
||||
// swallow errors here — the DB write already succeeded and the
|
||||
// operator can re-run the importer if the file is out of sync.
|
||||
const repoRoot = resolveRepoRoot();
|
||||
if (repoRoot) {
|
||||
try {
|
||||
const result = await exportBacklogItemToMarkdown(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
backlogItemId: input.backlogItemId,
|
||||
repoRootAbs: repoRoot,
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.warn(
|
||||
`[backlog.updateWorkflowPrompt] markdown export skipped (${result.reason}): ${result.detail}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[backlog.updateWorkflowPrompt] markdown export threw:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,37 @@ This document describes how the application and Cursor should keep **plans, epic
|
|||
| `app_authoritative` | UI/API edits win; export markdown + update Cursor on commit or interval. |
|
||||
| `bidirectional` | Reconcile by `updated_at` and explicit conflict rules (last-write-wins per field or manual resolution). |
|
||||
|
||||
## Two-way markdown contract (current implementation)
|
||||
|
||||
The repo today operates in a **hybrid markdown / DB authoritative** mode that approximates `bidirectional` for the small set of fields agents touch. The contract is:
|
||||
|
||||
| Direction | Trigger | Code path | Fields it can rewrite |
|
||||
|---|---|---|---|
|
||||
| `plans/**.md` → DB | `pnpm --filter @tasks/database import:markdown-backlog` (one-shot) or the optional `watch:markdown-backlog` script | `packages/database/src/markdown-backlog/sync.ts` | **All** frontmatter fields plus body bytes. The importer overwrites the DB row from the file. |
|
||||
| DB → `plans/**.md` | `claim_task` (status flip only), `complete_task` (always when `finalStatus` is set), `backlog.updateWorkflowPrompt` tRPC mutation | `packages/database/src/markdown-backlog/export.ts` | A tight allow-list: `status`, `priority`, `agent_prompt`, `updated_at`. **Body bytes and all other frontmatter keys are preserved verbatim.** |
|
||||
|
||||
### Conflict policy: DB wins on the allow-list, file wins on everything else
|
||||
|
||||
If a human edits `status: ready` → `status: blocked` in the `.md` between two MCP calls, the next `complete_task` will rewrite it (DB → file). If they edit the body, the title, the `tenant_id`, or any other frontmatter key, the exporter leaves their change in place. The next `import:markdown-backlog` run will then pull those file-side changes back into the DB.
|
||||
|
||||
This is a single-operator contract. In a multi-operator setup you'd need a true reconciliation policy with conflict detection (see `Task-export-db-to-markdown-frontmatter` for the deferred design notes); for now it's enough that any one operator picks "edit the file" OR "drive the app" per session.
|
||||
|
||||
### Idempotence guarantees the exporter must keep
|
||||
|
||||
- **No-diff re-export is a no-op.** Running the export with desired state equal to current file state must produce the original bytes byte-for-byte. The pure rewriter in `export.ts` is covered by 10 Vitest cases for this.
|
||||
- **`updated_at` only bumps when something else changes.** A "phantom" rewrite that bumps `updated_at` and nothing else is treated as a bug.
|
||||
- **Atomic writes.** Write to `.tmp` next to the target file, then `rename()`. On POSIX inside the same directory this is atomic — partial reads are impossible.
|
||||
- **Block-literal preservation for `agent_prompt`.** Multi-line prompts get `agent_prompt: |` so diffs stay readable. Single-line stays plain.
|
||||
|
||||
### How to disable the export side
|
||||
|
||||
Set `MARKDOWN_BACKLOG_REPO_ROOT=off` (or `0`, or empty string) in the environment of the MCP server or the Next dev server. Useful when:
|
||||
|
||||
- You're running the app in production (Coolify) where `plans/` isn't checked out.
|
||||
- You're testing a destructive change and want to confirm the file write is the cause without rolling back.
|
||||
|
||||
When unset, the exporter defaults to `process.cwd()` — which works in dev because Cursor spawns the MCP from the repo root.
|
||||
|
||||
## API surface (target)
|
||||
|
||||
Lightweight endpoints or jobs (names indicative):
|
||||
|
|
|
|||
167
packages/database/src/markdown-backlog/export.test.ts
Normal file
167
packages/database/src/markdown-backlog/export.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { rewriteFrontmatter } from "./export";
|
||||
|
||||
/**
|
||||
* Unit coverage for the pure rewriter that powers `exportBacklogItemToMarkdown`.
|
||||
* The DB / FS parts are exercised by the smoke tests in CI / dogfood; here we
|
||||
* only care that the YAML projection logic is round-trip-safe.
|
||||
*/
|
||||
|
||||
const STATUS_READY_FIXTURE = `---
|
||||
kind: task
|
||||
slug: example
|
||||
title: Example task
|
||||
plan_slug: example-plan
|
||||
epic_slug: example-epic
|
||||
status: ready
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-01"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Some body content.
|
||||
`;
|
||||
|
||||
describe("rewriteFrontmatter", () => {
|
||||
it("returns the input byte-for-byte when the desired projection matches", () => {
|
||||
const result = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "ready",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.text).toBe(STATUS_READY_FIXTURE);
|
||||
});
|
||||
|
||||
it("flips status from ready to done and bumps updated_at, preserving other keys", () => {
|
||||
const result = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "done",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.text).toMatch(/^---\nkind: task\n/); // first key preserved
|
||||
expect(result.text).toMatch(/\nstatus: done\n/);
|
||||
expect(result.text).not.toMatch(/\nstatus: ready\n/);
|
||||
expect(result.text).toMatch(/\nowner: unassigned\n/); // unrelated keys untouched
|
||||
expect(result.text).toMatch(/\ncursor_todo_id: null\n/);
|
||||
// updated_at bumped to today (YYYY-MM-DD)
|
||||
expect(result.text).toMatch(
|
||||
/\nupdated_at: ["']?\d{4}-\d{2}-\d{2}["']?\n/,
|
||||
);
|
||||
// Body bytes preserved verbatim
|
||||
expect(result.text).toContain("\n# Task summary\n\nSome body content.\n");
|
||||
});
|
||||
|
||||
it("does not write agent_prompt when desired prompt is null and key is absent", () => {
|
||||
const result = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "ready",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.text).not.toMatch(/\nagent_prompt:/);
|
||||
});
|
||||
|
||||
it("removes agent_prompt key when desired prompt is null and key was present", () => {
|
||||
const withPrompt = STATUS_READY_FIXTURE.replace(
|
||||
'updated_at: "2026-06-01"',
|
||||
'updated_at: "2026-06-01"\nagent_prompt: "be helpful"',
|
||||
);
|
||||
const result = rewriteFrontmatter(withPrompt, {
|
||||
status: "ready",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.text).not.toMatch(/agent_prompt/);
|
||||
// Did not write a literal null value
|
||||
expect(result.text).not.toMatch(/agent_prompt:\s*null/);
|
||||
});
|
||||
|
||||
it("serializes multi-line agent_prompt as a YAML block literal (|)", () => {
|
||||
const prompt = "Line one of the prompt.\nLine two with detail.\nLine three.";
|
||||
const result = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "ready",
|
||||
priority: "P2",
|
||||
workflowPrompt: prompt,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
// The block-literal indicator should appear on the agent_prompt line.
|
||||
expect(result.text).toMatch(/agent_prompt:\s*\|/);
|
||||
expect(result.text).toContain("Line one of the prompt.");
|
||||
expect(result.text).toContain("Line two with detail.");
|
||||
expect(result.text).toContain("Line three.");
|
||||
});
|
||||
|
||||
it("does not bump updated_at when nothing else changed (true no-op)", () => {
|
||||
const result = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "ready",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.text).toMatch(/updated_at: "2026-06-01"/);
|
||||
expect(result.changed).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the body's trailing newline pattern", () => {
|
||||
const noTrailing = STATUS_READY_FIXTURE.replace(/\n$/, ""); // strip trailing newline
|
||||
const result = rewriteFrontmatter(noTrailing, {
|
||||
status: "done",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.text.endsWith("Some body content.")).toBe(true);
|
||||
|
||||
const result2 = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "done",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result2.text.endsWith("Some body content.\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns unchanged when the file has no frontmatter (refuses to invent one)", () => {
|
||||
const noFrontmatter = "# Just a heading\n\nSome content.\n";
|
||||
const result = rewriteFrontmatter(noFrontmatter, {
|
||||
status: "done",
|
||||
priority: "P0",
|
||||
workflowPrompt: "hello",
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.text).toBe(noFrontmatter);
|
||||
});
|
||||
|
||||
it("can update priority alongside status without leaving stale values", () => {
|
||||
const result = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "in_progress",
|
||||
priority: "P0",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.text).toMatch(/\npriority: P0\n/);
|
||||
expect(result.text).not.toMatch(/\npriority: P2\n/);
|
||||
});
|
||||
|
||||
it("is byte-perfect idempotent on a second pass with the same desired state", () => {
|
||||
const first = rewriteFrontmatter(STATUS_READY_FIXTURE, {
|
||||
status: "done",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(first.changed).toBe(true);
|
||||
const second = rewriteFrontmatter(first.text, {
|
||||
status: "done",
|
||||
priority: "P2",
|
||||
workflowPrompt: null,
|
||||
});
|
||||
expect(second.changed).toBe(false);
|
||||
expect(second.text).toBe(first.text);
|
||||
});
|
||||
});
|
||||
325
packages/database/src/markdown-backlog/export.ts
Normal file
325
packages/database/src/markdown-backlog/export.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { readFile, writeFile, rename, access } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { parseDocument, Scalar } from "yaml";
|
||||
|
||||
import type { db as dbClient } from "../client";
|
||||
import { markdownBacklogItems } from "../schema/markdown_backlog";
|
||||
|
||||
type Database = typeof dbClient;
|
||||
|
||||
/**
|
||||
* DB → markdown frontmatter export.
|
||||
*
|
||||
* Sister of `syncMarkdownBacklogScan` (the markdown → DB importer). The
|
||||
* importer copies `plans/**` into the DB; this helper closes the loop by
|
||||
* writing the DB's authoritative fields (status, priority, agent_prompt,
|
||||
* updated_at) back to the on-disk `.md` file's frontmatter, **without
|
||||
* touching the body or any other frontmatter keys**.
|
||||
*
|
||||
* Why this exists: once MCP tools (`claim_task`, `complete_task`) start
|
||||
* writing to `markdown_backlog_items.status`, every subsequent importer
|
||||
* pass would clobber those changes back to whatever the file said. This
|
||||
* exporter makes the file the projection target so the two stores
|
||||
* converge.
|
||||
*
|
||||
* Contract:
|
||||
* - The file MUST already exist. We do not create new `.md` files from
|
||||
* DB rows (no UI today creates backlog items in the DB without a
|
||||
* corresponding markdown).
|
||||
* - Body bytes are preserved verbatim.
|
||||
* - Frontmatter key order, comments, and unrelated keys are preserved
|
||||
* (we use `yaml`'s `Document` API, not a re-serialize from scratch).
|
||||
* - `agent_prompt` is serialized as a YAML block literal (`|`) when
|
||||
* multi-line, plain string otherwise. Setting workflowPrompt to null
|
||||
* removes the key from frontmatter entirely (does NOT write
|
||||
* `agent_prompt: null`, which would carry different semantics).
|
||||
* - `updated_at` is bumped to today only when something else changed.
|
||||
* A re-export of an unchanged row is a byte-perfect no-op.
|
||||
* - File writes are atomic via tmp + rename.
|
||||
*
|
||||
* Conflict policy: DB wins. If a human edited the file between the
|
||||
* last importer run and this export, the canonical fields (status,
|
||||
* priority, agent_prompt) will reflect the DB, not the file. Other
|
||||
* frontmatter keys are preserved. This is documented in
|
||||
* `config/CursorSync.md`.
|
||||
*
|
||||
* Out of scope (deferred to follow-ups):
|
||||
* - Title sync. Titles are typically human-authored; we don't want
|
||||
* `complete_task` ever rewriting them.
|
||||
* - Creating new files for DB-only rows.
|
||||
* - Two-way merge / conflict detection.
|
||||
* - Workspace-level `markdown_export_enabled` flag (currently no
|
||||
* workspace settings table to hook into).
|
||||
*/
|
||||
|
||||
export type ExportResult =
|
||||
| {
|
||||
ok: true;
|
||||
changed: boolean;
|
||||
repoPath: string;
|
||||
absPath: string;
|
||||
bytesWritten: number | null;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason:
|
||||
| "BACKLOG_ITEM_NOT_FOUND"
|
||||
| "REPO_PATH_MISSING"
|
||||
| "FILE_NOT_FOUND"
|
||||
| "ROW_DRIFT";
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type ExportOptions = {
|
||||
workspaceId: string;
|
||||
backlogItemId: string;
|
||||
/** Absolute path to the repo root (the directory containing `plans/`). */
|
||||
repoRootAbs: string;
|
||||
/**
|
||||
* If true, compute the rewrite but don't actually write to disk.
|
||||
* Useful for previewing the change set in tests / orchestrator dry runs.
|
||||
*/
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The canonical scalar fields we project from DB into frontmatter. Anything
|
||||
* not listed here is left alone in the file. Keep this list tight; growing
|
||||
* it is a real product decision (each field becomes a thing the agent can
|
||||
* silently rewrite).
|
||||
*/
|
||||
const PROJECTED_KEYS = ["status", "priority", "agent_prompt", "updated_at"] as const;
|
||||
|
||||
/**
|
||||
* Today's date as a YYYY-MM-DD string. Frontmatter convention in this repo
|
||||
* uses date-only ISO strings for `updated_at`; the importer doesn't enforce
|
||||
* a specific format, but matching the existing templates keeps diffs clean.
|
||||
*/
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function exportBacklogItemToMarkdown(
|
||||
database: Database,
|
||||
options: ExportOptions,
|
||||
): Promise<ExportResult> {
|
||||
const { workspaceId, backlogItemId, repoRootAbs, dryRun = false } = options;
|
||||
|
||||
const [row] = await database
|
||||
.select({
|
||||
id: markdownBacklogItems.id,
|
||||
workspaceId: markdownBacklogItems.workspaceId,
|
||||
repoPath: markdownBacklogItems.repoPath,
|
||||
status: markdownBacklogItems.status,
|
||||
priority: markdownBacklogItems.priority,
|
||||
workflowPrompt: markdownBacklogItems.workflowPrompt,
|
||||
})
|
||||
.from(markdownBacklogItems)
|
||||
.where(eq(markdownBacklogItems.id, backlogItemId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "BACKLOG_ITEM_NOT_FOUND",
|
||||
detail: `No markdown_backlog_items row with id=${backlogItemId}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Tenancy fence. We accept the workspaceId arg so callers can't be
|
||||
// tricked into projecting a row from another tenant by id alone.
|
||||
if (row.workspaceId !== workspaceId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "ROW_DRIFT",
|
||||
detail: "Backlog item belongs to a different workspace than expected",
|
||||
};
|
||||
}
|
||||
|
||||
if (!row.repoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "REPO_PATH_MISSING",
|
||||
detail: "Row has no repo_path; can't locate the file to write",
|
||||
};
|
||||
}
|
||||
|
||||
const absPath = join(repoRootAbs, ...row.repoPath.split("/"));
|
||||
try {
|
||||
await access(absPath);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "FILE_NOT_FOUND",
|
||||
detail: `File does not exist on disk: ${absPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const rawBefore = await readFile(absPath, "utf8");
|
||||
const rewriteResult = rewriteFrontmatter(rawBefore, {
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
workflowPrompt: row.workflowPrompt,
|
||||
});
|
||||
|
||||
if (!rewriteResult.changed) {
|
||||
return {
|
||||
ok: true,
|
||||
changed: false,
|
||||
repoPath: row.repoPath,
|
||||
absPath,
|
||||
bytesWritten: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
changed: true,
|
||||
repoPath: row.repoPath,
|
||||
absPath,
|
||||
bytesWritten: Buffer.byteLength(rewriteResult.text, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWrite(absPath, rewriteResult.text);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changed: true,
|
||||
repoPath: row.repoPath,
|
||||
absPath,
|
||||
bytesWritten: Buffer.byteLength(rewriteResult.text, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-function frontmatter rewriter, exported for unit testing without
|
||||
* touching disk or DB. Returns `{ text, changed }` where `changed` is
|
||||
* false when the desired projection matches what was already on disk —
|
||||
* in which case `text` equals the input verbatim.
|
||||
*
|
||||
* `existingRaw` is the full file contents (frontmatter + body).
|
||||
*/
|
||||
export function rewriteFrontmatter(
|
||||
existingRaw: string,
|
||||
desired: {
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
workflowPrompt: string | null;
|
||||
},
|
||||
): { text: string; changed: boolean } {
|
||||
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?/;
|
||||
const match = existingRaw.match(FRONTMATTER);
|
||||
|
||||
// No frontmatter? We refuse to invent one. Returning unchanged is the
|
||||
// safer default — the importer would have noticed the missing kind/slug
|
||||
// and routed the file out of the tracked set anyway.
|
||||
if (!match) {
|
||||
return { text: existingRaw, changed: false };
|
||||
}
|
||||
|
||||
const yamlText = match[1];
|
||||
const closingNewline = match[2] ?? "\n";
|
||||
const bodyOffset = match[0].length;
|
||||
const body = existingRaw.slice(bodyOffset);
|
||||
|
||||
const doc = parseDocument(yamlText);
|
||||
|
||||
let frontmatterTouched = false;
|
||||
|
||||
// status — write/clear
|
||||
if (setOrDelete(doc, "status", desired.status)) frontmatterTouched = true;
|
||||
// priority — write/clear
|
||||
if (setOrDelete(doc, "priority", desired.priority)) frontmatterTouched = true;
|
||||
// agent_prompt — write as block literal when multi-line, plain when not
|
||||
if (setAgentPrompt(doc, desired.workflowPrompt)) frontmatterTouched = true;
|
||||
|
||||
// Only bump `updated_at` if something else actually changed. This is
|
||||
// what keeps "no real diff" re-exports byte-perfect no-ops.
|
||||
if (frontmatterTouched) {
|
||||
const today = todayIso();
|
||||
const before = doc.get("updated_at");
|
||||
if (typeof before !== "string" || before !== today) {
|
||||
doc.set("updated_at", today);
|
||||
}
|
||||
}
|
||||
|
||||
const newYaml = String(doc).replace(/\n$/, ""); // `yaml` always appends \n; the frontmatter terminator owns the next newline.
|
||||
const rebuilt = `---\n${newYaml}\n---${closingNewline}${body}`;
|
||||
|
||||
if (rebuilt === existingRaw) {
|
||||
return { text: existingRaw, changed: false };
|
||||
}
|
||||
|
||||
return { text: rebuilt, changed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a scalar key when value is non-null, delete it when null. Returns
|
||||
* true if the doc was modified.
|
||||
*/
|
||||
function setOrDelete(
|
||||
doc: ReturnType<typeof parseDocument>,
|
||||
key: string,
|
||||
value: string | null,
|
||||
): boolean {
|
||||
const existing = doc.get(key);
|
||||
if (value === null) {
|
||||
if (doc.has(key)) {
|
||||
doc.delete(key);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (typeof existing === "string" && existing === value) return false;
|
||||
doc.set(key, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* agent_prompt has special serialization: multi-line values become YAML
|
||||
* block literals (`|`) so they're readable; single-line values stay as
|
||||
* plain scalars. Empty / null means "remove the key" — we never write
|
||||
* `agent_prompt: null` because the importer treats explicit null and
|
||||
* missing keys identically and the missing form is less surprising in
|
||||
* a hand-edited file.
|
||||
*/
|
||||
function setAgentPrompt(
|
||||
doc: ReturnType<typeof parseDocument>,
|
||||
value: string | null,
|
||||
): boolean {
|
||||
const KEY = "agent_prompt";
|
||||
if (value === null || value.trim() === "") {
|
||||
if (doc.has(KEY)) {
|
||||
doc.delete(KEY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = doc.get(KEY);
|
||||
if (typeof existing === "string" && existing === value) return false;
|
||||
|
||||
const isMultiline = value.includes("\n");
|
||||
const node = doc.createNode(value);
|
||||
if (node instanceof Scalar) {
|
||||
node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN;
|
||||
}
|
||||
doc.set(KEY, node);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file atomically: write to a sibling tmp file, then rename. The
|
||||
* rename is atomic on POSIX filesystems within the same directory, which
|
||||
* is what we get by putting the tmp file in `dirname(target)`.
|
||||
*/
|
||||
async function atomicWrite(absPath: string, contents: string): Promise<void> {
|
||||
const dir = dirname(absPath);
|
||||
const tmp = join(dir, `.${randomUUID()}.tmp`);
|
||||
await writeFile(tmp, contents, "utf8");
|
||||
await rename(tmp, absPath);
|
||||
}
|
||||
|
|
@ -13,3 +13,9 @@ export {
|
|||
DEFAULT_AGENT_PROMPT,
|
||||
type ResolvedWorkflowPrompt,
|
||||
} from "./resolve-prompt";
|
||||
export {
|
||||
exportBacklogItemToMarkdown,
|
||||
rewriteFrontmatter,
|
||||
type ExportOptions,
|
||||
type ExportResult,
|
||||
} from "./export";
|
||||
|
|
|
|||
|
|
@ -17,17 +17,48 @@
|
|||
* Env:
|
||||
* - DATABASE_URL (required)
|
||||
* - MARKDOWN_BACKLOG_WORKSPACE_ID — UUID of the target workspace (required)
|
||||
* - MARKDOWN_BACKLOG_REPO_ROOT — absolute path to repo root (default: cwd)
|
||||
* - MARKDOWN_BACKLOG_REPO_ROOT — absolute path to repo root (default:
|
||||
* walk up from this script looking for `pnpm-workspace.yaml`, then fall
|
||||
* back to cwd). The walk is necessary because `pnpm --filter @tasks/database`
|
||||
* sets cwd to `packages/database`, NOT the repo root, and a naive cwd
|
||||
* resolution would scan a non-existent `packages/database/plans/`
|
||||
* directory — and then proceed to delete every "stale" row because
|
||||
* it found zero files.
|
||||
*/
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { db } from "../client";
|
||||
import { syncMarkdownBacklogScan } from "../markdown-backlog/sync";
|
||||
|
||||
function findRepoRootByMarker(start: string): string | null {
|
||||
let dir = start;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRepoRoot(): string {
|
||||
const envOverride = process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim();
|
||||
if (envOverride && envOverride.length > 0) return resolve(envOverride);
|
||||
|
||||
const fileDir = dirname(fileURLToPath(import.meta.url));
|
||||
const fromFile = findRepoRootByMarker(fileDir);
|
||||
if (fromFile) return fromFile;
|
||||
|
||||
const fromCwd = findRepoRootByMarker(process.cwd());
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
return resolve(process.cwd());
|
||||
}
|
||||
|
||||
const workspaceId = process.env.MARKDOWN_BACKLOG_WORKSPACE_ID?.trim();
|
||||
const repoRootAbs = resolve(
|
||||
process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim() || process.cwd(),
|
||||
);
|
||||
const repoRootAbs = resolveRepoRoot();
|
||||
|
||||
if (!workspaceId) {
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ slug: export-db-to-markdown-frontmatter
|
|||
title: Export DB state back to markdown frontmatter (close the sync loop)
|
||||
plan_slug: agent-coordination
|
||||
epic_slug: task-as-runnable-unit
|
||||
status: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ priority: P1
|
|||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-01"
|
||||
updated_at: "2026-06-03"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
|
|
|||
Loading…
Reference in a new issue