diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 092bd39..4334e09 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -378,9 +378,12 @@ fields are retired. Ships server-first (deploys); the daemon changes ride the ne ArtifactSync never wrote (and vice versa). Tests mirror the sharing (`retention.test.ts` `gatewayWithStore`). - Import direction: the generic wire plumbing (`HttpResult`, `WIRE_TEXT_CAP`, - `clipText`/`stripNul`, `nowIso`) lives in the leaf module `gateway/http.ts`, - imported by index/cli/sync alike - one clipping/NUL-stripping discipline, no - fork; domain helpers (caps, renders) still flow `index.ts` -> `cli.ts`/`sync.ts`, + `MESSAGE_CAP`, `clipText`/`stripNul`, `nowIso`) lives in the leaf module + `gateway/http.ts`, + imported by index/cli/sync/timelineSeed alike - one clipping/NUL-stripping + discipline, no fork (`index.ts` re-exports `MESSAGE_CAP`, which is where + `cli.ts` has always imported it from); + domain helpers (caps, renders) still flow `index.ts` -> `cli.ts`/`sync.ts`, and `index.ts` never imports its satellites, so there is no cycle. The whole shape is pinned by `gateway/layout.test.ts`. - The legacy `/api/machine/loop` + `/api/machine/log` routes call the owner-verb @@ -489,9 +492,19 @@ fields are retired. Ships server-first (deploys); the daemon changes ride the ne clobbers a rename). `assignee=` resolves registry-first; `/` stays an alias. `loopany team` renders the roster. - **Legacy advance**: every task-file ingest (old-daemon sync/report) also seeds - NEW dated Timeline entries as events, deduped on (day, clipped text) — the - same rule `scripts/migrate-v2-split.ts --execute` used for the one-time - history seed (conservative: doc left byte-identical; re-run is a no-op). + NEW dated Timeline entries as events. `gateway/timelineSeed.ts` is the ONE + seeder both surfaces import — the live ingest (`ingestTaskFileContent`) and + `scripts/migrate-v2-split.ts --execute` — so they cannot dedup differently + (conservative: doc left byte-identical; re-run IS a no-op). Identity is + (day, clipped text) per loop, enforced twice: `store.seededTimelineKeys` (a + KEYED, unbounded DISTINCT query over the seeder's own rows — `type = note` + carrying `data.source = "timeline"`) plus a deterministic row id + + `addEventIfAbsent` (ON CONFLICT DO NOTHING) for races/retries. **Never dedup + seeding against a `listEvents` window**: a seeded row carries the entry's + HISTORICAL `at`, so it is the OLDEST row and ages out of any newest-N window, + after which every sync re-seeds the whole Timeline (the fixed bug — a 4-event + loop hit 230 after one re-sync past 200). Seeding runs on the SYNC path only, + never the idle poll hot path. Guarded by `gateway/timelineSeed.test.ts`. - **Cloud-born create**: `loopany create` writes no local files; the doc rides the registration; createLoop accepts doc-only inert tasks. Folders appear lazily with artifacts. diff --git a/packages/server/scripts/migrate-v2-split.ts b/packages/server/scripts/migrate-v2-split.ts index eaad53e..a226eec 100644 --- a/packages/server/scripts/migrate-v2-split.ts +++ b/packages/server/scripts/migrate-v2-split.ts @@ -18,8 +18,11 @@ * * Usage: DATABASE_URL=… npx tsx scripts/migrate-v2-split.ts [--json | --execute] */ -import { addEvent, listEvents, listLoops, listMachines } from "../src/db/store.js"; +import { pathToFileURL } from "node:url"; + +import { listLoops, listMachines } from "../src/db/store.js"; import { splitTaskDoc } from "../src/server/docSplit.js"; +import { seedTimelineEvents } from "../src/gateway/timelineSeed.js"; import { machinePresence } from "../src/lib/machinePresence.js"; /** Mirrors the gateway's wire-field clip budget (`gateway/http.ts` WIRE_TEXT_CAP). */ @@ -43,7 +46,7 @@ interface RowReport { * (a subtracted front-matter line, whose key the splitter reports as * represented). Dates/actors move into event columns, so a dated line matches * on its text remainder. */ -function lostLines(original: string, doc: string, events: Array<{ text: string }>, representedKeys: string[]): string[] { +export function lostLines(original: string, doc: string, events: Array<{ text: string }>, representedKeys: string[]): string[] { const eventText = events.map((e) => e.text).join("\n"); const represented = new Set(representedKeys); const lost: string[] = []; @@ -54,7 +57,11 @@ function lostLines(original: string, doc: string, events: Array<{ text: string } const fmKey = /^([A-Za-z0-9][A-Za-z0-9_.-]*):\s/.exec(t)?.[1]; if (fmKey && represented.has(fmKey)) continue; // Match on the line's content tail (front-matter values, timeline text, prose). - const tail = t.replace(/^[-*]\s+/, "").replace(/^(\*\*|\[)?\d{4}-\d{2}-\d{2}(\*\*|\])?\s*(\([^)]+\))?\s*[:—–-]?\s*/, ""); + // The separator class MUST match docSplit's TIMELINE_LINE — `|` included: it is + // what the daemon's own appendTimeline writes (`- 2026-07-01 | text`). Omitting + // it left a `| ` glued to every tail, so the splitter's event text never matched + // and the rehearsal reported a false LOSS (exit 1) on every daemon-authored row. + const tail = t.replace(/^[-*]\s+/, "").replace(/^(\*\*|\[)?\d{4}-\d{2}-\d{2}(\*\*|\])?\s*(\([^)]+\))?\s*[:—–|-]?\s*/, ""); const needle = tail || t; if (!doc.includes(needle) && !eventText.includes(needle)) lost.push(t.slice(0, 120)); } @@ -63,45 +70,33 @@ function lostLines(original: string, doc: string, events: Array<{ text: string } /** * Execute mode (CONSERVATIVE, idempotent): seed each row's historical dated - * Timeline entries into the event stream, deduped on (day, text) exactly like - * the live ingest path — the doc itself is left byte-identical (nothing to - * retain, nothing to race: a concurrent watcher flush just re-runs the same - * dedup). Re-running is a no-op. Undated/continuation-only rows seed nothing. + * Timeline entries into the event stream through `gateway/timelineSeed.ts` — the + * SAME module the live ingest path uses, so this rehearsal/execute pass and a + * concurrent watcher flush can never dedup differently. The doc itself is left + * byte-identical (nothing to retain, nothing to race). + * + * Re-running IS a no-op, and now actually is: dedup keys on (day, clipped text) + * against the rows the seeder wrote — a keyed, unbounded query — plus a + * deterministic row id, instead of the newest-200-event window this script used + * to compare against. Seeded rows carry historical `at` values, so on any loop + * with >200 events that window held none of them and the whole Timeline + * re-seeded on every run. Undated/continuation-only rows seed nothing. */ -async function executeSeed(): Promise { +export async function executeSeed(): Promise { const loops = await listLoops(); let rows = 0; let seeded = 0; for (const loop of loops) { const { events } = splitTaskDoc(loop.taskFileContent ?? ""); - const dated = events.filter((e) => e.at); - if (!dated.length) continue; + if (!events.some((e) => e.at)) continue; rows++; - const existing = await listEvents(loop.id, { limit: 200 }); - const seen = new Set(existing.map((e) => `${e.at?.slice(0, 10)}|${e.text ?? ""}`)); - for (const e of dated) { - // Key on the CLIPPED text — the stored row is clipped, so an unclipped - // key would re-seed every over-cap entry on re-run. - const text = e.text.slice(0, 2000); - const key = `${e.at!.slice(0, 10)}|${text}`; - if (seen.has(key)) continue; - seen.add(key); - await addEvent({ - loopId: loop.id, - type: "note", - actor: e.actor ?? "timeline", - at: e.at, - text, - data: { source: "timeline" }, - }); - seeded++; - } + seeded += await seedTimelineEvents(loop.id, loop.taskFileContent, (e) => e.actor ?? "timeline"); } console.log(`seeded ${seeded} events across ${rows} rows (re-run is a no-op)`); return 0; } -async function main(): Promise { +export async function main(): Promise { if (process.argv.includes("--execute")) return executeSeed(); const json = process.argv.includes("--json"); const machines = new Map((await listMachines()).map((m) => [m.id, m])); @@ -148,10 +143,15 @@ async function main(): Promise { return losses.length ? 1 : 0; } -main().then( - (code) => process.exit(code), - (e) => { - console.error(e); - process.exit(1); - }, -); +/** Run only when invoked as a script — importing the module (the idempotency + * regression test does) must not connect to a DB or exit the process. */ +const invokedDirectly = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) { + main().then( + (code) => process.exit(code), + (e) => { + console.error(e); + process.exit(1); + }, + ); +} diff --git a/packages/server/src/db/store.ts b/packages/server/src/db/store.ts index c34b1b2..1ff980a 100644 --- a/packages/server/src/db/store.ts +++ b/packages/server/src/db/store.ts @@ -128,11 +128,44 @@ export async function loopsForTeams(teamIds: string[]): Promise { // ---- events (the append-only per-node record) ---- +type EventInput = Omit & { id?: string; at?: string }; + +function eventRow(input: EventInput): NewEvent { + return { ...input, id: input.id ?? `ev-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`, at: input.at ?? nowIso() }; +} + /** Append one immutable event. The ONLY write path — there is no update/delete * by design; the stream is the record plane the Timeline renders from. */ -export async function addEvent(input: Omit & { id?: string; at?: string }): Promise { - const row: NewEvent = { ...input, id: input.id ?? `ev-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`, at: input.at ?? nowIso() }; - return (await db.insert(events).values(row).returning())[0]!; +export async function addEvent(input: EventInput): Promise { + return (await db.insert(events).values(eventRow(input)).returning())[0]!; +} + +/** Append an event whose caller supplies a DETERMINISTIC id, skipping the insert + * when that id already exists (returns undefined). This is the write half of + * content-keyed idempotency: the id encodes the row's identity, so a retry or a + * concurrent writer converges on one row without a read-then-write race. Used + * by the legacy-Timeline seeder (`gateway/timelineSeed.ts`). */ +export async function addEventIfAbsent(input: EventInput & { id: string }): Promise { + return (await db.insert(events).values(eventRow(input)).onConflictDoNothing().returning())[0]; +} + +/** Every (day, text) identity already seeded into this node's stream from a + * legacy `## Timeline` section — i.e. exactly the rows `gateway/timelineSeed.ts` + * wrote (`type = note` carrying the `timeline` source marker). + * + * Deliberately NOT expressed as a `listEvents` window: a seeded row carries the + * Timeline entry's HISTORICAL `at`, so it sorts OLDEST and drops out of any + * newest-N window once the node accumulates N newer events — which re-seeds the + * whole Timeline on every subsequent sync. The query is keyed and unbounded; + * DISTINCT bounds the result to the number of distinct entries (itself bounded + * by the doc's WIRE_TEXT_CAP), not to the row count, so even an already- + * duplicated stream collapses to one key per entry. */ +export async function seededTimelineKeys(loopId: string): Promise> { + const rows = await db + .selectDistinct({ day: sql`substr(${events.at}, 1, 10)`, text: events.text }) + .from(events) + .where(and(eq(events.loopId, loopId), eq(events.type, "note"), sql`${events.data}->>'source' = 'timeline'`)); + return new Set(rows.map((r) => `${r.day}|${r.text ?? ""}`)); } /** A node's events, NEWEST first, bounded. `since` filters strictly-after (the diff --git a/packages/server/src/gateway/http.ts b/packages/server/src/gateway/http.ts index d77673f..5a78a9a 100644 --- a/packages/server/src/gateway/http.ts +++ b/packages/server/src/gateway/http.ts @@ -59,6 +59,12 @@ export interface HttpResult { * clipping discipline for every large string the daemon can send. */ export const WIRE_TEXT_CAP = 512 * 1024; +/** Run messages (report --message / workflow direct message / finalText fallback) + * and event text. Run errors share the same cap. It lives in this leaf module so + * every consumer — index.ts, cli.ts, timelineSeed.ts — clips to one budget without + * importing the run-lifecycle core (index.ts re-exports it for existing callers). */ +export const MESSAGE_CAP = 2000; + export function nowIso(): string { return new Date().toISOString(); } diff --git a/packages/server/src/gateway/index.ts b/packages/server/src/gateway/index.ts index 9e2768c..1c0556b 100644 --- a/packages/server/src/gateway/index.ts +++ b/packages/server/src/gateway/index.ts @@ -65,7 +65,8 @@ import { type Scalar, } from "./toon.js"; import { validateSchema, validateUi, validateWorkflow } from "./validate.js"; -import { clipText, nowIso, stripNul, WIRE_TEXT_CAP, type HttpResult } from "./http.js"; +import { clipText, MESSAGE_CAP, nowIso, stripNul, WIRE_TEXT_CAP, type HttpResult } from "./http.js"; +import { seedTimelineEvents } from "./timelineSeed.js"; const log = logger.child({ mod: "gateway" }); @@ -175,10 +176,9 @@ const STEP_FIELD_MAX = 4000; /** A workflow cursor bigger than this (serialized) is ignored rather than persisted * onto the loop row — the run itself still records normally. */ const CURSOR_CAP = 256 * 1024; -/** Run messages (report --message / workflow direct message / finalText fallback). - * Run errors share the same cap. Exported for `cli.ts` (the report/finish verbs - * clip to the same budget). */ -export const MESSAGE_CAP = 2000; +/** Run messages / event text share ONE cap, defined in the leaf `http.js`. + * Re-exported here because `cli.ts` (report/finish) imports it from this module. */ +export { MESSAGE_CAP }; /** A claude-code session id is a UUID-ish token — anything longer is garbage. */ const SESSION_ID_CAP = 200; /** A loop's goal (setpoint) is a one-line, checkable statement — clip generously @@ -2226,31 +2226,13 @@ export class MachineGateway { if (!updated) return; // Legacy record advance: a file-era daemon keeps appending to the README's // Timeline section instead of emitting events — seed each NEW dated entry - // into the event stream (dedup on at+text against what's already recorded), - // so the record plane advances for legacy loops too. Best-effort, bounded. + // into the event stream, so the record plane advances for legacy loops too. + // Dedup is keyed on (day, clipped text) against the rows the seeder itself + // wrote — see `timelineSeed.ts` for why a newest-N window cannot work here. + // This runs on the SYNC path (a task-file snapshot landed), never on the + // idle poll hot path. Best-effort. try { - const { events: parsed } = splitTaskDoc(updated.taskFileContent ?? ""); - const dated = parsed.filter((e) => e.at); - if (dated.length) { - const existing = await store.listEvents(loopId, { limit: 200 }); - const seen = new Set(existing.map((e) => `${e.at?.slice(0, 10)}|${e.text ?? ""}`)); - for (const e of dated) { - // Key on the CLIPPED text — the stored row is clipped, so an unclipped - // key would re-seed every over-cap entry on the next sync. - const text = e.text.slice(0, MESSAGE_CAP); - const key = `${e.at!.slice(0, 10)}|${text}`; - if (seen.has(key)) continue; - seen.add(key); - await store.addEvent({ - loopId, - type: "note", - actor: e.actor ?? `agent:${updated.agent}`, - at: e.at, - text, - data: { source: "timeline" }, - }); - } - } + await seedTimelineEvents(loopId, updated.taskFileContent, (e) => e.actor ?? `agent:${updated.agent}`); } catch (err) { log.warn({ loopId, err: err instanceof Error ? err.message : String(err) }, "timeline event seeding failed"); } diff --git a/packages/server/src/gateway/timelineSeed.test.ts b/packages/server/src/gateway/timelineSeed.test.ts new file mode 100644 index 0000000..54d96f5 --- /dev/null +++ b/packages/server/src/gateway/timelineSeed.test.ts @@ -0,0 +1,204 @@ +/** + * Legacy-Timeline seeding idempotency — the regression guard for the unbounded + * event-duplication bug. + * + * The seeding dedup used to compare against `listEvents(loopId, {limit: 200})`, + * the NEWEST 200 rows. Seeded rows carry the Timeline entry's HISTORICAL `at`, + * so they are the OLDEST rows in the stream: once a loop accumulated 200 newer + * events they fell out of the window and every task-file sync re-inserted the + * entire Timeline. Reproduced on a live server as 4 events → 230 after one + * re-sync past the window. + * + * These tests encode exactly that: seed a loop, push it PAST the old 200-row + * window with unrelated events, re-sync the same Timeline, and assert ZERO new + * rows — on both surfaces (the live gateway ingest and the migration script's + * `--execute` pass). The small-stream behaviour is covered alongside so the + * mechanism is proven to still seed. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, expect, test } from "vitest"; + +let tmp: string; +let db: typeof import("../db/index.js"); +let store: typeof import("../db/store.js"); +let gatewayMod: typeof import("./index.js"); +let seedMod: typeof import("./timelineSeed.js"); +let migrate: typeof import("../../scripts/migrate-v2-split.js"); +let tokens: typeof import("./tokens.js"); + +beforeAll(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "loopany-tlseed-")); + process.env.LOOPANY_DATA_DIR = tmp; + process.env.LOOPANY_LOG_LEVEL = "silent"; + db = await import("../db/index.js"); + await db.runMigrations(); + store = await import("../db/store.js"); + gatewayMod = await import("./index.js"); + seedMod = await import("./timelineSeed.js"); + migrate = await import("../../scripts/migrate-v2-split.js"); + tokens = await import("./tokens.js"); +}); + +afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await (db.client as never as { exec(sql: string): Promise }).exec("DELETE FROM events; DELETE FROM runs; DELETE FROM loops; DELETE FROM machines;"); +}); + +const noopScheduler = { + maybeFlagEvolve(): void {}, + finishEvolution(): void {}, + finishEdit(): void {}, + addLoop(): void {}, + removeLoop(): void {}, + runNow(): void {}, +} as never; + +const gateway = (): import("./index.js").MachineGateway => new gatewayMod.MachineGateway(noopScheduler, undefined); + +/** A file-era README: front matter + Spec + an authored `## Timeline`. */ +const readme = (...timeline: string[]): string => + ["---", "id: old-spike", "title: Old spike", "status: todo", "---", "", "## Spec", "Poke at the thing.", "", "## Timeline", ...timeline, ""].join("\n"); + +const TWO_LINES = ["- 2026-07-01 | Created.", "- 2026-07-02 | Shipped v1."]; + +async function seedLoop(): Promise { + const token = tokens.mintDeviceToken(); + const machineId = tokens.machineIdFromToken(token); + await store.createMachine({ id: machineId, userId: "u1", name: "M", tokenHash: tokens.sha256(token), online: true }); + const loop = await store.createLoop({ + userId: "u1", + machineId, + cron: "0 9 * * *", + enabled: true, + notify: "never", + taskFile: "/h/loopany/old-spike/README.md", + // Front matter already at its final work-state, so the ingests under test + // add ONLY seeded rows (the store chokepoint emits status-changed on a diff). + taskFileContent: readme(), + }); + return loop.id; +} + +/** Push the loop past the old newest-200 dedup window with unrelated events + * NEWER than every seeded (historical) row — one bulk insert, not 226 writes. */ +async function padEvents(loopId: string, n: number): Promise { + const rows = Array.from({ length: n }, (_, i) => `('pad-${i}', '${loopId}', 'run-returned', 'agent:claude-code', '2026-07-26T00:00:00.${String(i).padStart(3, "0")}Z')`); + await (db.client as never as { exec(sql: string): Promise }).exec(`INSERT INTO events (id, loop_id, type, actor, at) VALUES ${rows.join(",")};`); +} + +// ---- the live ingest path (every task-file sync / report) ---- + +test("re-syncing the same Timeline past 200 events seeds nothing (the duplication bug)", async () => { + const gw = gateway(); + const loopId = await seedLoop(); + const doc = readme(...TWO_LINES); + + await gw.ingestTaskFileContent(loopId, doc); + expect(await store.countEvents(loopId)).toBe(2); // both lines seeded once + + // A daily loop emits run-started + run-returned per run, so it crosses 200 + // events in ~100 runs. Past that point the two seeded rows are no longer in + // the newest-200 window the old dedup read. + await padEvents(loopId, 226); + expect(await store.countEvents(loopId)).toBe(228); + + await gw.ingestTaskFileContent(loopId, doc); + expect(await store.countEvents(loopId)).toBe(228); // was 230 — the whole Timeline re-seeded + + // And it stays flat across further syncs, not merely on the first re-run. + await gw.ingestTaskFileContent(loopId, doc); + await gw.ingestTaskFileContent(loopId, doc); + expect(await store.countEvents(loopId)).toBe(228); +}); + +test("a NEW Timeline entry still seeds after the loop is past the window", async () => { + const gw = gateway(); + const loopId = await seedLoop(); + await gw.ingestTaskFileContent(loopId, readme(...TWO_LINES)); + await padEvents(loopId, 226); + + await gw.ingestTaskFileContent(loopId, readme(...TWO_LINES, "- 2026-07-03 | Fixed the leak.")); + expect(await store.countEvents(loopId)).toBe(229); + // The new row is the OLDEST in the stream (historical `at`), so it is not in + // any newest-N listing — look it up by its content-derived identity instead. + const keys = await store.seededTimelineKeys(loopId); + expect(keys.has(seedMod.timelineSeedKey("2026-07-03T00:00:00.000Z", "Fixed the leak."))).toBe(true); +}); + +test("rows seeded BEFORE the fix (random ids) still dedup — the keyed query, not just the id", async () => { + const gw = gateway(); + const loopId = await seedLoop(); + // Exactly what the old code wrote: the source marker, a random id. + for (const [at, text] of [ + ["2026-07-01T00:00:00.000Z", "Created."], + ["2026-07-02T00:00:00.000Z", "Shipped v1."], + ] as const) { + await store.addEvent({ loopId, type: "note", actor: "agent:claude-code", at, text, data: { source: "timeline" } }); + } + await padEvents(loopId, 226); + + await gw.ingestTaskFileContent(loopId, readme(...TWO_LINES)); + expect(await store.countEvents(loopId)).toBe(228); +}); + +test("concurrent syncs converge on one row per entry (deterministic id + ON CONFLICT)", async () => { + const gw = gateway(); + const loopId = await seedLoop(); + const doc = readme(...TWO_LINES); + // Both passes read an empty key set before either writes — only the + // deterministic primary key can keep this from doubling. + await Promise.all([gw.ingestTaskFileContent(loopId, doc), gw.ingestTaskFileContent(loopId, doc)]); + expect(await store.countEvents(loopId)).toBe(2); +}); + +test("seedTimelineEvents reports what it wrote; over-cap text keys on the CLIPPED body", async () => { + const loopId = await seedLoop(); + const long = "x".repeat(3000); + const doc = readme(`- 2026-07-01 | ${long}`); + expect(await seedMod.seedTimelineEvents(loopId, doc, () => "timeline")).toBe(1); + expect(await seedMod.seedTimelineEvents(loopId, doc, () => "timeline")).toBe(0); + const [row] = await store.listEvents(loopId, { limit: 5 }); + expect(row!.text).toHaveLength(2000); + + // Undated / empty content seeds nothing at all. + expect(await seedMod.seedTimelineEvents(loopId, readme("- just a bullet, no date"), () => "timeline")).toBe(0); + expect(await seedMod.seedTimelineEvents(loopId, null, () => "timeline")).toBe(0); +}); + +// ---- the migration script's --execute pass (same module, same guarantee) ---- + +test("migrate-v2-split --execute is a true no-op on re-run past 200 events", async () => { + const loopId = await seedLoop(); + await store.updateLoop(loopId, { taskFileContent: readme(...TWO_LINES) }); + // updateLoop alone does not seed — the script's first pass does. + const beforeFirst = await store.countEvents(loopId); + + expect(await migrate.executeSeed()).toBe(0); + const afterFirst = await store.countEvents(loopId); + expect(afterFirst - beforeFirst).toBe(2); + + await padEvents(loopId, 226); + const padded = await store.countEvents(loopId); + + await migrate.executeSeed(); + expect(await store.countEvents(loopId)).toBe(padded); // the idempotency claim, now true + await migrate.executeSeed(); + expect(await store.countEvents(loopId)).toBe(padded); +}); + +test("the rehearsal's loss check accepts the daemon's own `- DATE | text` Timeline format", async () => { + const { splitTaskDoc } = await import("../server/docSplit.js"); + const content = readme(...TWO_LINES); + const split = splitTaskDoc(content); + // Every separator docSplit's TIMELINE_LINE accepts must survive the loss check, + // or the rehearsal exits 1 on rows that migrate perfectly well. + expect(migrate.lostLines(content, split.doc, split.events, split.representedKeys)).toEqual([]); + const colons = readme("- 2026-07-01: Created.", "* **2026-07-02** — Shipped v1."); + const s2 = splitTaskDoc(colons); + expect(migrate.lostLines(colons, s2.doc, s2.events, s2.representedKeys)).toEqual([]); +}); diff --git a/packages/server/src/gateway/timelineSeed.ts b/packages/server/src/gateway/timelineSeed.ts new file mode 100644 index 0000000..348414a Binary files /dev/null and b/packages/server/src/gateway/timelineSeed.ts differ