Skip to content
Open
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
25 changes: 19 additions & 6 deletions packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -489,9 +492,19 @@ fields are retired. Ships server-first (deploys); the daemon changes ride the ne
clobbers a rename). `assignee=<agent-slug>` resolves registry-first;
`<machine>/<runtime>` 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.
Expand Down
74 changes: 37 additions & 37 deletions packages/server/scripts/migrate-v2-split.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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[] = [];
Expand All @@ -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));
}
Expand All @@ -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<number> {
export async function executeSeed(): Promise<number> {
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<number> {
export async function main(): Promise<number> {
if (process.argv.includes("--execute")) return executeSeed();
const json = process.argv.includes("--json");
const machines = new Map((await listMachines()).map((m) => [m.id, m]));
Expand Down Expand Up @@ -148,10 +143,15 @@ async function main(): Promise<number> {
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);
},
);
}
39 changes: 36 additions & 3 deletions packages/server/src/db/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,44 @@ export async function loopsForTeams(teamIds: string[]): Promise<Loop[]> {

// ---- events (the append-only per-node record) ----

type EventInput = Omit<NewEvent, "id" | "at"> & { 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<NewEvent, "id" | "at"> & { id?: string; at?: string }): Promise<EventRow> {
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<EventRow> {
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<EventRow | undefined> {
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<Set<string>> {
const rows = await db
.selectDistinct({ day: sql<string>`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
Expand Down
6 changes: 6 additions & 0 deletions packages/server/src/gateway/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
40 changes: 11 additions & 29 deletions packages/server/src/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
Expand Down
Loading