From f66e8999a692b78521a0e1116e8470063509d163 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 8 Sep 2026 13:48:30 -0700 Subject: [PATCH 1/9] fix(harness): archive history before event retention --- .changeset/archive-before-retention.md | 5 +++ packages/harness/src/server/index.ts | 44 +++++++++---------- .../src/server/record-archive-wiring.test.ts | 35 +++++++++++++-- 3 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 .changeset/archive-before-retention.md diff --git a/.changeset/archive-before-retention.md b/.changeset/archive-before-retention.md new file mode 100644 index 000000000..935e70c31 --- /dev/null +++ b/.changeset/archive-before-retention.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Archive historical conversations before event retention can remove their source events during startup. diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 1cf2c24f6..31a983798 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -3024,35 +3024,14 @@ export const startServer = async ( }; }); - // Boot-time retention sweep: keeps events.ndjson within the 50 MB / 30-day - // caps even on long-lived installs. Runs through the store's exclusive queue - // so the sweep's read→filter→rename window never races a concurrent append. - // Fire-and-forget — a slow FS is no reason to delay server startup. - const runNdjsonSweep = (): void => { - void eventStore - .runExclusive(() => sweepNdjson(eventStorePath)) - .catch((err: unknown) => { - console.error("[harness] events.ndjson retention sweep failed:", err); - }); - }; - runNdjsonSweep(); - const ndjsonRetentionTimer = setInterval( - runNdjsonSweep, - NDJSON_RETENTION_SWEEP_MS, - ); - ndjsonRetentionTimer.unref?.(); - // One boot-time pass that archives conversations the log still holds but the // archive doesn't, then sweeps the archive's own caps. This is what covers the // two cases archiving-at-exit can't: a harness that was force-killed (no exit // transition, no session.end), and every session that ended before this // existed — whose history would otherwise vanish at its 30-day mark. // - // It races the ndjson sweep queued above, and deliberately doesn't wait for - // it: reads run outside the store's exclusive queue by design (see store.ts), - // and either order is correct here — win the race and the record is archived - // from bytes retention was about to delete, lose it and the record is archived - // from what survived. Both beat not archiving it. + // Retention must wait for this pass: reads run outside the store's exclusive + // queue, so a sweep could otherwise delete old events before we archive them. // // Fire-and-forget: boot must not wait on it. The cost is one full index build // (~130 ms against a 50 MB log), which the first history open would have paid @@ -3076,6 +3055,25 @@ export const startServer = async ( console.error("[harness] session record backfill failed:", err); }); + // Boot-time retention sweep: keeps events.ndjson within the 50 MB / 30-day + // caps even on long-lived installs. Runs through the store's exclusive queue + // so the sweep's read→filter→rename window never races a concurrent append. + // Wait for backfill before every sweep, including a timer tick during a slow + // boot pass. Server startup stays independent of both maintenance tasks. + const runNdjsonSweep = (): void => { + void recordBackfill + .then(() => eventStore.runExclusive(() => sweepNdjson(eventStorePath))) + .catch((err: unknown) => { + console.error("[harness] events.ndjson retention sweep failed:", err); + }); + }; + runNdjsonSweep(); + const ndjsonRetentionTimer = setInterval( + runNdjsonSweep, + NDJSON_RETENTION_SWEEP_MS, + ); + ndjsonRetentionTimer.unref?.(); + const harnessVersion = readVersion(); const batcher = createHarnessEmitter({ telemetryOptIn: options.telemetryOptIn, diff --git a/packages/harness/src/server/record-archive-wiring.test.ts b/packages/harness/src/server/record-archive-wiring.test.ts index 9b8b37fa7..04be44990 100644 --- a/packages/harness/src/server/record-archive-wiring.test.ts +++ b/packages/harness/src/server/record-archive-wiring.test.ts @@ -20,6 +20,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { startServer, type HarnessServer } from "./index.js"; +import * as recordArchive from "../core/record-archive.js"; +import * as retention from "../core/collector/store-retention.js"; import type { AnalyticsEvent, HarnessAdapter, @@ -79,6 +81,7 @@ describe("session record archive wiring", () => { await server?.close(); await server?.sessionManager.flush(); server = undefined; + vi.restoreAllMocks(); await rm(dir, { recursive: true, force: true }); }); @@ -232,11 +235,12 @@ describe("session record archive wiring", () => { it("archives conversations that ended before the archive existed", async () => { // An events file from an install that predates this feature: a complete // conversation, no archive, and no registry entry for it. + const expiredAt = Date.now() - retention.DEFAULT_MAX_AGE_MS - 60_000; const events: AnalyticsEvent[] = [ { eventId: "evt-1", seq: 1, - ts: "2026-06-01T10:00:00.000Z", + ts: new Date(expiredAt).toISOString(), userId: null, tenantId: null, machineId: "machine-1", @@ -249,7 +253,7 @@ describe("session record archive wiring", () => { { eventId: "evt-2", seq: 2, - ts: "2026-06-01T10:00:01.000Z", + ts: new Date(expiredAt + 1000).toISOString(), userId: null, tenantId: null, machineId: "machine-1", @@ -262,14 +266,39 @@ describe("session record archive wiring", () => { ]; await writeFile(eventStorePath, events.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8"); - server = await boot(); + // Hold the backfill to prove retention cannot delete its source events, + // even when startup finishes before the archive work does. + let releaseBackfill!: () => void; + const backfillGate = new Promise((resolve) => { releaseBackfill = resolve; }); + const backfill = recordArchive.backfillSessionRecords; + const backfillSpy = vi.spyOn(recordArchive, "backfillSessionRecords") + .mockImplementation(async (options) => { + await backfillGate; + return backfill(options); + }); + const sweepSpy = vi.spyOn(retention, "sweepNdjson"); + try { + server = await boot(); + expect(backfillSpy).toHaveBeenCalledOnce(); + expect(sweepSpy).not.toHaveBeenCalled(); + } finally { + releaseBackfill(); + } await vi.waitFor( async () => { const archived = await readArchived("sess-legacy"); expect(archived?.turns[0].prompt).toBe("from before the archive existed"); + expect(archived?.turns[0].assistantText).toBe("done"); }, { timeout: 10_000, interval: 100 }, ); + await vi.waitFor(async () => { + expect(await readFile(eventStorePath, "utf8")).not.toContain("sess-legacy"); + }); + const archived = await fetchRecord("agent-legacy"); + expect(archived.status).toBe(200); + expect(archived.body?.turns[0].prompt).toBe("from before the archive existed"); + expect(archived.body?.turns[0].assistantText).toBe("done"); }, 20_000); }); From 8a2616022dfa62cea40f620d9278048436d5e9c5 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Fri, 4 Sep 2026 14:40:36 -0700 Subject: [PATCH 2/9] fix(harness): preserve coding agent for template launches --- .changeset/template-harness-selection.md | 5 + packages/harness/README.md | 3 + .../harness/web/e2e/template-harness.spec.ts | 178 ++++++++++++++++++ packages/harness/web/src/App.tsx | 13 +- .../web/src/components/NewSessionComposer.tsx | 4 +- 5 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 .changeset/template-harness-selection.md create mode 100644 packages/harness/web/e2e/template-harness.spec.ts diff --git a/.changeset/template-harness-selection.md b/.changeset/template-harness-selection.md new file mode 100644 index 000000000..e4314ac6a --- /dev/null +++ b/.changeset/template-harness-selection.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Preserve the selected coding agent when launching a template from the new-session composer or template gallery, including bundled starters. Codex selections no longer start Claude Code sessions. diff --git a/packages/harness/README.md b/packages/harness/README.md index 506e4684b..db2ff33fe 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -21,6 +21,9 @@ system prompt, in whatever project directory you choose. Agent Studio only configures it. The `+` beside a project starts a session at that project root; the tab-strip `+` starts a sibling session. Sessions have resumable chat history. +- **Templates** — quick starts and the template gallery use your selected coding + agent, including bundled starters. Claude Code is the default until you choose + another agent in the new-session composer. - **Agents rail** — agent projects (`sapiom.json`) discovered and tracked, with one-click local test run, deploy, production run, and open-in-Sapiom actions. How that discovery is rooted and bounded, how a diff --git a/packages/harness/web/e2e/template-harness.spec.ts b/packages/harness/web/e2e/template-harness.spec.ts new file mode 100644 index 000000000..e777db992 --- /dev/null +++ b/packages/harness/web/e2e/template-harness.spec.ts @@ -0,0 +1,178 @@ +/** SAP-3121: every template launch keeps the user's selected coding agent. + * Mock sessions record the actual create request; telemetry alone would not + * prove which adapter the server is asked to launch. */ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +type LaunchSurface = + | "composer" + | "gallery-detail" + | "starter-detail" + | "gallery-card"; + +async function chooseCodex(page: Page): Promise { + await page.getByTestId("composer-harness-select").click(); + await page.getByTestId("composer-harness-option-codex").click(); + await expect(page.getByTestId("composer-harness-select")).toContainText( + "Codex", + ); +} + +async function launchTemplate( + page: Page, + surface: LaunchSurface, +): Promise { + if (surface === "composer") { + await page.getByTestId("composer-template-hello-agent").click(); + return; + } + await page.getByTestId("composer-browse-templates").click(); + const id = surface.startsWith("starter") ? "coding-pause" : "hello-agent"; + await confirmTemplate(page, id, surface.endsWith("card")); +} + +async function confirmTemplate( + page: Page, + id: string, + fromCard = false, +): Promise { + if (fromCard) { + await page.getByTestId(`template-card-info-${id}`).click(); + await page.getByTestId(`template-facts-use-${id}`).click(); + } else { + await page.getByTestId(`template-card-open-${id}`).click(); + await page.getByTestId("template-use-btn").click(); + } + await page.getByTestId("template-use-confirm").click(); +} + +async function expectTemplateSession( + page: Page, + harness: "claude-code" | "codex", + starter = false, +): Promise { + const root = "/Users/demo/acme-app/projects"; + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { + createSessionCalls?: Array<{ + req: { cwd: string; harness: string }; + }>; + }; + } + ).__HARNESS_TEST__?.createSessionCalls ?? [], + ), + ) + .toEqual([ + { req: { cwd: starter ? root : `${root}/hello-agent`, harness } }, + ]); + await expect(page.getByTestId("new-session-composer")).toHaveCount(0); + await expect(page.getByTestId("templates-panel")).toHaveCount(0); + if (starter) { + await expect(page.getByTestId("workflow-coding-pause")).toBeVisible(); + } else { + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __HARNESS_TEST__?: { + lastInjectInput?: { req?: { text?: string } }; + }; + } + ).__HARNESS_TEST__?.lastInjectInput?.req?.text ?? "", + ), + ) + .toContain('templateId "hello-agent"'); + } +} + +for (const surface of [ + "composer", + "gallery-detail", + "starter-detail", + "gallery-card", +] as const) { + test(`selected Codex is preserved from ${surface} on a fresh install`, async ({ + page, + }) => { + await page.goto("/?mockState=fresh"); + await expect(page.getByTestId("new-session-composer")).toBeVisible(); + await chooseCodex(page); + await launchTemplate(page, surface); + await expectTemplateSession(page, "codex", surface.startsWith("starter")); + }); +} + +for (const surface of [ + "composer", + "gallery-detail", + "starter-detail", +] as const) { + test(`no harness preference keeps the Claude default from ${surface}`, async ({ + page, + }) => { + await page.goto("/?mockState=fresh"); + await expect(page.getByTestId("composer-harness-select")).toContainText( + "Claude", + ); + await launchTemplate(page, surface); + await expectTemplateSession( + page, + "claude-code", + surface.startsWith("starter"), + ); + }); +} + +test("the composer passes its selected harness even when preferences cannot be saved", async ({ + page, +}) => { + await page.addInitScript(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value): void { + if (key === "sapiom-harness-ui-prefs") { + throw new DOMException("Storage quota exceeded", "QuotaExceededError"); + } + setItem.call(this, key, value); + }; + }); + await page.goto("/?mockState=fresh"); + await chooseCodex(page); + await launchTemplate(page, "composer"); + await expectTemplateSession(page, "codex"); +}); + +for (const entry of ["rail", "palette", "deep-link"] as const) { + test(`saved Codex preference is preserved when entering templates from ${entry}`, async ({ + page, + }) => { + await page.goto("/?mockState=fresh"); + await chooseCodex(page); + if (entry === "deep-link") { + await page.goto("/?mockState=fresh&template=hello-agent"); + await page.getByTestId("template-use-btn").click(); + await page.getByTestId("template-use-confirm").click(); + } else { + // Reload to verify the saved preference, independent of composer state. + await page.reload(); + if (entry === "rail") { + await page.getByTestId("rail-templates").click(); + } else { + await page.getByTestId("palette-trigger").click(); + await page.getByTestId("command-palette-input").fill("templates"); + await page + .getByTestId("command-palette-list") + .getByText("Browse templates") + .click(); + } + await confirmTemplate(page, "hello-agent"); + } + await expectTemplateSession(page, "codex"); + }); +} diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 8e5ae0946..13bcf8cb9 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -2146,6 +2146,8 @@ export const App = (): JSX.Element => { | "welcome" | "template_gallery" | "template_detail" = "template_gallery", + // The gallery has no picker; the composer passes its current selection. + agentHarness: HarnessKind = preferredHarness(), ): Promise => { // Product metric — "templates used". Fires at the choke point every // template surface funnels through; `agent.created` fires later when the @@ -2175,12 +2177,12 @@ export const App = (): JSX.Element => { trackUse(); setTemplatesOpen(false); setFocusedAgentPath(created.path); - const session = await createSessionAt(parent, "claude-code"); + const session = await createSessionAt(parent, agentHarness); await harness.bindWorkflow(session.id, created.path); setFocusedAgentPath(created.path); return; } - const session = await createSessionAt(cwd, "claude-code", { + const session = await createSessionAt(cwd, agentHarness, { initialUserInputPending: true, }); trackUse(); @@ -2240,14 +2242,17 @@ export const App = (): JSX.Element => { setComposing(false); }; - const handleComposerUseTemplate = (template: GalleryTemplate): void => { + const handleComposerUseTemplate = ( + template: GalleryTemplate, + agentHarness: HarnessKind, + ): void => { const cwd = uniqueProjectDir(template.id); if (!cwd) { harness.showToast("Set a project folder first — use the + to open one."); return; } setRightCollapsed(true); - void handleUseTemplate(cwd, template, "welcome"); + void handleUseTemplate(cwd, template, "welcome", agentHarness); }; // Bulk discovery from the add dialog. diff --git a/packages/harness/web/src/components/NewSessionComposer.tsx b/packages/harness/web/src/components/NewSessionComposer.tsx index a56eea89d..7581a5488 100644 --- a/packages/harness/web/src/components/NewSessionComposer.tsx +++ b/packages/harness/web/src/components/NewSessionComposer.tsx @@ -92,7 +92,7 @@ interface NewSessionComposerProps { /** Surface a file-resolution or submit failure in the app's existing toast. */ onAttachmentError: (message: string) => void; /** Start a session from a catalog template (clone + first run). */ - onUseTemplate: (template: GalleryTemplate) => void; + onUseTemplate: (template: GalleryTemplate, harness: HarnessKind) => void; /** Navigate to the full templates catalog. */ onBrowseTemplates: () => void; /** Adapter registry + template catalog fetches. */ @@ -557,7 +557,7 @@ export function NewSessionComposer({ type="button" className="composer-template-card" data-testid={`composer-template-${template.id}`} - onClick={() => leaveThen(() => onUseTemplate(template))} + onClick={() => leaveThen(() => onUseTemplate(template, harness))} > {template.name} From ff1e758fe04e8e84a4229198a7e9b1249a4c9d8c Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Fri, 4 Sep 2026 16:44:47 -0700 Subject: [PATCH 3/9] fix(harness): carry composer harness into gallery visits --- packages/harness/README.md | 5 +- .../harness/web/e2e/template-harness.spec.ts | 71 ++++++++++++++----- packages/harness/web/src/App.tsx | 30 ++++++-- .../web/src/components/NewSessionComposer.tsx | 4 +- packages/harness/web/src/lib/api.ts | 7 +- 5 files changed, 89 insertions(+), 28 deletions(-) diff --git a/packages/harness/README.md b/packages/harness/README.md index db2ff33fe..1776b1cd2 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -22,8 +22,9 @@ system prompt, in whatever project directory you choose. that project root; the tab-strip `+` starts a sibling session. Sessions have resumable chat history. - **Templates** — quick starts and the template gallery use your selected coding - agent, including bundled starters. Claude Code is the default until you choose - another agent in the new-session composer. + agent, including bundled starters. Browsing from the composer keeps its current + selection even when preferences cannot be saved. Other gallery entry points use + the saved preference, defaulting to Claude Code. - **Agents rail** — agent projects (`sapiom.json`) discovered and tracked, with one-click local test run, deploy, production run, and open-in-Sapiom actions. How that discovery is rooted and bounded, how a diff --git a/packages/harness/web/e2e/template-harness.spec.ts b/packages/harness/web/e2e/template-harness.spec.ts index e777db992..388f8623f 100644 --- a/packages/harness/web/e2e/template-harness.spec.ts +++ b/packages/harness/web/e2e/template-harness.spec.ts @@ -130,23 +130,48 @@ for (const surface of [ }); } -test("the composer passes its selected harness even when preferences cannot be saved", async ({ - page, -}) => { - await page.addInitScript(() => { - const setItem = Storage.prototype.setItem; - Storage.prototype.setItem = function (key, value): void { - if (key === "sapiom-harness-ui-prefs") { - throw new DOMException("Storage quota exceeded", "QuotaExceededError"); - } - setItem.call(this, key, value); - }; +for (const surface of [ + "composer", + "gallery-detail", + "starter-detail", +] as const) { + test(`automatically selected Codex is preserved from ${surface}`, async ({ + page, + }) => { + await page.addInitScript(() => { + ( + window as unknown as { __MOCK_UNINSTALLED_HARNESSES__: string[] } + ).__MOCK_UNINSTALLED_HARNESSES__ = ["claude-code"]; + }); + await page.goto("/?mockState=fresh"); + await expect(page.getByTestId("composer-harness-select")).toContainText( + "Codex", + ); + await launchTemplate(page, surface); + await expectTemplateSession(page, "codex", surface === "starter-detail"); }); - await page.goto("/?mockState=fresh"); - await chooseCodex(page); - await launchTemplate(page, "composer"); - await expectTemplateSession(page, "codex"); -}); + + test(`selected Codex is preserved from ${surface} when preferences cannot be saved`, async ({ + page, + }) => { + await page.addInitScript(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value): void { + if (key === "sapiom-harness-ui-prefs") { + throw new DOMException( + "Storage quota exceeded", + "QuotaExceededError", + ); + } + setItem.call(this, key, value); + }; + }); + await page.goto("/?mockState=fresh"); + await chooseCodex(page); + await launchTemplate(page, surface); + await expectTemplateSession(page, "codex", surface === "starter-detail"); + }); +} for (const entry of ["rail", "palette", "deep-link"] as const) { test(`saved Codex preference is preserved when entering templates from ${entry}`, async ({ @@ -176,3 +201,17 @@ for (const entry of ["rail", "palette", "deep-link"] as const) { await expectTemplateSession(page, "codex"); }); } + +test("a direct gallery visit uses the saved preference after leaving a composer visit", async ({ + page, +}) => { + await page.goto("/?mockState=fresh"); + await chooseCodex(page); + await page.getByTestId("composer-browse-templates").click(); + await page.getByTestId("templates-exit").click(); + await page.getByTestId("composer-harness-select").click(); + await page.getByTestId("composer-harness-option-claude-code").click(); + await page.getByTestId("rail-templates").click(); + await confirmTemplate(page, "hello-agent"); + await expectTemplateSession(page, "claude-code"); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 13bcf8cb9..d3036411f 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -612,9 +612,18 @@ export const App = (): JSX.Element => { const [reviewSummary, setReviewSummary] = useState( null, ); - // Template gallery opened from the command palette (browse is reachable - // from anywhere, not only the add dialog / welcome panel entries). - const [templatesOpen, setTemplatesOpen] = useState(false); + // Keep the composer's selection with this gallery visit. Other entry points + // use the saved preference; closing or reopening the gallery clears the override. + const [templatesView, setTemplatesView] = useState<{ + harness?: HarnessKind; + } | null>(null); + const templatesOpen = templatesView !== null; + const setTemplatesOpen = useCallback( + (open: boolean, agentHarness?: HarnessKind) => { + setTemplatesView(open ? { harness: agentHarness } : null); + }, + [], + ); // The Overview: an introduction to the app, opened from the account menu's // "Overview" item. A full-width destination like Templates (never the // composer it used to alias), cleared by any navigation the same way. @@ -1219,7 +1228,7 @@ export const App = (): JSX.Element => { ); } }, - [harness.state, isMobile, setActiveSessionId], + [harness.state, isMobile, setActiveSessionId, setTemplatesOpen], ); // The dead pane's Resume button has to be as honest as a history row's tag, @@ -3066,7 +3075,14 @@ export const App = (): JSX.Element => { recentDirs={harness.settings?.recentDirs ?? []} listDir={harness.listDir} onExit={() => setTemplatesOpen(false)} - onUse={handleUseTemplate} + onUse={(cwd, template) => + handleUseTemplate( + cwd, + template, + "template_gallery", + templatesView?.harness, + ) + } listTemplates={harness.listTemplates} getTemplate={harness.getTemplate} openTemplateId={deepLinkTemplateId} @@ -3315,11 +3331,11 @@ export const App = (): JSX.Element => { onSubmitIdea={handleComposerSubmitIdea} onAttachmentError={harness.showToast} onUseTemplate={handleComposerUseTemplate} - onBrowseTemplates={() => { + onBrowseTemplates={(agentHarness) => { studioRestoreGenerationRef.current += 1; setStudioSelection(null); setSelectedProject(null); - setTemplatesOpen(true); + setTemplatesOpen(true, agentHarness); }} listHarnesses={harness.listHarnesses} listTemplates={harness.listTemplates} diff --git a/packages/harness/web/src/components/NewSessionComposer.tsx b/packages/harness/web/src/components/NewSessionComposer.tsx index 7581a5488..18aa6a272 100644 --- a/packages/harness/web/src/components/NewSessionComposer.tsx +++ b/packages/harness/web/src/components/NewSessionComposer.tsx @@ -94,7 +94,7 @@ interface NewSessionComposerProps { /** Start a session from a catalog template (clone + first run). */ onUseTemplate: (template: GalleryTemplate, harness: HarnessKind) => void; /** Navigate to the full templates catalog. */ - onBrowseTemplates: () => void; + onBrowseTemplates: (harness: HarnessKind) => void; /** Adapter registry + template catalog fetches. */ listHarnesses: () => Promise; listTemplates: () => Promise; @@ -545,7 +545,7 @@ export function NewSessionComposer({ type="button" className="composer-templates-all" data-testid="composer-browse-templates" - onClick={onBrowseTemplates} + onClick={() => onBrowseTemplates(harness)} > Browse all templates diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 49c6da2df..2e9db4d94 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -3466,7 +3466,12 @@ export class MockApi implements HarnessApi { async listHarnesses(): Promise { await delay(120); - return MOCK_HARNESSES; + const uninstalled = + (window as unknown as { __MOCK_UNINSTALLED_HARNESSES__?: string[] }) + .__MOCK_UNINSTALLED_HARNESSES__ ?? []; + return MOCK_HARNESSES.map((entry) => + uninstalled.includes(entry.id) ? { ...entry, installed: false } : entry, + ); } /** From 53ac07271adf8ff277180bb401cec1465f8678d9 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Fri, 4 Sep 2026 17:47:35 -0700 Subject: [PATCH 4/9] fix(harness): retain template selection across gallery navigation --- packages/harness/README.md | 6 +- .../harness/web/e2e/template-harness.spec.ts | 77 +++++++++++++++++++ packages/harness/web/src/App.tsx | 65 ++++++++++++++-- .../web/src/components/NewSessionComposer.tsx | 46 +++-------- .../harness/web/src/lib/navigation-history.ts | 4 +- 5 files changed, 147 insertions(+), 51 deletions(-) diff --git a/packages/harness/README.md b/packages/harness/README.md index 1776b1cd2..34ca20bd9 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -21,10 +21,8 @@ system prompt, in whatever project directory you choose. Agent Studio only configures it. The `+` beside a project starts a session at that project root; the tab-strip `+` starts a sibling session. Sessions have resumable chat history. -- **Templates** — quick starts and the template gallery use your selected coding - agent, including bundled starters. Browsing from the composer keeps its current - selection even when preferences cannot be saved. Other gallery entry points use - the saved preference, defaulting to Claude Code. +- **Templates** — quick starts, the template gallery, and bundled starters use + your selected coding agent. - **Agents rail** — agent projects (`sapiom.json`) discovered and tracked, with one-click local test run, deploy, production run, and open-in-Sapiom actions. How that discovery is rooted and bounded, how a diff --git a/packages/harness/web/e2e/template-harness.spec.ts b/packages/harness/web/e2e/template-harness.spec.ts index 388f8623f..154b37aec 100644 --- a/packages/harness/web/e2e/template-harness.spec.ts +++ b/packages/harness/web/e2e/template-harness.spec.ts @@ -215,3 +215,80 @@ test("a direct gallery visit uses the saved preference after leaving a composer await confirmTemplate(page, "hello-agent"); await expectTemplateSession(page, "claude-code"); }); + +for (const entry of ["rail", "palette", "deep-link"] as const) { + test(`only Codex installed launches Codex through ${entry}`, async ({ + page, + }) => { + await page.addInitScript(() => { + ( + window as unknown as { __MOCK_UNINSTALLED_HARNESSES__: string[] } + ).__MOCK_UNINSTALLED_HARNESSES__ = ["claude-code"]; + }); + await page.goto( + entry === "deep-link" + ? "/?mockState=fresh&template=hello-agent" + : "/?mockState=fresh", + ); + if (entry === "deep-link") { + await page.getByTestId("template-use-btn").click(); + await page.getByTestId("template-use-confirm").click(); + } else { + await expect(page.getByTestId("composer-harness-select")).toContainText( + "Codex", + ); + if (entry === "rail") { + await page.getByTestId("rail-templates").click(); + } else { + await page.getByTestId("palette-trigger").click(); + await page.getByTestId("command-palette-input").fill("templates"); + await page + .getByTestId("command-palette-list") + .getByText("Browse templates") + .click(); + } + await confirmTemplate(page, "hello-agent"); + } + await expectTemplateSession(page, "codex"); + }); +} + +for (const navigation of ["exit-back", "back-forward"] as const) { + test(`gallery selection survives ${navigation} when preference writes fail`, async ({ + page, + }) => { + await page.addInitScript(() => { + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value): void { + if (key === "sapiom-harness-ui-prefs") { + throw new DOMException( + "Storage quota exceeded", + "QuotaExceededError", + ); + } + original.call(this, key, value); + }; + }); + await page.goto("/?mockState=fresh"); + // The create button records a composer visit for Back/Forward replay. + await page.getByTestId("rail-create-new").click(); + await chooseCodex(page); + await page.getByTestId("composer-browse-templates").click(); + await expect(page.getByTestId("templates-panel")).toBeVisible(); + if (navigation === "exit-back") { + await page.getByTestId("templates-exit").click(); + // A later choice must not change the original gallery visit. + await page.getByTestId("composer-harness-select").click(); + await page.getByTestId("composer-harness-option-claude-code").click(); + await page.getByRole("button", { name: "Go back", exact: true }).click(); + } else { + await page.getByRole("button", { name: "Go back", exact: true }).click(); + await page + .getByRole("button", { name: "Go forward", exact: true }) + .click(); + } + await expect(page.getByTestId("templates-panel")).toBeVisible(); + await confirmTemplate(page, "hello-agent"); + await expectTemplateSession(page, "codex"); + }); +} diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index d3036411f..835bd465f 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -51,6 +51,7 @@ import type { JSX } from "react"; import type { AppState, CreateSessionRequest, + HarnessEntry, HarnessKind, HarnessSession, MacroDef, @@ -156,6 +157,11 @@ import { sessionDisplayName } from "./lib/session-name"; import type { PaletteAction } from "./lib/palette"; import { toggleTheme } from "./lib/theme"; import { loadUiPrefs, saveUiPrefs } from "./lib/ui-prefs"; +import { + FALLBACK_HARNESSES, + isHarnessSelectable, + orderHarnesses, +} from "./lib/harness-registry"; import { useNavigationHistory, type NavigationVisit, @@ -275,6 +281,36 @@ const shellApi = createApi(); export const App = (): JSX.Element => { const harness = useHarnessState(); + const [selectedHarness, setSelectedHarness] = useState( + () => loadUiPrefs().preferredHarness ?? "claude-code", + ); + const [harnessEntries, setHarnessEntries] = useState( + null, + ); + // Keep the selection above the composer so every template entry point sees + // automatic corrections and choices that could not be saved to preferences. + useEffect(() => { + let cancelled = false; + harness.listHarnesses() + .then((registry) => { + if (cancelled || registry.length === 0) return; + setHarnessEntries(orderHarnesses(registry)); + const selectable = registry.filter(isHarnessSelectable); + setSelectedHarness((current) => + selectable.some((entry) => entry.id === current) + ? current + : ((selectable[0]?.id as HarnessKind | undefined) ?? current), + ); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [harness.listHarnesses]); + const pickHarness = useCallback((kind: HarnessKind) => { + setSelectedHarness(kind); + saveUiPrefs({ preferredHarness: kind === "codex" ? "codex" : "claude-code" }); + }, []); // Live browser connectivity (navigator.onLine + online/offline events). // Combined with the boot-error kind below to pick the honest shell state. const online = useConnectivity(); @@ -612,17 +648,16 @@ export const App = (): JSX.Element => { const [reviewSummary, setReviewSummary] = useState( null, ); - // Keep the composer's selection with this gallery visit. Other entry points - // use the saved preference; closing or reopening the gallery clears the override. + // A gallery visit retains its selection when replayed through Back/Forward. const [templatesView, setTemplatesView] = useState<{ harness?: HarnessKind; } | null>(null); const templatesOpen = templatesView !== null; const setTemplatesOpen = useCallback( (open: boolean, agentHarness?: HarnessKind) => { - setTemplatesView(open ? { harness: agentHarness } : null); + setTemplatesView(open ? { harness: agentHarness ?? selectedHarness } : null); }, - [], + [selectedHarness], ); // The Overview: an introduction to the app, opened from the account menu's // "Overview" item. A full-width destination like Templates (never the @@ -1121,7 +1156,7 @@ export const App = (): JSX.Element => { label: selectedProject.label, }); } else if (templatesOpen) { - recordVisit({ kind: "templates" }); + recordVisit({ kind: "templates", harness: templatesView?.harness }); } else if (reviewSummary) { recordVisit({ kind: "review", summary: reviewSummary }); } else if (composing) { @@ -1143,6 +1178,7 @@ export const App = (): JSX.Element => { selectedProject, effectiveStudioSelection, templatesOpen, + templatesView, reviewSummary, composing, activeSessionIdForNav, @@ -1160,7 +1196,10 @@ export const App = (): JSX.Element => { // would truncate the forward stack). See applyingVisitRef above. applyingVisitRef.current = true; setOverviewOpen(false); - setTemplatesOpen(visit.kind === "templates"); + setTemplatesOpen( + visit.kind === "templates", + visit.kind === "templates" ? visit.harness : undefined, + ); setComposing(visit.kind === "composer"); setReviewSummary(visit.kind === "review" ? visit.summary : null); if ( @@ -2156,8 +2195,16 @@ export const App = (): JSX.Element => { | "template_gallery" | "template_detail" = "template_gallery", // The gallery has no picker; the composer passes its current selection. - agentHarness: HarnessKind = preferredHarness(), + agentHarness: HarnessKind = selectedHarness, ): Promise => { + // A deep link can open before registry loading finishes. Resolve its + // selection before creating a session on an unavailable default adapter. + const registry = harnessEntries ?? (await harness.listHarnesses()); + const selectable = registry.filter(isHarnessSelectable); + if (!selectable.some((entry) => entry.id === agentHarness)) { + agentHarness = + (selectable[0]?.id as HarnessKind | undefined) ?? agentHarness; + } // Product metric — "templates used". Fires at the choke point every // template surface funnels through; `agent.created` fires later when the // clone produces a real sapiom.json, so built ≥ templates holds. @@ -3327,6 +3374,9 @@ export const App = (): JSX.Element => { screen gives way to the terminal (createSessionAt clears `composing`), and the canvas reveals itself once populated. */ { setSelectedProject(null); setTemplatesOpen(true, agentHarness); }} - listHarnesses={harness.listHarnesses} listTemplates={harness.listTemplates} telemetryOptIn={ harness.settings?.telemetryOptIn ?? state.telemetryOptIn diff --git a/packages/harness/web/src/components/NewSessionComposer.tsx b/packages/harness/web/src/components/NewSessionComposer.tsx index 18aa6a272..85f2eb1e1 100644 --- a/packages/harness/web/src/components/NewSessionComposer.tsx +++ b/packages/harness/web/src/components/NewSessionComposer.tsx @@ -8,14 +8,8 @@ import { } from "@shared/types"; import { errorMessage, type FsListResponse } from "../lib/api"; -import { - FALLBACK_HARNESSES, - harnessLabel, - isHarnessSelectable, - orderHarnesses, -} from "../lib/harness-registry"; +import { harnessLabel } from "../lib/harness-registry"; import { formatComplexity, type GalleryTemplate } from "../lib/templates"; -import { loadUiPrefs, saveUiPrefs } from "../lib/ui-prefs"; import { getDesktopBridge } from "../lib/desktop"; import { filesToAttachments, @@ -79,6 +73,9 @@ function chipSlug(label: string): string { } interface NewSessionComposerProps { + harness: HarnessKind; + entries: HarnessEntry[]; + onHarnessChange: (harness: HarnessKind) => void; /** Genuine first run (AppState.firstRun): changes the greeting and shows the * one-time telemetry opt-in + docs footer. */ firstRun: boolean; @@ -95,8 +92,7 @@ interface NewSessionComposerProps { onUseTemplate: (template: GalleryTemplate, harness: HarnessKind) => void; /** Navigate to the full templates catalog. */ onBrowseTemplates: (harness: HarnessKind) => void; - /** Adapter registry + template catalog fetches. */ - listHarnesses: () => Promise; + /** Template catalog fetch. */ listTemplates: () => Promise; /** First-run telemetry opt-in (SAP-1988): off by default, folded in from the * retired WelcomePanel. */ @@ -114,12 +110,14 @@ interface NewSessionComposerProps { } export function NewSessionComposer({ + harness, + entries, + onHarnessChange, firstRun, onSubmitIdea, onAttachmentError, onUseTemplate, onBrowseTemplates, - listHarnesses, listTemplates, telemetryOptIn, onToggleTelemetry, @@ -132,10 +130,6 @@ export function NewSessionComposer({ onSaveProjectRoot, }: NewSessionComposerProps): JSX.Element { const [idea, setIdea] = useState(""); - const [harness, setHarness] = useState( - () => loadUiPrefs().preferredHarness ?? "claude-code", - ); - const [entries, setEntries] = useState(FALLBACK_HARNESSES); const [pickerOpen, setPickerOpen] = useState(false); const [templates, setTemplates] = useState([]); const [addOpen, setAddOpen] = useState(false); @@ -158,27 +152,6 @@ export function NewSessionComposer({ const pendingQueueCountRef = useRef(0); const closePicker = useCallback(() => setPickerOpen(false), []); - // Registry-driven agent picker, same correction NewSessionModal applies: an - // uninstalled/external default is never left selected. - useEffect(() => { - let cancelled = false; - listHarnesses() - .then((registry) => { - if (cancelled || registry.length === 0) return; - setEntries(orderHarnesses(registry)); - const selectable = registry.filter(isHarnessSelectable); - setHarness((current) => - selectable.some((entry) => entry.id === current) - ? current - : ((selectable[0]?.id as HarnessKind | undefined) ?? current), - ); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [listHarnesses]); - // The first few catalog templates for the home's starter row. On failure the // row simply doesn't render (the box is still the primary path). useEffect(() => { @@ -199,8 +172,7 @@ export function NewSessionComposer({ }, [listTemplates]); const pickHarness = (kind: HarnessKind): void => { - setHarness(kind); - saveUiPrefs({ preferredHarness: kind === "codex" ? "codex" : "claude-code" }); + onHarnessChange(kind); closePicker(); }; diff --git a/packages/harness/web/src/lib/navigation-history.ts b/packages/harness/web/src/lib/navigation-history.ts index 06bc12185..075a25416 100644 --- a/packages/harness/web/src/lib/navigation-history.ts +++ b/packages/harness/web/src/lib/navigation-history.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; -import type { SessionSummary } from "@shared/types"; +import type { HarnessKind, SessionSummary } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; import type { StudioProjectId } from "@shared/agent-map"; @@ -23,7 +23,7 @@ export type NavigationVisit = } | { kind: "review"; summary: SessionSummary } | { kind: "composer" } - | { kind: "templates" }; + | { kind: "templates"; harness?: HarnessKind }; export interface NavigationHistoryState { entries: NavigationVisit[]; From 3a84909c71df0dda75d781f2f45dd680aea25544 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 8 Sep 2026 10:44:37 -0700 Subject: [PATCH 5/9] fix(harness): use one App-level harness selection --- .../harness/web/e2e/template-harness.spec.ts | 33 +++++-- packages/harness/web/src/App.tsx | 85 +++++++------------ .../web/src/components/NewSessionComposer.tsx | 11 ++- packages/harness/web/src/lib/api.ts | 4 + .../harness/web/src/lib/navigation-history.ts | 4 +- 5 files changed, 68 insertions(+), 69 deletions(-) diff --git a/packages/harness/web/e2e/template-harness.spec.ts b/packages/harness/web/e2e/template-harness.spec.ts index 154b37aec..b9ac3ed55 100644 --- a/packages/harness/web/e2e/template-harness.spec.ts +++ b/packages/harness/web/e2e/template-harness.spec.ts @@ -68,7 +68,13 @@ async function expectTemplateSession( ), ) .toEqual([ - { req: { cwd: starter ? root : `${root}/hello-agent`, harness } }, + { + req: { + cwd: starter ? root : `${root}/hello-agent`, + harness, + ...(!starter ? { initialUserInputPending: true } : {}), + }, + }, ]); await expect(page.getByTestId("new-session-composer")).toHaveCount(0); await expect(page.getByTestId("templates-panel")).toHaveCount(0); @@ -202,7 +208,7 @@ for (const entry of ["rail", "palette", "deep-link"] as const) { }); } -test("a direct gallery visit uses the saved preference after leaving a composer visit", async ({ +test("a direct gallery visit uses the current selection after leaving the composer", async ({ page, }) => { await page.goto("/?mockState=fresh"); @@ -254,7 +260,7 @@ for (const entry of ["rail", "palette", "deep-link"] as const) { } for (const navigation of ["exit-back", "back-forward"] as const) { - test(`gallery selection survives ${navigation} when preference writes fail`, async ({ + test(`gallery uses the current selection after ${navigation} when preference writes fail`, async ({ page, }) => { await page.addInitScript(() => { @@ -277,7 +283,7 @@ for (const navigation of ["exit-back", "back-forward"] as const) { await expect(page.getByTestId("templates-panel")).toBeVisible(); if (navigation === "exit-back") { await page.getByTestId("templates-exit").click(); - // A later choice must not change the original gallery visit. + // Returning to the gallery uses the new choice, even without storage. await page.getByTestId("composer-harness-select").click(); await page.getByTestId("composer-harness-option-claude-code").click(); await page.getByRole("button", { name: "Go back", exact: true }).click(); @@ -289,6 +295,23 @@ for (const navigation of ["exit-back", "back-forward"] as const) { } await expect(page.getByTestId("templates-panel")).toBeVisible(); await confirmTemplate(page, "hello-agent"); - await expectTemplateSession(page, "codex"); + await expectTemplateSession( + page, + navigation === "exit-back" ? "claude-code" : "codex", + ); }); } + +test("a registry failure still launches the selected harness from the composer", async ({ + page, +}) => { + await page.addInitScript(() => { + ( + window as unknown as { __MOCK_HARNESS_REGISTRY_FAIL__: boolean } + ).__MOCK_HARNESS_REGISTRY_FAIL__ = true; + }); + await page.goto("/?mockState=fresh"); + await chooseCodex(page); + await launchTemplate(page, "composer"); + await expectTemplateSession(page, "codex"); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 835bd465f..5aee7e3bd 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -291,7 +291,8 @@ export const App = (): JSX.Element => { // automatic corrections and choices that could not be saved to preferences. useEffect(() => { let cancelled = false; - harness.listHarnesses() + harness + .listHarnesses() .then((registry) => { if (cancelled || registry.length === 0) return; setHarnessEntries(orderHarnesses(registry)); @@ -307,10 +308,9 @@ export const App = (): JSX.Element => { cancelled = true; }; }, [harness.listHarnesses]); - const pickHarness = useCallback((kind: HarnessKind) => { - setSelectedHarness(kind); - saveUiPrefs({ preferredHarness: kind === "codex" ? "codex" : "claude-code" }); - }, []); + useEffect(() => { + saveUiPrefs({ preferredHarness: selectedHarness }); + }, [selectedHarness]); // Live browser connectivity (navigator.onLine + online/offline events). // Combined with the boot-error kind below to pick the honest shell state. const online = useConnectivity(); @@ -648,17 +648,9 @@ export const App = (): JSX.Element => { const [reviewSummary, setReviewSummary] = useState( null, ); - // A gallery visit retains its selection when replayed through Back/Forward. - const [templatesView, setTemplatesView] = useState<{ - harness?: HarnessKind; - } | null>(null); - const templatesOpen = templatesView !== null; - const setTemplatesOpen = useCallback( - (open: boolean, agentHarness?: HarnessKind) => { - setTemplatesView(open ? { harness: agentHarness ?? selectedHarness } : null); - }, - [selectedHarness], - ); + // Template gallery opened from the command palette (browse is reachable + // from anywhere, not only the add dialog / welcome panel entries). + const [templatesOpen, setTemplatesOpen] = useState(false); // The Overview: an introduction to the app, opened from the account menu's // "Overview" item. A full-width destination like Templates (never the // composer it used to alias), cleared by any navigation the same way. @@ -1156,7 +1148,7 @@ export const App = (): JSX.Element => { label: selectedProject.label, }); } else if (templatesOpen) { - recordVisit({ kind: "templates", harness: templatesView?.harness }); + recordVisit({ kind: "templates" }); } else if (reviewSummary) { recordVisit({ kind: "review", summary: reviewSummary }); } else if (composing) { @@ -1178,7 +1170,6 @@ export const App = (): JSX.Element => { selectedProject, effectiveStudioSelection, templatesOpen, - templatesView, reviewSummary, composing, activeSessionIdForNav, @@ -1196,10 +1187,7 @@ export const App = (): JSX.Element => { // would truncate the forward stack). See applyingVisitRef above. applyingVisitRef.current = true; setOverviewOpen(false); - setTemplatesOpen( - visit.kind === "templates", - visit.kind === "templates" ? visit.harness : undefined, - ); + setTemplatesOpen(visit.kind === "templates"); setComposing(visit.kind === "composer"); setReviewSummary(visit.kind === "review" ? visit.summary : null); if ( @@ -1267,7 +1255,7 @@ export const App = (): JSX.Element => { ); } }, - [harness.state, isMobile, setActiveSessionId, setTemplatesOpen], + [harness.state, isMobile, setActiveSessionId], ); // The dead pane's Resume button has to be as honest as a history row's tag, @@ -1752,7 +1740,7 @@ export const App = (): JSX.Element => { harness.setActiveSessionId(decision.to.id); return; } - void startProjectSession(root, label, preferredHarness()); + void startProjectSession(root, label, selectedHarness); }; selectProjectRef.current = handleSelectWorkspace; @@ -1789,22 +1777,13 @@ export const App = (): JSX.Element => { }; const handleStartProjectSession = async (root: string, label: string): Promise => { - const started = await startProjectSession(root, label, preferredHarness()); + const started = await startProjectSession(root, label, selectedHarness); if (!started) return; studioRestoreGenerationRef.current += 1; setStudioSelection(null); setSelectedProject(null); }; - /** - * The provider a create-initiated session boots with — the same stored - * preference the rail used to read before it dispatched. It moved here with - * the create itself; the rail no longer starts sessions. - */ - function preferredHarness(): HarnessKind { - return loadUiPrefs().preferredHarness === "codex" ? "codex" : "claude-code"; - } - /** * The ONE answer to "where does a session for this agent boot" (SAP-2927). * @@ -2043,7 +2022,7 @@ export const App = (): JSX.Element => { : null; const session = existing ?? - (await createSessionAt(request.root, preferredHarness(), { + (await createSessionAt(request.root, selectedHarness, { initialUserInputPending: input.instruction.trim().length > 0, })); await harness.bindWorkflow(session.id, created.path); @@ -2194,16 +2173,21 @@ export const App = (): JSX.Element => { | "welcome" | "template_gallery" | "template_detail" = "template_gallery", - // The gallery has no picker; the composer passes its current selection. - agentHarness: HarnessKind = selectedHarness, ): Promise => { + // Capture the current choice for this launch, including async scaffolding. + let agentHarness = selectedHarness; // A deep link can open before registry loading finishes. Resolve its // selection before creating a session on an unavailable default adapter. - const registry = harnessEntries ?? (await harness.listHarnesses()); + const registry = + harnessEntries ?? + (await harness.listHarnesses().catch(() => FALLBACK_HARNESSES)); const selectable = registry.filter(isHarnessSelectable); if (!selectable.some((entry) => entry.id === agentHarness)) { agentHarness = (selectable[0]?.id as HarnessKind | undefined) ?? agentHarness; + setSelectedHarness((current) => + current === selectedHarness ? agentHarness : current, + ); } // Product metric — "templates used". Fires at the choke point every // template surface funnels through; `agent.created` fires later when the @@ -2271,7 +2255,6 @@ export const App = (): JSX.Element => { const handleComposerSubmitIdea = async ( idea: string, - agentHarness: HarnessKind, attachments: readonly NewSessionAttachment[], ): Promise => { const cwd = uniqueProjectDir( @@ -2282,7 +2265,7 @@ export const App = (): JSX.Element => { } // Terminal-first: the new session's canvas slides in once it paints. setRightCollapsed(true); - await createSessionAt(cwd, agentHarness, { + await createSessionAt(cwd, selectedHarness, { keepComposerOpen: true, standaloneBuilder: true, scaffold: { template: "default" }, @@ -2298,17 +2281,14 @@ export const App = (): JSX.Element => { setComposing(false); }; - const handleComposerUseTemplate = ( - template: GalleryTemplate, - agentHarness: HarnessKind, - ): void => { + const handleComposerUseTemplate = (template: GalleryTemplate): void => { const cwd = uniqueProjectDir(template.id); if (!cwd) { harness.showToast("Set a project folder first — use the + to open one."); return; } setRightCollapsed(true); - void handleUseTemplate(cwd, template, "welcome", agentHarness); + void handleUseTemplate(cwd, template, "welcome"); }; // Bulk discovery from the add dialog. @@ -3122,14 +3102,7 @@ export const App = (): JSX.Element => { recentDirs={harness.settings?.recentDirs ?? []} listDir={harness.listDir} onExit={() => setTemplatesOpen(false)} - onUse={(cwd, template) => - handleUseTemplate( - cwd, - template, - "template_gallery", - templatesView?.harness, - ) - } + onUse={handleUseTemplate} listTemplates={harness.listTemplates} getTemplate={harness.getTemplate} openTemplateId={deepLinkTemplateId} @@ -3376,16 +3349,16 @@ export const App = (): JSX.Element => { { + onBrowseTemplates={() => { studioRestoreGenerationRef.current += 1; setStudioSelection(null); setSelectedProject(null); - setTemplatesOpen(true, agentHarness); + setTemplatesOpen(true); }} listTemplates={harness.listTemplates} telemetryOptIn={ diff --git a/packages/harness/web/src/components/NewSessionComposer.tsx b/packages/harness/web/src/components/NewSessionComposer.tsx index 85f2eb1e1..19515af4a 100644 --- a/packages/harness/web/src/components/NewSessionComposer.tsx +++ b/packages/harness/web/src/components/NewSessionComposer.tsx @@ -83,15 +83,14 @@ interface NewSessionComposerProps { * App derives the folder and runs the scaffold+inject path. */ onSubmitIdea: ( idea: string, - harness: HarnessKind, attachments: readonly NewSessionAttachment[], ) => Promise; /** Surface a file-resolution or submit failure in the app's existing toast. */ onAttachmentError: (message: string) => void; /** Start a session from a catalog template (clone + first run). */ - onUseTemplate: (template: GalleryTemplate, harness: HarnessKind) => void; + onUseTemplate: (template: GalleryTemplate) => void; /** Navigate to the full templates catalog. */ - onBrowseTemplates: (harness: HarnessKind) => void; + onBrowseTemplates: () => void; /** Template catalog fetch. */ listTemplates: () => Promise; /** First-run telemetry opt-in (SAP-1988): off by default, folded in from the @@ -190,7 +189,7 @@ export function NewSessionComposer({ setLeaving(true); const queuedAttachments = attachmentsRef.current; window.setTimeout(() => { - void onSubmitIdea(idea.trim(), harness, queuedAttachments).catch( + void onSubmitIdea(idea.trim(), queuedAttachments).catch( (err: unknown) => { submittingRef.current = false; setLeaving(false); @@ -517,7 +516,7 @@ export function NewSessionComposer({ type="button" className="composer-templates-all" data-testid="composer-browse-templates" - onClick={() => onBrowseTemplates(harness)} + onClick={onBrowseTemplates} > Browse all templates @@ -529,7 +528,7 @@ export function NewSessionComposer({ type="button" className="composer-template-card" data-testid={`composer-template-${template.id}`} - onClick={() => leaveThen(() => onUseTemplate(template, harness))} + onClick={() => leaveThen(() => onUseTemplate(template))} > {template.name} diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 2e9db4d94..af3c13d8a 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -3465,6 +3465,10 @@ export class MockApi implements HarnessApi { } async listHarnesses(): Promise { + if ((window as unknown as { __MOCK_HARNESS_REGISTRY_FAIL__?: boolean }) + .__MOCK_HARNESS_REGISTRY_FAIL__) { + throw new Error("mock: harness registry unavailable"); + } await delay(120); const uninstalled = (window as unknown as { __MOCK_UNINSTALLED_HARNESSES__?: string[] }) diff --git a/packages/harness/web/src/lib/navigation-history.ts b/packages/harness/web/src/lib/navigation-history.ts index 075a25416..06bc12185 100644 --- a/packages/harness/web/src/lib/navigation-history.ts +++ b/packages/harness/web/src/lib/navigation-history.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; -import type { HarnessKind, SessionSummary } from "@shared/types"; +import type { SessionSummary } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; import type { StudioProjectId } from "@shared/agent-map"; @@ -23,7 +23,7 @@ export type NavigationVisit = } | { kind: "review"; summary: SessionSummary } | { kind: "composer" } - | { kind: "templates"; harness?: HarnessKind }; + | { kind: "templates" }; export interface NavigationHistoryState { entries: NavigationVisit[]; From b2cbf2c0b8c0beee274d60141250d458ff5b1398 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 8 Sep 2026 11:24:07 -0700 Subject: [PATCH 6/9] test(harness): choose project session harness through the UI --- packages/harness/web/e2e/project-axis.spec.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/harness/web/e2e/project-axis.spec.ts b/packages/harness/web/e2e/project-axis.spec.ts index 28f8d2969..83ffb55f3 100644 --- a/packages/harness/web/e2e/project-axis.spec.ts +++ b/packages/harness/web/e2e/project-axis.spec.ts @@ -168,17 +168,9 @@ test.describe("durable Studio project navigation", () => { test("the project plus starts a coding session at its root without creating an agent", async ({ page, }) => { - await page.evaluate(() => { - const key = "sapiom-harness-ui-prefs"; - const current = JSON.parse(localStorage.getItem(key) ?? "{}") as Record< - string, - unknown - >; - localStorage.setItem( - key, - JSON.stringify({ ...current, preferredHarness: "codex" }), - ); - }); + await page.getByTestId("rail-create-new").click(); + await page.getByTestId("composer-harness-select").click(); + await page.getByTestId("composer-harness-option-codex").click(); const group = page.getByTestId("workspace-group-dashboard-keeper"); const row = group.getByTestId("project-row-dashboard-keeper"); const start = group.getByTestId("project-start-session-dashboard-keeper"); From aa66db851004bfec456e24f1a53d9dcddc29d38e Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 8 Sep 2026 13:29:34 -0700 Subject: [PATCH 7/9] refactor(harness): name the default harness selection --- packages/harness/web/src/App.tsx | 3 ++- packages/harness/web/src/lib/harness-registry.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 5aee7e3bd..98af0b0bb 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -158,6 +158,7 @@ import type { PaletteAction } from "./lib/palette"; import { toggleTheme } from "./lib/theme"; import { loadUiPrefs, saveUiPrefs } from "./lib/ui-prefs"; import { + DEFAULT_HARNESS, FALLBACK_HARNESSES, isHarnessSelectable, orderHarnesses, @@ -282,7 +283,7 @@ const shellApi = createApi(); export const App = (): JSX.Element => { const harness = useHarnessState(); const [selectedHarness, setSelectedHarness] = useState( - () => loadUiPrefs().preferredHarness ?? "claude-code", + () => loadUiPrefs().preferredHarness ?? DEFAULT_HARNESS, ); const [harnessEntries, setHarnessEntries] = useState( null, diff --git a/packages/harness/web/src/lib/harness-registry.ts b/packages/harness/web/src/lib/harness-registry.ts index 57b94c479..0c5362450 100644 --- a/packages/harness/web/src/lib/harness-registry.ts +++ b/packages/harness/web/src/lib/harness-registry.ts @@ -7,6 +7,8 @@ import type { HarnessEntry, HarnessKind } from "@shared/types"; import { SPAWNABLE_HARNESS_KINDS } from "@shared/types"; +export const DEFAULT_HARNESS: HarnessKind = "claude-code"; + /** Fallback shown until (or in case) the registry fetch resolves — the two * embedded adapters every install ships, assumed selectable so demo mode * and older servers behave exactly as before. Labels mirror the upstream From 74cad51a268874ae636b77ae728911640a552f9a Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 8 Sep 2026 22:19:07 -0700 Subject: [PATCH 8/9] fix(harness): finish archive backfill before retention --- .changeset/archive-before-retention.md | 2 +- .../harness/src/core/record-archive.test.ts | 6 +- packages/harness/src/core/record-archive.ts | 14 ++-- packages/harness/src/server/index.ts | 76 +++++++------------ .../src/server/record-archive-wiring.test.ts | 54 ++++++++++--- 5 files changed, 84 insertions(+), 68 deletions(-) diff --git a/.changeset/archive-before-retention.md b/.changeset/archive-before-retention.md index 935e70c31..1fe7edc3f 100644 --- a/.changeset/archive-before-retention.md +++ b/.changeset/archive-before-retention.md @@ -2,4 +2,4 @@ "@sapiom/harness": patch --- -Archive historical conversations before event retention can remove their source events during startup. +Archive all historical conversation batches before event retention removes their source events. Keep source events if archiving fails, and retry at the next scheduled cleanup. diff --git a/packages/harness/src/core/record-archive.test.ts b/packages/harness/src/core/record-archive.test.ts index 190258959..3669c83a2 100644 --- a/packages/harness/src/core/record-archive.test.ts +++ b/packages/harness/src/core/record-archive.test.ts @@ -384,11 +384,13 @@ describe("backfillSessionRecords", () => { expect(capped).toEqual([2]); }); - it("skips a conversation the fold has nothing for, without failing the pass", async () => { + it.each(["missing", "empty"])("skips a %s conversation without failing the pass", async (kind) => { const archived = await backfillSessionRecords({ conversationIds: async () => ["gone", "here"], readFromEvents: async (id) => - id === "gone" ? null : record({ harnessSessionId: id, mergedSessionIds: [id], agentSessionId: null }), + id === "gone" + ? (kind === "missing" ? null : record({ turns: [], turnCount: 0 })) + : record({ harnessSessionId: id, mergedSessionIds: [id], agentSessionId: null }), archive, }); expect(archived).toEqual(["here"]); diff --git a/packages/harness/src/core/record-archive.ts b/packages/harness/src/core/record-archive.ts index 473da5fa2..18e78792f 100644 --- a/packages/harness/src/core/record-archive.ts +++ b/packages/harness/src/core/record-archive.ts @@ -534,10 +534,8 @@ export interface BackfillOptions { onCapped?: (remaining: number) => void; } -/** Default ceiling for one backfill pass. High enough to cover a typical - * install's whole history on the first boot after this shipped, low enough - * that a pathological log doesn't turn boot into a write storm. Whatever is - * left is archived by the next boot's pass. */ +/** Maximum writes per batch. Callers must process the remaining batches before + * retention can remove their source events. */ export const RECORDS_BACKFILL_MAX = 200; /** @@ -550,7 +548,8 @@ export const RECORDS_BACKFILL_MAX = 200; * on. Idempotent: a conversation already archived is skipped, so the steady * state after the first pass is "nothing to do". * - * Never throws. Returns the ids it archived. + * Returns the ids it archived. Read/write failures propagate so callers can + * preserve the source events rather than continuing with retention. */ export async function backfillSessionRecords(options: BackfillOptions): Promise { const maxRecords = options.maxRecords ?? RECORDS_BACKFILL_MAX; @@ -565,9 +564,10 @@ export async function backfillSessionRecords(options: BackfillOptions): Promise< continue; } const record = await options.readFromEvents(id); - if (!record) continue; + if (!record || record.turns.length === 0) continue; const written = await options.archive.write(record); - if (written) archived.push(id); + if (!written) throw new Error(`Could not archive conversation ${id}; keeping source events`); + archived.push(id); } if (remaining > 0) options.onCapped?.(remaining); return archived; diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 31a983798..c37d044ca 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -99,6 +99,7 @@ import { import { backfillSessionRecords, createRecordArchive, + RECORDS_BACKFILL_MAX, } from "../core/record-archive.js"; import { createHarnessEmitter } from "../core/collector/analytics-emitter.js"; import { migrateHarnessIdentity } from "../core/collector/identity-migration.js"; @@ -3024,54 +3025,35 @@ export const startServer = async ( }; }); - // One boot-time pass that archives conversations the log still holds but the - // archive doesn't, then sweeps the archive's own caps. This is what covers the - // two cases archiving-at-exit can't: a harness that was force-killed (no exit - // transition, no session.end), and every session that ended before this - // existed — whose history would otherwise vanish at its 30-day mark. - // - // Retention must wait for this pass: reads run outside the store's exclusive - // queue, so a sweep could otherwise delete old events before we archive them. - // - // Fire-and-forget: boot must not wait on it. The cost is one full index build - // (~130 ms against a 50 MB log), which the first history open would have paid - // anyway. - const recordBackfill = backfillSessionRecords({ - conversationIds: () => sessionRecordReader.conversationIds(), - readFromEvents: (id) => sessionRecordReader.readFromEvents(id), - archive: recordArchive, - isLiveSession: (harnessSessionId) => { - const session = sessionManager.get(harnessSessionId); - return session !== undefined && session.status !== "exited"; - }, - onCapped: (remaining) => { - console.error( - `[harness] session record backfill hit its per-boot cap; ${remaining} conversation(s) left for the next boot`, - ); - }, - }) - .then(() => recordArchive.sweep()) - .catch((err: unknown) => { - console.error("[harness] session record backfill failed:", err); + // Archive every batch before retention can delete source events. Keep these + // background cycles serial: startup stays responsive, and a read/write failure + // skips cleanup while the next timer tick retries the unarchived conversations. + let recordMaintenance = Promise.resolve(); + const runRecordMaintenance = (): Promise => { + recordMaintenance = recordMaintenance.then(async () => { + const ids = await sessionRecordReader.conversationIds(); + for (let offset = 0; offset < ids.length; offset += RECORDS_BACKFILL_MAX) { + await backfillSessionRecords({ + conversationIds: async () => ids.slice(offset, offset + RECORDS_BACKFILL_MAX), + readFromEvents: (id) => sessionRecordReader.readFromEvents(id), + archive: recordArchive, + isLiveSession: (id) => { + const session = sessionManager.get(id); + return session !== undefined && session.status !== "exited"; + }, + }); + } + await recordArchive.sweep(); + // The exclusive queue also protects retention's read/filter/rename from + // concurrent event appends. + await eventStore.runExclusive(() => sweepNdjson(eventStorePath)); + }).catch((err: unknown) => { + console.error("[harness] session record maintenance failed:", err); }); - - // Boot-time retention sweep: keeps events.ndjson within the 50 MB / 30-day - // caps even on long-lived installs. Runs through the store's exclusive queue - // so the sweep's read→filter→rename window never races a concurrent append. - // Wait for backfill before every sweep, including a timer tick during a slow - // boot pass. Server startup stays independent of both maintenance tasks. - const runNdjsonSweep = (): void => { - void recordBackfill - .then(() => eventStore.runExclusive(() => sweepNdjson(eventStorePath))) - .catch((err: unknown) => { - console.error("[harness] events.ndjson retention sweep failed:", err); - }); + return recordMaintenance; }; - runNdjsonSweep(); - const ndjsonRetentionTimer = setInterval( - runNdjsonSweep, - NDJSON_RETENTION_SWEEP_MS, - ); + void runRecordMaintenance(); + const ndjsonRetentionTimer = setInterval(runRecordMaintenance, NDJSON_RETENTION_SWEEP_MS); ndjsonRetentionTimer.unref?.(); const harnessVersion = readVersion(); @@ -4661,7 +4643,7 @@ export const startServer = async ( await registrationClosing; await settle(() => sessionManager.flush()); await settle(async () => { - await recordBackfill; + await recordMaintenance; while (pendingRecordArchives.size > 0) { await Promise.all([...pendingRecordArchives]); } diff --git a/packages/harness/src/server/record-archive-wiring.test.ts b/packages/harness/src/server/record-archive-wiring.test.ts index 04be44990..83961af50 100644 --- a/packages/harness/src/server/record-archive-wiring.test.ts +++ b/packages/harness/src/server/record-archive-wiring.test.ts @@ -232,7 +232,7 @@ describe("session record archive wiring", () => { ); }, 20_000); - it("archives conversations that ended before the archive existed", async () => { + it.each([1, recordArchive.RECORDS_BACKFILL_MAX + 1])("archives historical conversations before cleanup (count=%i)", async (count) => { // An events file from an install that predates this feature: a complete // conversation, no archive, and no registry entry for it. const expiredAt = Date.now() - retention.DEFAULT_MAX_AGE_MS - 60_000; @@ -264,7 +264,15 @@ describe("session record archive wiring", () => { payload: { assistantText: "done" }, }, ]; - await writeFile(eventStorePath, events.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8"); + const history = Array.from({ length: count }, (_, index) => events.map((event) => ({ + ...event, + eventId: `${event.eventId}-${index}`, + seq: event.seq + index * events.length, + ts: new Date(Date.parse(event.ts) + index).toISOString(), + harnessSessionId: index === 0 ? event.harnessSessionId : `${event.harnessSessionId}-${index}`, + agentSessionId: index === 0 ? event.agentSessionId : `${event.agentSessionId}-${index}`, + }))).flat(); + await writeFile(eventStorePath, history.map((event) => `${JSON.stringify(event)}\n`).join(""), "utf8"); // Hold the backfill to prove retention cannot delete its source events, // even when startup finishes before the archive work does. @@ -285,20 +293,44 @@ describe("session record archive wiring", () => { releaseBackfill(); } - await vi.waitFor( - async () => { - const archived = await readArchived("sess-legacy"); - expect(archived?.turns[0].prompt).toBe("from before the archive existed"); - expect(archived?.turns[0].assistantText).toBe("done"); - }, - { timeout: 10_000, interval: 100 }, - ); await vi.waitFor(async () => { expect(await readFile(eventStorePath, "utf8")).not.toContain("sess-legacy"); - }); + }, { timeout: 10_000, interval: 100 }); + // This is the oldest conversation, beyond the first batch when count > 200. const archived = await fetchRecord("agent-legacy"); expect(archived.status).toBe(200); expect(archived.body?.turns[0].prompt).toBe("from before the archive existed"); expect(archived.body?.turns[0].assistantText).toBe("done"); }, 20_000); + + it.each(["write", "read"])("keeps source events when archive %s fails", async (failure) => { + const prompt = "keep this conversation until it is archived"; + const event: AnalyticsEvent = { + eventId: "evt-unarchived", seq: 1, + ts: new Date(Date.now() - retention.DEFAULT_MAX_AGE_MS - 60_000).toISOString(), + userId: null, tenantId: null, machineId: "machine-1", + harnessSessionId: "sess-unarchived", agentSessionId: "agent-unarchived", + harness: "claude-code", type: "prompt.submitted", payload: { prompt }, + }; + await writeFile(eventStorePath, `${JSON.stringify(event)}\n`, "utf8"); + const backfill = recordArchive.backfillSessionRecords; + const backfillSpy = vi.spyOn(recordArchive, "backfillSessionRecords"); + const intervals = vi.spyOn(globalThis, "setInterval"); + if (failure === "write") await writeFile(recordsRoot, "blocks archive directory creation"); + else backfillSpy.mockRejectedValue(new Error("archive source unavailable")); + const sweepSpy = vi.spyOn(retention, "sweepNdjson"); + server = await boot(); + const tick = intervals.mock.calls.find(([, ms]) => ms === 6 * 60 * 60 * 1_000)?.[0]; + expect(tick).toBeTypeOf("function"); + const runMaintenance = tick as () => Promise; + // Another scheduled attempt must also preserve the source while it fails. + await runMaintenance(); + expect(sweepSpy).not.toHaveBeenCalled(); + expect(await readFile(eventStorePath, "utf8")).toContain(prompt); + if (failure === "write") await rm(recordsRoot); + else backfillSpy.mockImplementation(backfill); + await runMaintenance(); + expect(await readFile(eventStorePath, "utf8")).not.toContain(prompt); + expect((await fetchRecord("agent-unarchived")).body?.turns[0].prompt).toBe(prompt); + }); }); From 1f3869a95730ef7bacf24d8734ca0aa7571b9508 Mon Sep 17 00:00:00 2001 From: Brett Wallace Date: Tue, 8 Sep 2026 22:55:29 -0700 Subject: [PATCH 9/9] fix(harness): keep archive maintenance bounded --- .changeset/archive-before-retention.md | 2 +- .../harness/src/core/record-archive.test.ts | 23 ++++++------ packages/harness/src/core/record-archive.ts | 24 +++++-------- packages/harness/src/server/index.ts | 36 +++++++++++-------- .../src/server/record-archive-wiring.test.ts | 29 +++++++++++++-- 5 files changed, 69 insertions(+), 45 deletions(-) diff --git a/.changeset/archive-before-retention.md b/.changeset/archive-before-retention.md index 1fe7edc3f..a78006df8 100644 --- a/.changeset/archive-before-retention.md +++ b/.changeset/archive-before-retention.md @@ -2,4 +2,4 @@ "@sapiom/harness": patch --- -Archive all historical conversation batches before event retention removes their source events. Keep source events if archiving fails, and retry at the next scheduled cleanup. +Limit archive backfill to 200 conversations per maintenance pass. Keep source events while work remains or archiving fails, and retry at the next scheduled cleanup. diff --git a/packages/harness/src/core/record-archive.test.ts b/packages/harness/src/core/record-archive.test.ts index 3669c83a2..146e2ef36 100644 --- a/packages/harness/src/core/record-archive.test.ts +++ b/packages/harness/src/core/record-archive.test.ts @@ -354,34 +354,35 @@ describe("backfillSessionRecords", () => { const read = new Set(); const archived = await backfillSessionRecords({ - conversationIds: async () => ["sess-live", "sess-done", "sess-missing"], + conversationIds: async () => ["sess-live", "sess-missing", "sess-done"], readFromEvents: async (id) => { read.add(id); return record({ harnessSessionId: id, mergedSessionIds: [id], agentSessionId: null }); }, archive, isLiveSession: (id) => id === "sess-live", + maxRecords: 1, }); - expect(archived).toEqual(["sess-missing"]); + expect(archived).toEqual({ archived: ["sess-missing"], complete: true }); // A live session is never even folded — the point is not to store a // half-finished record over the one its exit will write. expect([...read]).toEqual(["sess-missing"]); expect(await archive.has("sess-live")).toBe(false); }); - it("stops at its cap and reports what it left behind", async () => { - const capped: number[] = []; - const archived = await backfillSessionRecords({ + it("stops at its cap and finishes the remaining work on later passes", async () => { + const options = { conversationIds: async () => ["a", "b", "c", "d"], - readFromEvents: async (id) => record({ harnessSessionId: id, mergedSessionIds: [id], agentSessionId: null }), + readFromEvents: async (id: string) => record({ harnessSessionId: id, mergedSessionIds: [id], agentSessionId: null }), archive, maxRecords: 2, - onCapped: (remaining) => capped.push(remaining), - }); + }; - expect(archived).toEqual(["a", "b"]); - expect(capped).toEqual([2]); + expect(await backfillSessionRecords(options)).toEqual({ archived: ["a", "b"], complete: false }); + expect(await archive.has("c")).toBe(false); + expect(await backfillSessionRecords(options)).toEqual({ archived: ["c", "d"], complete: true }); + expect(await backfillSessionRecords(options)).toEqual({ archived: [], complete: true }); }); it.each(["missing", "empty"])("skips a %s conversation without failing the pass", async (kind) => { @@ -393,6 +394,6 @@ describe("backfillSessionRecords", () => { : record({ harnessSessionId: id, mergedSessionIds: [id], agentSessionId: null }), archive, }); - expect(archived).toEqual(["here"]); + expect(archived).toEqual({ archived: ["here"], complete: true }); }); }); diff --git a/packages/harness/src/core/record-archive.ts b/packages/harness/src/core/record-archive.ts index 18e78792f..834797a3c 100644 --- a/packages/harness/src/core/record-archive.ts +++ b/packages/harness/src/core/record-archive.ts @@ -529,13 +529,10 @@ export interface BackfillOptions { isLiveSession?: (harnessSessionId: string) => boolean; /** Ceiling on how many conversations one pass archives. */ maxRecords?: number; - /** Called with the number of eligible conversations left unarchived when the - * cap cut the pass short — a bounded pass must say what it didn't do. */ - onCapped?: (remaining: number) => void; } -/** Maximum writes per batch. Callers must process the remaining batches before - * retention can remove their source events. */ +/** Limit startup disk writes. Any remainder waits for a later maintenance + * pass; retention must preserve the source events until backfill completes. */ export const RECORDS_BACKFILL_MAX = 200; /** @@ -548,27 +545,24 @@ export const RECORDS_BACKFILL_MAX = 200; * on. Idempotent: a conversation already archived is skipped, so the steady * state after the first pass is "nothing to do". * - * Returns the ids it archived. Read/write failures propagate so callers can - * preserve the source events rather than continuing with retention. + * Returns the ids it archived and whether the pass finished. An incomplete + * pass or a read/write failure must prevent retention from deleting sources. */ -export async function backfillSessionRecords(options: BackfillOptions): Promise { +export async function backfillSessionRecords( + options: BackfillOptions, +): Promise<{ archived: string[]; complete: boolean }> { const maxRecords = options.maxRecords ?? RECORDS_BACKFILL_MAX; const ids = await options.conversationIds(); const archived: string[] = []; - let remaining = 0; for (const id of ids) { if (options.isLiveSession?.(id)) continue; if (await options.archive.has(id)) continue; - if (archived.length >= maxRecords) { - remaining += 1; - continue; - } + if (archived.length >= maxRecords) return { archived, complete: false }; const record = await options.readFromEvents(id); if (!record || record.turns.length === 0) continue; const written = await options.archive.write(record); if (!written) throw new Error(`Could not archive conversation ${id}; keeping source events`); archived.push(id); } - if (remaining > 0) options.onCapped?.(remaining); - return archived; + return { archived, complete: true }; } diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index c37d044ca..d3d905dcd 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -99,7 +99,6 @@ import { import { backfillSessionRecords, createRecordArchive, - RECORDS_BACKFILL_MAX, } from "../core/record-archive.js"; import { createHarnessEmitter } from "../core/collector/analytics-emitter.js"; import { migrateHarnessIdentity } from "../core/collector/identity-migration.js"; @@ -3025,28 +3024,35 @@ export const startServer = async ( }; }); - // Archive every batch before retention can delete source events. Keep these - // background cycles serial: startup stays responsive, and a read/write failure - // skips cleanup while the next timer tick retries the unarchived conversations. + // Bound archive work per pass and run passes one at a time. Remaining work + // or a read/write failure skips cleanup; the next timer tick retries the + // conversations that are not yet archived. let recordMaintenance = Promise.resolve(); + // Session-exit sweeps can evict archives between passes. Remember completed + // writes until event cleanup succeeds, so a large backfill makes progress. + const archivedDuringBackfill = new Set(); const runRecordMaintenance = (): Promise => { recordMaintenance = recordMaintenance.then(async () => { - const ids = await sessionRecordReader.conversationIds(); - for (let offset = 0; offset < ids.length; offset += RECORDS_BACKFILL_MAX) { - await backfillSessionRecords({ - conversationIds: async () => ids.slice(offset, offset + RECORDS_BACKFILL_MAX), - readFromEvents: (id) => sessionRecordReader.readFromEvents(id), - archive: recordArchive, - isLiveSession: (id) => { - const session = sessionManager.get(id); - return session !== undefined && session.status !== "exited"; - }, - }); + const { archived, complete } = await backfillSessionRecords({ + conversationIds: async () => (await sessionRecordReader.conversationIds()) + .filter((id) => !archivedDuringBackfill.has(id)), + readFromEvents: (id) => sessionRecordReader.readFromEvents(id), + archive: recordArchive, + isLiveSession: (id) => { + const session = sessionManager.get(id); + return session !== undefined && session.status !== "exited"; + }, + }); + for (const id of archived) archivedDuringBackfill.add(id); + if (!complete) { + console.warn("[harness] archive backfill reached its limit; keeping source events until the next pass"); + return; } await recordArchive.sweep(); // The exclusive queue also protects retention's read/filter/rename from // concurrent event appends. await eventStore.runExclusive(() => sweepNdjson(eventStorePath)); + archivedDuringBackfill.clear(); }).catch((err: unknown) => { console.error("[harness] session record maintenance failed:", err); }); diff --git a/packages/harness/src/server/record-archive-wiring.test.ts b/packages/harness/src/server/record-archive-wiring.test.ts index 83961af50..c8cb01c18 100644 --- a/packages/harness/src/server/record-archive-wiring.test.ts +++ b/packages/harness/src/server/record-archive-wiring.test.ts @@ -15,7 +15,7 @@ * disappearing at its 30-day mark. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -232,7 +232,7 @@ describe("session record archive wiring", () => { ); }, 20_000); - it.each([1, recordArchive.RECORDS_BACKFILL_MAX + 1])("archives historical conversations before cleanup (count=%i)", async (count) => { + it.each([1, 200, 201, 401])("archives historical conversations before cleanup (count=%i)", async (count) => { // An events file from an install that predates this feature: a complete // conversation, no archive, and no registry entry for it. const expiredAt = Date.now() - retention.DEFAULT_MAX_AGE_MS - 60_000; @@ -285,6 +285,7 @@ describe("session record archive wiring", () => { return backfill(options); }); const sweepSpy = vi.spyOn(retention, "sweepNdjson"); + const intervals = vi.spyOn(globalThis, "setInterval"); try { server = await boot(); expect(backfillSpy).toHaveBeenCalledOnce(); @@ -293,10 +294,32 @@ describe("session record archive wiring", () => { releaseBackfill(); } + await backfillSpy.mock.results[0].value; + if (count > 200) { + expect((await readdir(recordsRoot)).filter((file) => file.endsWith(".json"))).toHaveLength(200); + expect(await readArchived("sess-legacy")).toBeNull(); + expect(sweepSpy).not.toHaveBeenCalled(); + expect(await readFile(eventStorePath, "utf8")).toContain("sess-legacy"); + // Finish the remaining work on the next scheduled pass, not at boot. + const tick = intervals.mock.calls.find(([, ms]) => ms === 6 * 60 * 60 * 1_000)?.[0]; + expect(tick).toBeTypeOf("function"); + for (let pass = 1; pass * 200 < count; pass += 1) { + // Session-exit archive sweeps can evict completed files between passes. + // Force that eviction to prove a large backfill still makes progress. + if (count > 400) { + await recordArchive.createRecordArchive({ root: recordsRoot, maxTotalBytes: 0 }).sweep(); + } + expect(sweepSpy).not.toHaveBeenCalled(); + expect(await readFile(eventStorePath, "utf8")).toContain("sess-legacy"); + await (tick as () => Promise)(); + } + expect(sweepSpy).toHaveBeenCalledOnce(); + } + await vi.waitFor(async () => { expect(await readFile(eventStorePath, "utf8")).not.toContain("sess-legacy"); }, { timeout: 10_000, interval: 100 }); - // This is the oldest conversation, beyond the first batch when count > 200. + // The oldest conversation must survive even when it needs a later pass. const archived = await fetchRecord("agent-legacy"); expect(archived.status).toBe(200); expect(archived.body?.turns[0].prompt).toBe("from before the archive existed");