From 6091c3b203467fa7b31fd36d4b07316d6a7bfa5c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 01:20:38 -0400 Subject: [PATCH 01/10] feat(core): resolve non-git directories as first-class path-keyed projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 (spec spec-20260905-045114-session-device-lifecycle): Project.resolve now keys non-git homes on their resolved worktree path (dir: hash) instead of collapsing them into the global project with the filesystem root as worktree — the founding shape of the invisible-group incident, where 687 sessions were API-visible but rendered nowhere. Git-derived identity is untouched; global remains defined for legacy rows. --- packages/core/src/project.ts | 17 ++++++++++++++++- packages/core/test/project.test.ts | 28 +++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 49e054d7c..48517d6d7 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -17,6 +17,16 @@ export type ID = ProjectSchema.ID export const Vcs = ProjectSchema.Vcs export type Vcs = ProjectSchema.Vcs +/** + * Directory-keyed project identity for non-git homes (D1, spec + * spec-20260905-045114-session-device-lifecycle): every directory a session + * opens is a first-class home, keyed on the resolved worktree path — never + * collapsed into the global project. + */ +export function dirKey(directory: AbsolutePath): ID { + return ID.make(Hash.fast(`dir:${directory}`)) +} + export class Info extends Schema.Class("Project.Info")({ id: ID, }) {} @@ -109,7 +119,12 @@ const layer = Layer.effect( const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) { const repo = yield* git.repo.discover(input) - if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } + if (!repo) { + // D1: a non-git directory is a first-class home keyed on its own + // resolved path — never the global project, never the filesystem root. + const directory = AbsolutePath.make(yield* fs.resolve(input)) + return { id: dirKey(directory), directory, vcs: undefined } + } const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index fa709a8b2..7248346f0 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -39,7 +39,7 @@ async function rootCommit(dir: string) { } describe("ProjectV2.resolve", () => { - it.live("returns global for non-git directory", () => + it.live("resolves a non-git directory to a first-class project keyed on the path", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -49,13 +49,35 @@ describe("ProjectV2.resolve", () => { const result = yield* project.resolve(abs(tmp.path)) - expect(result.id).toBe(ProjectV2.ID.make("global")) - expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) + expect(result.id).toBe(ProjectV2.dirKey(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path))))) + expect(result.id).not.toBe(ProjectV2.ID.global) + expect(result.directory).toBe(yield* real(tmp.path)) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), ) + it.live("non-git projects are path-keyed: same path resolves identically, distinct paths differ", () => + Effect.gen(function* () { + const a = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const b = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const project = yield* ProjectV2.Service + + const first = yield* project.resolve(abs(a.path)) + const second = yield* project.resolve(abs(a.path)) + const other = yield* project.resolve(abs(b.path)) + + expect(second.id).toBe(first.id) + expect(other.id).not.toBe(first.id) + }), + ) + it.live("returns git global for repo with no commits and no remote", () => Effect.gen(function* () { const tmp = yield* Effect.acquireRelease( From 72910c77fe8bbdf089e657c230fbe8c52098412d Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 01:47:39 -0400 Subject: [PATCH 02/10] feat(core): boot-time project backfill with git-init re-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 (spec spec-20260905-045114-session-device-lifecycle): ProjectBackfill.run resolves every distinct session directory, re-keys sessions whose project row no longer matches that resolution, and retires the superseded auto-created (directory-keyed) row — so a non-git home that later becomes a git repo merges into the git-derived project. One transaction per worktree (#272 discipline): a merge killed mid-flight converges on the next run, verified by the partial-merge convergence test. Wired into legacy Project.init (forked, failure-logged) so every instance bootstrap reconciles all homes; the backfill itself is idempotent. --- packages/core/src/project/backfill.ts | 132 +++++++++++++ packages/core/test/project-backfill.test.ts | 179 ++++++++++++++++++ packages/opencode/src/project/project.ts | 10 + .../opencode/test/project/project.test.ts | 6 +- 4 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/project/backfill.ts create mode 100644 packages/core/test/project-backfill.test.ts diff --git a/packages/core/src/project/backfill.ts b/packages/core/src/project/backfill.ts new file mode 100644 index 000000000..2aace8415 --- /dev/null +++ b/packages/core/src/project/backfill.ts @@ -0,0 +1,132 @@ +export * as ProjectBackfill from "./backfill" + +import { and, eq, ne } from "drizzle-orm" +import { Context, Effect, Layer } from "effect" +import { WorkspaceTable } from "../control-plane/workspace.sql" +import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" +import { AbsolutePath } from "../schema" +import { SessionTable } from "../session/sql" +import { ProjectV2 } from "../project" +import { ProjectDirectoryTable, ProjectTable } from "./sql" + +export interface Result { + /** Distinct session directories inspected. */ + readonly directories: number + /** Sessions moved to their directory's resolved project. */ + readonly repointed: number + /** Auto-created (directory-keyed) project rows retired by the re-key. */ + readonly retired: readonly ProjectV2.ID[] +} + +export interface Interface { + /** + * D1 boot-time backfill: resolve every distinct session directory and re-key + * sessions whose project row no longer matches that resolution. Idempotent, + * one transaction per worktree (#272 discipline) — a merge interrupted + * mid-flight converges on the next run. + */ + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectBackfill") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = (yield* Database.Service).db + const projects = yield* ProjectV2.Service + + const run = Effect.fn("ProjectBackfill.run")(function* () { + const directories = yield* db + .selectDistinct({ directory: SessionTable.directory }) + .from(SessionTable) + .all() + .pipe(Effect.orDie) + + let repointed = 0 + const retired: ProjectV2.ID[] = [] + + for (const { directory } of directories) { + // Legacy rows may persist an empty directory — nothing to resolve. + if (!directory) continue + const resolved = yield* projects.resolve(AbsolutePath.make(directory)) + const stale = yield* db + .selectDistinct({ id: SessionTable.project_id }) + .from(SessionTable) + .where(and(eq(SessionTable.directory, directory), ne(SessionTable.project_id, resolved.id))) + .all() + .pipe(Effect.orDie) + if (stale.length === 0) continue + + const moving = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(and(eq(SessionTable.directory, directory), ne(SessionTable.project_id, resolved.id))) + .all() + .pipe(Effect.orDie) + + // One transaction per worktree: the project row, the session re-key and + // the auto-row retirement land atomically or not at all. + yield* db + .transaction( + (tx) => + Effect.gen(function* () { + if (resolved.id !== ProjectV2.ID.global) { + yield* tx + .insert(ProjectTable) + .values({ + id: resolved.id, + worktree: resolved.directory, + vcs: resolved.vcs?.type ?? null, + sandboxes: [], + }) + .onConflictDoNothing() + .run() + yield* tx + .insert(ProjectDirectoryTable) + .values({ project_id: resolved.id, directory: AbsolutePath.make(directory) }) + .onConflictDoNothing() + .run() + } + yield* tx + .update(SessionTable) + .set({ project_id: resolved.id }) + .where(and(eq(SessionTable.directory, directory), ne(SessionTable.project_id, resolved.id))) + .run() + + for (const { id } of stale) { + if (id === ProjectV2.ID.global) continue + const row = yield* tx.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get() + if (!row) continue + // Only auto-created rows (directory-keyed, no VCS) retire. + // Git rows and any hand-made row stay — the junk-home lint, + // not the backfill, owns zero-session pruning. + if (row.vcs !== null) continue + if (row.id !== ProjectV2.dirKey(row.worktree)) continue + const remaining = yield* tx + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.project_id, id)) + .all() + if (remaining.length > 0) continue + yield* tx.update(WorkspaceTable).set({ project_id: resolved.id }).where(eq(WorkspaceTable.project_id, id)).run() + yield* tx.delete(ProjectTable).where(eq(ProjectTable.id, id)).run() + retired.push(id) + } + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + + repointed += moving.length + } + + return { directories: directories.length, repointed, retired } + }) + + return Service.of({ run: run() }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node, ProjectV2.node] }) diff --git a/packages/core/test/project-backfill.test.ts b/packages/core/test/project-backfill.test.ts new file mode 100644 index 000000000..4e8de789a --- /dev/null +++ b/packages/core/test/project-backfill.test.ts @@ -0,0 +1,179 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProjectBackfill } from "@opencode-ai/core/project/backfill" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { Hash } from "@opencode-ai/core/util/hash" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectV2.node, ProjectBackfill.node]))) + +function remoteID(remote: string) { + return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) +} + +function abs(value: string) { + return AbsolutePath.make(value) +} + +async function initRepo(dir: string, opts?: { remote?: string }) { + await $`git init`.cwd(dir).quiet() + await $`git config user.email test@opencode.test`.cwd(dir).quiet() + await $`git config user.name Test`.cwd(dir).quiet() + await $`git commit --allow-empty -m root`.cwd(dir).quiet() + if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet() +} + +const seed = (home: { + db: EffectDrizzleSqlite.EffectSQLiteDatabase + project: { id: ProjectV2.ID; worktree: string; vcs?: string | null } + sessions?: { id: string; projectID: ProjectV2.ID }[] +}) => + Effect.gen(function* () { + yield* home.db + .insert(ProjectTable) + .values({ + id: home.project.id, + worktree: abs(home.project.worktree), + vcs: home.project.vcs ?? null, + sandboxes: [], + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + for (const session of home.sessions ?? []) { + yield* home.db + .insert(SessionTable) + .values({ + id: SessionV2.ID.make(session.id), + project_id: session.projectID, + slug: session.id, + directory: home.project.worktree, + title: `Session ${session.id}`, + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + } + }) + +describe("ProjectBackfill", () => { + it.effect("re-points pre-existing global sessions in a non-git home to a first-class project", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const real = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path))) + const db = (yield* Database.Service).db + yield* seed({ db, project: { id: ProjectV2.ID.global, worktree: "/" } }) + yield* seed({ + db, + project: { id: ProjectV2.ID.global, worktree: tmp.path }, + sessions: [ + { id: "ses_a", projectID: ProjectV2.ID.global }, + { id: "ses_b", projectID: ProjectV2.ID.global }, + ], + }) + const backfill = yield* ProjectBackfill.Service + + const result = yield* backfill.run + + const homeID = ProjectV2.dirKey(real) + const rows = yield* db.select().from(SessionTable).all().pipe(Effect.orDie) + expect(rows.map((row) => row.project_id)).toEqual([homeID, homeID]) + expect(result.repointed).toBe(2) + const projects = yield* db.select().from(ProjectTable).all().pipe(Effect.orDie) + const home = projects.find((row) => row.id === homeID) + expect(home?.worktree).toBe(real) + expect(home?.vcs).toBeNull() + expect(projects.find((row) => row.id === ProjectV2.ID.global)).toBeDefined() + }), + ) + + it.effect("re-keys a non-git home that later became a git repo and retires the auto row", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const real = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path))) + const autoID = ProjectV2.dirKey(real) + const db = (yield* Database.Service).db + yield* seed({ + db, + project: { id: autoID, worktree: tmp.path }, + sessions: [ + { id: "ses_a", projectID: autoID }, + { id: "ses_b", projectID: autoID }, + ], + }) + yield* Effect.promise(() => initRepo(tmp.path, { remote: "git@github.com:Acme/App.git" })) + const backfill = yield* ProjectBackfill.Service + + yield* backfill.run + + const gitID = remoteID("github.com/Acme/App") + const rows = yield* db.select().from(SessionTable).all().pipe(Effect.orDie) + expect(rows.map((row) => row.project_id)).toEqual([gitID, gitID]) + const projects = yield* db.select().from(ProjectTable).all().pipe(Effect.orDie) + expect(projects.find((row) => row.id === autoID)).toBeUndefined() + const git = projects.find((row) => row.id === gitID) + expect(git?.worktree).toBe(real) + expect(git?.vcs).toBe("git") + }), + ) + + it.effect("converges when a merge was applied only partially", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const real = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(tmp.path))) + const autoID = ProjectV2.dirKey(real) + const db = (yield* Database.Service).db + yield* seed({ + db, + project: { id: autoID, worktree: tmp.path }, + sessions: [ + { id: "ses_a", projectID: autoID }, + { id: "ses_b", projectID: autoID }, + ], + }) + yield* Effect.promise(() => initRepo(tmp.path, { remote: "git@github.com:Acme/App.git" })) + const gitID = remoteID("github.com/Acme/App") + // Simulate a merge killed mid-flight: one session already re-keyed + // (its git project row exists), the second still on the auto row and + // the auto row not yet retired. + yield* seed({ db, project: { id: gitID, worktree: tmp.path, vcs: "git" } }) + yield* db + .update(SessionTable) + .set({ project_id: gitID }) + .where(eq(SessionTable.id, SessionV2.ID.make("ses_a"))) + .run() + const backfill = yield* ProjectBackfill.Service + + yield* backfill.run + yield* backfill.run + + const rows = yield* db.select().from(SessionTable).all().pipe(Effect.orDie) + expect(rows.map((row) => row.project_id)).toEqual([gitID, gitID]) + const projects = yield* db.select().from(ProjectTable).all().pipe(Effect.orDie) + expect(projects.find((row) => row.id === autoID)).toBeUndefined() + expect(projects.find((row) => row.id === gitID)).toBeDefined() + }), + ) +}) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9870377b2..81c436497 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectBackfill } from "@opencode-ai/core/project/backfill" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AbsolutePath } from "@opencode-ai/core/schema" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -109,6 +110,7 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service + const backfill = yield* ProjectBackfill.Service const projectDirectories = yield* ProjectDirectories.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service @@ -397,6 +399,13 @@ const layer = Layer.effect( const init = Effect.fn("Project.init")(function* () { yield* InstanceState.get(initState) + // D1: every instance boot also reconciles every session home in the + // table — backfill for pre-existing non-git homes and re-key when a + // home became a git repo. Idempotent, so forking it here is safe. + yield* backfill.run.pipe( + Effect.catchCause((cause) => Effect.logWarning("project backfill failed", { cause })), + Effect.forkIn(scope), + ) }) const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) { @@ -474,6 +483,7 @@ export const node = LayerNode.make({ CrossSpawnSpawner.node, ProjectV2.node, ProjectDirectories.node, + ProjectBackfill.node, EventV2Bridge.node, RuntimeFlags.node, Database.node, diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 804b92b08..2ced15bdb 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -139,12 +139,14 @@ describe("Project.fromDirectory", () => { }), ) - it.live("returns global for non-git directory", () => + it.live("resolves a non-git directory to a first-class project keyed on the path", () => Effect.gen(function* () { const project = yield* Project.Service const tmp = yield* tmpdirScoped() const result = yield* project.fromDirectory(tmp) - expect(result.project.id).toBe(ProjectV2.ID.global) + expect(result.project.id).not.toBe(ProjectV2.ID.global) + expect(result.project.worktree).toBe(tmp) + expect(result.project.vcs).toBeUndefined() }), ) From ebea202cead150b34abdd592e5e2df97545224d2 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 01:56:59 -0400 Subject: [PATCH 03/10] feat(core,session): junk-home lint + restore re-resolves the project by worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 (spec spec-20260905-045114-session-device-lifecycle): ProjectJunkLint.prune removes project rows that have held no sessions of ANY state (active or archived) and no workspaces past the 30-day cutoff — the global fallback and archived-only homes survive by construction, verified per-row inside the delete transaction. Restore (setArchived with time undefined) re-resolves the session's home by worktree and carries the re-resolved project identity in the published event too — the projector rewrites the whole session row from the event, so a stale projectID there would revert the re-key. --- packages/core/src/project/junk-lint.ts | 84 +++++++++++ packages/core/test/project-junk-lint.test.ts | 131 ++++++++++++++++++ packages/opencode/src/session/session.ts | 26 +++- .../server/session-restore-resolve.test.ts | 66 +++++++++ 4 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/project/junk-lint.ts create mode 100644 packages/core/test/project-junk-lint.test.ts create mode 100644 packages/opencode/test/server/session-restore-resolve.test.ts diff --git a/packages/core/src/project/junk-lint.ts b/packages/core/src/project/junk-lint.ts new file mode 100644 index 000000000..cbdeebd45 --- /dev/null +++ b/packages/core/src/project/junk-lint.ts @@ -0,0 +1,84 @@ +export * as ProjectJunkLint from "./junk-lint" + +import { and, eq, lt, ne, sql } from "drizzle-orm" +import { Context, Effect, Layer } from "effect" +import { WorkspaceTable } from "../control-plane/workspace.sql" +import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/app-node" +import { SessionTable } from "../session/sql" +import { ProjectV2 } from "../project" +import { ProjectTable } from "./sql" + +/** F6 (spec spec-20260905-045114-session-device-lifecycle): validate against + * real usage — the constant is named so it stays adjustable. */ +export const DEFAULT_CUTOFF_DAYS = 30 + +export interface PruneResult { + /** Project rows removed: zero sessions of ANY state, no workspaces, stale. */ + readonly pruned: readonly ProjectV2.ID[] +} + +export interface Interface { + /** + * D1 junk-home policy: prune project rows that have held no sessions of any + * state (active or archived) and no workspaces for longer than the cutoff. + * The global fallback and rows still holding archived sessions survive. + */ + readonly prune: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectJunkLint") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = (yield* Database.Service).db + + const prune = Effect.fn("ProjectJunkLint.prune")(function* () { + const cutoff = Date.now() - DEFAULT_CUTOFF_DAYS * 24 * 60 * 60 * 1000 + // A NOT EXISTS subquery, not a join-count: "zero sessions of any state" + // must stay true when the session table grows. + const junk = yield* db + .select({ id: ProjectTable.id }) + .from(ProjectTable) + .where( + and( + ne(ProjectTable.id, ProjectV2.ID.global), + lt(ProjectTable.time_updated, cutoff), + sql`(SELECT COUNT(*) FROM ${SessionTable} WHERE ${eq(SessionTable.project_id, ProjectTable.id)}) = 0`, + sql`(SELECT COUNT(*) FROM ${WorkspaceTable} WHERE ${eq(WorkspaceTable.project_id, ProjectTable.id)}) = 0`, + ), + ) + .all() + .pipe(Effect.orDie) + if (junk.length === 0) return { pruned: [] } + + const pruned: ProjectV2.ID[] = [] + for (const { id } of junk) { + // Re-check per row inside the delete transaction: the lint runs + // unattended, so a home may gain a session between scan and delete. + yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const holding = yield* tx + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.project_id, id)) + .all() + if (holding.length > 0) return + yield* tx.delete(ProjectTable).where(eq(ProjectTable.id, id)).run() + pruned.push(id) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + } + return { pruned } + }) + + return Service.of({ prune: prune() }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/test/project-junk-lint.test.ts b/packages/core/test/project-junk-lint.test.ts new file mode 100644 index 000000000..3f81ff718 --- /dev/null +++ b/packages/core/test/project-junk-lint.test.ts @@ -0,0 +1,131 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProjectJunkLint } from "@opencode-ai/core/project/junk-lint" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectJunkLint.node]))) + +const DAY = 24 * 60 * 60 * 1000 + +const home = (values: { id: ProjectV2.ID; worktree: string; ageDays?: number }) => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ + id: values.id, + worktree: AbsolutePath.make(values.worktree), + sandboxes: [], + time_created: Date.now() - (values.ageDays ?? 0) * DAY, + time_updated: Date.now() - (values.ageDays ?? 0) * DAY, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + +const archivedSession = (values: { id: string; projectID: ProjectV2.ID; directory: string }) => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(SessionTable) + .values({ + id: SessionV2.ID.make(values.id), + project_id: values.projectID, + slug: values.id, + directory: values.directory, + title: `Session ${values.id}`, + version: "test", + time_created: Date.now() - 60 * DAY, + time_updated: Date.now() - 60 * DAY, + time_archived: Date.now() - 30 * DAY, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + +const projectIDs = Effect.gen(function* () { + const db = (yield* Database.Service).db + const rows = yield* db.select({ id: ProjectTable.id }).from(ProjectTable).all().pipe(Effect.orDie) + return rows.map((row) => row.id) +}) + +describe("ProjectJunkLint", () => { + it.effect("prunes a zero-session home past the cutoff", () => + Effect.gen(function* () { + const id = ProjectV2.ID.make("junk-home") + yield* home({ id, worktree: "/tmp/junk-home", ageDays: 60 }) + const lint = yield* ProjectJunkLint.Service + + const result = yield* lint.prune + + expect(result.pruned).toEqual([id]) + expect(yield* projectIDs).not.toContain(id) + }), + ) + + it.effect("never prunes a home holding archived sessions", () => + Effect.gen(function* () { + const id = ProjectV2.ID.make("archived-home") + yield* home({ id, worktree: "/tmp/archived-home", ageDays: 60 }) + yield* archivedSession({ id: "ses_archived", projectID: id, directory: "/tmp/archived-home" }) + const lint = yield* ProjectJunkLint.Service + + const result = yield* lint.prune + + expect(result.pruned).toEqual([]) + expect(yield* projectIDs).toContain(id) + }), + ) + + it.effect("never prunes a fresh zero-session home", () => + Effect.gen(function* () { + const id = ProjectV2.ID.make("fresh-home") + yield* home({ id, worktree: "/tmp/fresh-home", ageDays: 2 }) + const lint = yield* ProjectJunkLint.Service + + const result = yield* lint.prune + + expect(result.pruned).toEqual([]) + expect(yield* projectIDs).toContain(id) + }), + ) + + it.effect("never prunes the global fallback", () => + Effect.gen(function* () { + yield* home({ id: ProjectV2.ID.global, worktree: "/", ageDays: 400 }) + const lint = yield* ProjectJunkLint.Service + + const result = yield* lint.prune + + expect(result.pruned).toEqual([]) + expect(yield* projectIDs).toContain(ProjectV2.ID.global) + }), + ) + + it.effect("prunes only homes past the cutoff across a mixed table", () => + Effect.gen(function* () { + const old = ProjectV2.ID.make("old-empty") + const fresh = ProjectV2.ID.make("fresh-empty") + yield* home({ id: old, worktree: "/tmp/old-empty", ageDays: 45 }) + yield* home({ id: fresh, worktree: "/tmp/fresh-empty", ageDays: 5 }) + const lint = yield* ProjectJunkLint.Service + + const result = yield* lint.prune + + expect(result.pruned).toEqual([old]) + const ids = yield* projectIDs + expect(ids).not.toContain(old) + expect(ids).toContain(fresh) + }), + ) +}) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 8945693d5..3ca832f96 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -28,6 +28,7 @@ import { or } from "drizzle-orm" import type { SQL } from "drizzle-orm" import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" @@ -488,7 +489,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" const layer: Layer.Layer< Service, never, - BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Snapshot.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Snapshot.Service | ProjectV2.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -498,6 +499,7 @@ const layer: Layer.Layer< const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const snapshot = yield* Snapshot.Service + const projectV2 = yield* ProjectV2.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -760,13 +762,31 @@ const layer: Layer.Layer< const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { const current = yield* get(input.sessionID).pipe(Effect.orDie) + // D1: restoring re-resolves the session's home by worktree, so archive + // never strands a session on a retired project row — the row the + // directory resolves to now wins, in the row AND in the published event + // (the projector rewrites the whole session row from the event). + let projectID = current.projectID + if (input.time === undefined) { + const resolved = yield* projectV2.resolve(AbsolutePath.make(current.directory)) + if (resolved.id !== current.projectID) { + yield* db + .insert(ProjectTable) + .values({ id: resolved.id, worktree: resolved.directory, vcs: resolved.vcs?.type ?? null, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + projectID = resolved.id + } + } const next = { ...current, + projectID, time: { ...current.time, archived: input.time, updated: Date.now() }, } as Info yield* db .update(SessionTable) - .set({ time_archived: input.time ?? null, time_updated: next.time.updated }) + .set({ time_archived: input.time ?? null, time_updated: next.time.updated, project_id: projectID }) .where(eq(SessionTable.id, input.sessionID)) .run() .pipe(Effect.orDie) @@ -1188,7 +1208,7 @@ function listByProject( export const node = LayerNode.make({ service: Service, layer: layer, - deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Snapshot.node], + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Snapshot.node, ProjectV2.node], }) export * as Session from "./session" diff --git a/packages/opencode/test/server/session-restore-resolve.test.ts b/packages/opencode/test/server/session-restore-resolve.test.ts new file mode 100644 index 000000000..fd3dc7bcf --- /dev/null +++ b/packages/opencode/test/server/session-restore-resolve.test.ts @@ -0,0 +1,66 @@ +import { describe, expect } from "bun:test" +import { $ } from "bun" +import { eq } from "drizzle-orm" +import { Effect } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Hash } from "@opencode-ai/core/util/hash" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Project } from "@/project/project" +import { Session as SessionNs } from "@/session/session" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect( + LayerNode.compile( + LayerNode.group([ + SessionNs.node, + SessionProjector.node, + Project.node, + CrossSpawnSpawner.node, + Database.node, + ]), + ), +) + +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease(SessionNs.use.create(input), (created) => + SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)), + ) + +const remoteID = (remote: string) => ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) + +describe("Session.setArchived restore", () => { + it.instance( + "re-resolves the session's project by worktree when the home changed while archived", + () => + Effect.gen(function* () { + const home = yield* tmpdirScoped() + const session = yield* withSession({ title: "restore-me" }).pipe(provideInstance(home)) + + yield* SessionNs.Service.use((s) => s.setArchived({ sessionID: session.id, time: Date.now() })) + + // While archived, the home becomes a git repo — its project identity + // changes under the session. + yield* Effect.promise(() => + $`git init && git config user.email test@opencode.test && git config user.name Test && git commit --allow-empty -m root && git remote add origin git@github.com:Acme/Restored.git` + .cwd(home) + .quiet() + .nothrow(), + ) + + yield* SessionNs.Service.use((s) => s.setArchived({ sessionID: session.id, time: undefined })) + + const db = (yield* Database.Service).db + const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get().pipe(Effect.orDie) + const expected = remoteID("github.com/Acme/Restored") + expect(row?.project_id).toBe(expected) + const project = yield* Project.use.get(expected) + expect(project?.worktree).toBe(home) + }), + { git: false }, + ) +}) From 8cd69ebb17a7dc73aa63b243e2e2001915310db7 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 01:58:01 -0400 Subject: [PATCH 04/10] feat(session): derived list-currency token on the v2 session.list response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2/H4 (spec spec-20260905-045114-session-device-lifecycle): session.list now returns a currency token derived from the rendered projection — (count, max time_updated, sum of time_updated) over the returned rows — plus the hub build id, recomputed on every response. Property tests enforce 'derived, never hand-bumped': same-tick writes, archive churn, delete-then-touch pairs, and direct out-of-band SQL writes all advance it by construction, and it is a pure function of the projection. Additive optional field with base default (absent) — permitted by D6's change policy and registered in the semantics manifest added next. --- packages/client/src/generated/types.ts | 1 + packages/core/src/session/currency.ts | 36 +++++ packages/core/test/session-currency.test.ts | 170 ++++++++++++++++++++ packages/protocol/src/groups/session.ts | 4 + packages/server/src/handlers/session.ts | 5 + 5 files changed, 216 insertions(+) create mode 100644 packages/core/src/session/currency.ts create mode 100644 packages/core/test/session-currency.test.ts diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index a24d133e6..8650553b3 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -267,6 +267,7 @@ export type SessionsListOutput = { }> } }> + readonly currency?: string | null readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } diff --git a/packages/core/src/session/currency.ts b/packages/core/src/session/currency.ts new file mode 100644 index 000000000..f0ea15cc7 --- /dev/null +++ b/packages/core/src/session/currency.ts @@ -0,0 +1,36 @@ +export * as SessionCurrency from "./currency" + +import { DateTime } from "effect" +import { InstallationVersion } from "../installation/version" +import { SessionSchema } from "./schema" + +/** + * D2 (spec spec-20260905-045114-session-device-lifecycle): the list-currency + * token is DERIVED from the session-table projection the client renders — + * (count, max time_updated, sum of time_updated) over the rows the list + * returns, plus the hub build id. Never hand-bumped: any write that changes + * the rendered projection — same-tick touches, archive churn, out-of-band SQL + * — changes the token by construction, because it is recomputed from the rows + * on every list response. + */ + +export interface Projection { + readonly count: number + readonly maxUpdated: number + readonly sumUpdated: number +} + +export function projection(rows: readonly Pick[]): Projection { + let maxUpdated = 0 + let sumUpdated = 0 + for (const row of rows) { + const updated = DateTime.toEpochMillis(row.time.updated) + if (updated > maxUpdated) maxUpdated = updated + sumUpdated += updated + } + return { count: rows.length, maxUpdated, sumUpdated } +} + +export function token(input: Projection, build: string = InstallationVersion): string { + return `v1.${input.count}.${input.maxUpdated}.${input.sumUpdated}.${build}` +} diff --git a/packages/core/test/session-currency.test.ts b/packages/core/test/session-currency.test.ts new file mode 100644 index 000000000..620a1a184 --- /dev/null +++ b/packages/core/test/session-currency.test.ts @@ -0,0 +1,170 @@ +import { describe, expect } from "bun:test" +import { eq, sql } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionCurrency } from "@opencode-ai/core/session/currency" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Location } from "@opencode-ai/core/location" +import { testEffect } from "./lib/effect" +import { tmpdir } from "./fixture/tmpdir" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => + Effect.succeed({ id: ProjectV2.ID.make("cur-test-project"), directory, vcs: undefined }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), +) + +const location = Location.Ref.make({ directory: AbsolutePath.make("/currency-home") }) +const BUILD = "test-build-1" + +const render = Effect.gen(function* () { + const session = yield* SessionV2.Service + const rows = yield* session.list() + return SessionCurrency.token(SessionCurrency.projection(rows), BUILD) +}) + +describe("SessionCurrency (H4: derived, never hand-bumped)", () => { + it.effect("advances on same-tick writes", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const before = yield* render + + const first = yield* session.create({ location }) + const second = yield* session.create({ location }) + // Force the same tick: identical created/updated stamps for both rows. + const now = Date.now() + yield* Database.Service.use(({ db }) => + db + .update(SessionTable) + .set({ time_created: now, time_updated: now }) + .where(sql`${SessionTable.id} in (${first.id}, ${second.id})`) + .run() + .pipe(Effect.orDie), + ) + + const after = yield* render + + expect(after).not.toBe(before) + }), + ) + + it.effect("advances on archive churn", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + const seeded = yield* render + + // Out-of-band archive: the write path bypasses every API, as migrations + // and ops sometimes do. The rendered projection still changes. + yield* Database.Service.use(({ db }) => + db + .update(SessionTable) + .set({ time_archived: Date.now(), time_updated: Date.now() }) + .where(eq(SessionTable.id, created.id)) + .run() + .pipe(Effect.orDie), + ) + + const after = yield* render + + expect(after).not.toBe(seeded) + }), + ) + + it.effect("advances on delete-then-touch pairs", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const victim = yield* session.create({ location }) + const survivor = yield* session.create({ location }) + const victimRow = yield* Database.Service.use(({ db }) => + db.select().from(SessionTable).where(eq(SessionTable.id, victim.id)).get().pipe(Effect.orDie), + ) + const seeded = yield* render + + yield* Database.Service.use(({ db }) => + db.delete(SessionTable).where(eq(SessionTable.id, victim.id)).run().pipe(Effect.orDie), + ) + // Touch the survivor back to the victim's old timestamp: count drops by + // one, max stays, sum changes — the tuple moves without any "hand bump". + yield* Database.Service.use(({ db }) => + db + .update(SessionTable) + .set({ time_updated: victimRow!.time_created }) + .where(eq(SessionTable.id, survivor.id)) + .run() + .pipe(Effect.orDie), + ) + + const after = yield* render + + expect(after).not.toBe(seeded) + }), + ) + + it.effect("advances on direct out-of-band writes", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + yield* session.create({ location }) + const seeded = yield* render + + yield* Database.Service.use(({ db }) => + db + .update(SessionTable) + .set({ time_updated: Date.now() + 5000 }) + .run() + .pipe(Effect.orDie), + ) + + const after = yield* render + + expect(after).not.toBe(seeded) + }), + ) + + it.effect("binds the hub build id: same rows under a different build give a different token", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const rows = yield* session.list() + + const a = SessionCurrency.token(SessionCurrency.projection(rows), "build-a") + const b = SessionCurrency.token(SessionCurrency.projection(rows), "build-b") + + expect(a).not.toBe(b) + }), + ) + + it.effect("is a pure function of the projection: same projection, same token", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + yield* session.create({ location }) + const rows = yield* session.list() + + const first = SessionCurrency.token(SessionCurrency.projection(rows), BUILD) + const second = SessionCurrency.token(SessionCurrency.projection(rows), BUILD) + + expect(second).toBe(first) + }), + ) +}) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 8ce85ef79..c78f25ce5 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -110,6 +110,10 @@ export const makeSessionGroup = (sessionLo query: SessionsQuery, success: Schema.Struct({ data: Schema.Array(Session.Info), + // D2: derived list-currency token over the returned rows plus hub + // build id. Additive optional field with base default (absent) — + // registered in the session-list semantics manifest. + currency: Schema.optional(Schema.String), cursor: Schema.Struct({ previous: SessionsCursor.pipe(Schema.optional), next: SessionsCursor.pipe(Schema.optional), diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 5b7d354b0..36afe8827 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,4 +1,6 @@ import { SessionV2 } from "@opencode-ai/core/session" +import { SessionCurrency } from "@opencode-ai/core/session/currency" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -39,6 +41,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl const last = sessions.at(-1) return { data: sessions, + // D2: recomputed from the rendered rows on every response — the + // token is derived, never hand-bumped. + currency: SessionCurrency.token(SessionCurrency.projection(sessions), InstallationVersion), cursor: { previous: first ? SessionsCursor.make({ From 7da331be0ddd4a7d2eb0b58264c075e3e79ebdc0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 01:58:56 -0400 Subject: [PATCH 05/10] feat(protocol): freeze session-list query semantics behind the drift gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D6/H5 (spec spec-20260905-045114-session-device-lifecycle): the query semantics of GET /api/session (scoping keys, filter fields, defaults) are declared frozen surface in a checked-in manifest. The gate fixture fails any semantic change — removals, requiredness flips, kind changes, default changes — without a companion manifest update, while additive optional fields with base defaults pass untouched. The server's default limit now imports the manifest's constant, so declared defaults are load-bearing rather than documentation. The founding incident: the v1 route's silent directory-filtered → project-scoped change. --- .../src/groups/session-list-semantics.ts | 70 +++++++++++++++++++ .../test/session-list-semantics.test.ts | 59 ++++++++++++++++ packages/server/src/handlers/session.ts | 5 +- 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/src/groups/session-list-semantics.ts create mode 100644 packages/protocol/test/session-list-semantics.test.ts diff --git a/packages/protocol/src/groups/session-list-semantics.ts b/packages/protocol/src/groups/session-list-semantics.ts new file mode 100644 index 000000000..67fe38164 --- /dev/null +++ b/packages/protocol/src/groups/session-list-semantics.ts @@ -0,0 +1,70 @@ +export * as SessionListSemantics from "./session-list-semantics" + +import { SessionsQuery } from "./session" + +export { SessionsQuery } + +/** + * D6 (spec spec-20260905-045114-session-device-lifecycle): the query semantics + * of session-list endpoints are declared, frozen surface. This manifest IS the + * companion update — a PR that changes what a query means must update it in + * the same change, or the drift-gate fixture (test/session-list-semantics) + * goes red. Additive optional fields with base defaults are permitted without + * a companion update; semantic changes (removals, requiredness flips, default + * changes, kind changes) are not. The 2026-09-05 v1-route semantics change + * (directory-filtered → project-scoped) is the founding incident. + * + * The server's handlers import `defaults` from here, so the declared defaults + * are load-bearing, not documentation. + */ + +export interface QueryField { + readonly name: string + readonly kind: "scope" | "filter" | "cursor" + readonly required: boolean + readonly default: number | string | undefined +} + +export const queryFields = [ + { name: "workspace", kind: "scope", required: false, default: undefined }, + { name: "directory", kind: "scope", required: false, default: undefined }, + { name: "project", kind: "scope", required: false, default: undefined }, + { name: "subpath", kind: "scope", required: false, default: undefined }, + { name: "cursor", kind: "cursor", required: false, default: undefined }, + { name: "limit", kind: "filter", required: false, default: 50 }, + { name: "order", kind: "filter", required: false, default: "desc" }, + { name: "search", kind: "filter", required: false, default: undefined }, +] as const satisfies readonly QueryField[] + +export const defaults = { + limit: queryFields.find((field) => field.name === "limit")!.default as number, + order: queryFields.find((field) => field.name === "order")!.default as "asc" | "desc", +} + +export type Verdict = { readonly ok: true } | { readonly ok: false; readonly reason: string } + +/** Classify a candidate change against the frozen semantics. Additive + * optional fields (with or without a base default) pass; everything that + * changes what an existing query means fails. */ +export function classify(previous: readonly QueryField[], current: readonly QueryField[]): Verdict { + const previousByName = new Map(previous.map((field) => [field.name, field])) + for (const field of current) { + const before = previousByName.get(field.name) + if (!before) { + if (field.required) + return { ok: false, reason: `new field ${field.name} is required — additive fields must be optional with a base default` } + continue + } + if (field.required !== before.required) + return { ok: false, reason: `field ${field.name} changed requiredness ${before.required} → ${field.required}` } + if (field.kind !== before.kind) + return { ok: false, reason: `field ${field.name} changed kind ${before.kind} → ${field.kind}` } + if (field.default !== before.default) + return { ok: false, reason: `field ${field.name} changed default ${JSON.stringify(before.default)} → ${JSON.stringify(field.default)}` } + } + const currentByName = new Map(current.map((field) => [field.name, field])) + for (const field of previous) { + if (!currentByName.has(field.name)) return { ok: false, reason: `field ${field.name} was removed` } + } + return { ok: true } +} diff --git a/packages/protocol/test/session-list-semantics.test.ts b/packages/protocol/test/session-list-semantics.test.ts new file mode 100644 index 000000000..3caa90fc3 --- /dev/null +++ b/packages/protocol/test/session-list-semantics.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test" +import { Schema } from "effect" +import { SessionListSemantics, SessionsQuery } from "../src/groups/session-list-semantics" + +const decode = Schema.decodeUnknownSync(SessionsQuery) + +const liveFields = () => { + const probe: Record = {} + try { + decode(probe) + } catch { + throw new Error("empty query should decode — session.list has no required fields") + } + return Object.keys(SessionsQuery.fields).sort() +} + +describe("SessionListSemantics (H5: the drift gate)", () => { + it("the live session.list query schema matches the frozen semantics manifest", () => { + const fields = liveFields() + expect(fields).toEqual(SessionListSemantics.queryFields.map((field) => field.name).sort()) + for (const field of SessionListSemantics.queryFields) { + expect((SessionsQuery.fields as Record)[field.name]).toBeDefined() + } + // Optionality is part of the frozen meaning: the manifest records every + // field as optional, and the live schema agrees — an empty query decodes. + expect(SessionListSemantics.queryFields.every((field) => !field.required)).toBe(true) + }) + + it("an additive optional field with a base default passes the gate", () => { + const next: readonly SessionListSemantics.QueryField[] = [ + ...SessionListSemantics.queryFields, + { name: "fleet_view", kind: "filter", required: false, default: undefined }, + ] + const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next) + expect(verdict).toEqual({ ok: true }) + }) + + it("removing a query field without a companion update fails the gate", () => { + const next = SessionListSemantics.queryFields.filter((field) => field.name !== "directory") + const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next) + expect(verdict.ok).toBe(false) + }) + + it("flipping a field from optional to required fails the gate", () => { + const next = SessionListSemantics.queryFields.map((field) => + field.name === "workspace" ? { ...field, required: true } : field, + ) + const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next) + expect(verdict.ok).toBe(false) + }) + + it("changing a default fails the gate", () => { + const next = SessionListSemantics.queryFields.map((field) => + field.name === "limit" ? { ...field, default: 100 } : field, + ) + const verdict = SessionListSemantics.classify(SessionListSemantics.queryFields, next) + expect(verdict.ok).toBe(false) + }) +}) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 36afe8827..450aa6bc9 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -5,6 +5,7 @@ import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" +import { SessionListSemantics } from "@opencode-ai/protocol/groups/session-list-semantics" import { ConflictError, InvalidCursorError, @@ -15,7 +16,9 @@ import { } from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" -const DefaultSessionsLimit = 50 +// D6: the default rides the frozen session-list semantics manifest — changing +// it means changing the manifest in the same PR, or the drift gate goes red. +const DefaultSessionsLimit = SessionListSemantics.defaults.limit const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => From d4e3d64f82e9cf0cc6a9f0ce7a789a130a151f81 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:02:44 -0400 Subject: [PATCH 06/10] feat(app): honest session-list states + in-product panel-state reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2 (spec spec-20260905-045114-session-device-lifecycle): the UI now distinguishes 'not yet fetched' from 'genuinely empty' — a session-list fetch that has not resolved renders Loading, never the empty state, closing #293's invisible failure shape. Child stores carry a sessions_fetched flag set when a list fetch resolves (either path in loadSessions); the sessions dropdown routes through sessionListState. 'Reset panel state' (command.panel.reset, all 18 locales) clears session caches only — in-memory session fields, sessionTotal/status, and the loadSessions/activeSessions query keys — and never touches persisted workspace preferences or user drafts. The reset's touch-list is pinned by a unit test so recovery can never silently grow into configuration. --- .../src/components/session/session-header.tsx | 31 +++++++++++- .../src/context/global-sync/child-store.ts | 1 + packages/app/src/context/global-sync/types.ts | 4 ++ packages/app/src/context/server-sync.tsx | 38 ++++++++++++-- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/uk.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + packages/app/src/pages/layout.tsx | 10 ++++ .../app/src/utils/session-list-state.test.ts | 50 +++++++++++++++++++ packages/app/src/utils/session-list-state.ts | 42 ++++++++++++++++ 25 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/utils/session-list-state.test.ts create mode 100644 packages/app/src/utils/session-list-state.ts diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index a57c3042f..5a5414c47 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -39,6 +39,7 @@ import { useServerSync } from "@/context/server-sync" import { useGlobal } from "@/context/global" import { base64Encode } from "@opencode-ai/core/util/encode" import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers" +import { sessionListState } from "@/utils/session-list-state" import { useNavigate } from "@solidjs/router" import type { Session } from "@opencode-ai/sdk/v2/client" @@ -759,6 +760,30 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } }) + // D2 honest states: "not yet fetched" (a completed list request is the only + // authority for "genuinely empty") vs "empty" vs ready — never render the + // empty state while no fetch has resolved (#293's invisible failure). + const activeListState = createMemo(() => { + if (!open()) return "ready" as const + try { + const conn = server.current + if (!conn) return "unfetched" as const + const ctx = globalCtx.ensureServerCtx(conn) + if (!ctx) return "unfetched" as const + const directories = sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? []) + let fetched = false + let count = 0 + for (const dir of directories) { + const [store] = ctx.sync.child(dir, { bootstrap: false }) + if (store.sessions_fetched) fetched = true + count += store.session?.length ?? 0 + } + return sessionListState({ fetched, count, searching: !!searchQuery() }) + } catch { + return "unfetched" as const + } + }) + // Sort: open-tab sessions first const sortedActiveSessions = createMemo(() => { if (!open()) return [] @@ -1071,7 +1096,11 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) when={filteredActiveSessions().length > 0} fallback={
- {searchQuery() ? language.t("home.sessions.search.noResults", { query: search() }) : language.t("home.sessions.empty")} + {searchQuery() + ? language.t("home.sessions.search.noResults", { query: search() }) + : activeListState() === "unfetched" + ? language.t("common.loading") + : language.t("home.sessions.empty")}
} > diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 9ebd27d98..d002dacf4 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -228,6 +228,7 @@ export function createChildStoreManager(input: { }, session: [], sessionTotal: 0, + sessions_fetched: false, session_status: {}, session_working(id: string) { const type = this.session_status[id]?.type diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index af9824c58..30879111e 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -44,6 +44,10 @@ export type State = { path: Path session: Session[] sessionTotal: number + // D2 honest states: has a session-list fetch completed for this directory? + // A persisted snapshot is a render accelerator, never an authority — until a + // fetch resolves, the UI renders "not yet fetched", never "empty". + sessions_fetched?: boolean session_status: { [sessionID: string]: SessionStatus } diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b9140d78a..0aa19615d 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -404,9 +404,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { limit: retainedLimit, permission: session.data.permission, }) - if (next.length !== store.session.length) { - setStore("session", reconcile(next, { key: "id" })) - } + batch(() => { + // D2 honest states: a completed list fetch means the UI may render + // "genuinely empty" — never "not yet fetched". + if (!store.sessions_fetched) setStore("sessions_fetched", true) + if (next.length !== store.session.length) { + setStore("session", reconcile(next, { key: "id" })) + } + }) children.unpin(key) return } @@ -447,6 +452,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { limited: x.limited, }), ) + // D2 honest states: the fetch resolved (even to empty) — the + // UI may now distinguish "genuinely empty" from "not yet + // fetched". + setStore("sessions_fetched", true) setStore("session", reconcile(next, { key: "id" })) }) sessionMeta.set(key, { limit: retained }) @@ -659,6 +668,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { icon(directory: string, value: string | undefined) { children.projectIcon(directory, value) }, + // D2 in-product reset: clears session caches ONLY — in-memory session + // state plus the session-list query keys. Persisted workspace preferences + // (settings, archive cutoff, posture config) live in stores this never + // touches, so recovery never destroys configuration and never requires + // filesystem surgery (#293). + resetSessionCaches() { + sessionMeta.clear() + for (const child of Object.values(children.children)) { + const [store, setStore] = child + batch(() => { + if (store.sessions_fetched) setStore("sessions_fetched", false) + if (store.sessionTotal !== 0) setStore("sessionTotal", 0) + setStore("session", reconcile([], { key: "id" })) + setStore("session_status", reconcile({})) + }) + } + queryClient.removeQueries({ + predicate: (query) => { + const name = (query.queryKey as readonly unknown[])[2] + return name === "loadSessions" || name === "activeSessions" + }, + }) + }, } const updateConfigMutation = useMutation(() => ({ diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index f5b871d14..70957473c 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "داكن", "command.sidebar.toggle": "تبديل الشريط الجانبي", "command.project.open": "فتح مشروع", + "command.panel.reset": "إعادة تعيين حالة اللوحة", "command.project.previous": "المشروع السابق", "command.project.next": "المشروع التالي", "command.project.index": "التبديل إلى المشروع {{index}}", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 8e553fbcd..dcd1ef6b5 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "Escuro", "command.sidebar.toggle": "Alternar barra lateral", "command.project.open": "Abrir projeto", + "command.panel.reset": "Redefinir estado do painel", "command.project.previous": "Projeto anterior", "command.project.next": "Próximo projeto", "command.project.index": "Alternar para o projeto {{index}}", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index b1da656f3..a3255479f 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Prikaži/sakrij bočnu traku", "command.project.open": "Otvori projekat", + "command.panel.reset": "Resetuj stanje panela", "command.project.previous": "Prethodni projekat", "command.project.next": "Sljedeći projekat", "command.project.index": "Prebaci na projekat {{index}}", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 6c8eccd87..4dee8cf35 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Skift sidebjælke", "command.project.open": "Åbn projekt", + "command.panel.reset": "Nulstil paneltilstand", "command.project.previous": "Forrige projekt", "command.project.next": "Næste projekt", "command.project.index": "Skift til projekt {{index}}", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 0fc55e759..a783f66c9 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -25,6 +25,7 @@ export const dict = { "theme.scheme.dark": "Dunkel", "command.sidebar.toggle": "Seitenleiste umschalten", "command.project.open": "Projekt öffnen", + "command.panel.reset": "Panel-Zustand zurücksetzen", "command.project.previous": "Vorheriges Projekt", "command.project.next": "Nächstes Projekt", "command.project.index": "Zu Projekt {{index}} wechseln", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 32dcd9b0e..93cf87400 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Toggle sidebar", "command.project.open": "Open project", + "command.panel.reset": "Reset panel state", "command.project.previous": "Previous project", "command.project.next": "Next project", "command.project.index": "Switch to project {{index}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 72f3152ad..1c0d382e5 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Alternar barra lateral", "command.project.open": "Abrir proyecto", + "command.panel.reset": "Restablecer estado del panel", "command.project.previous": "Proyecto anterior", "command.project.next": "Siguiente proyecto", "command.project.index": "Cambiar al proyecto {{index}}", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index ee47be369..438ad470b 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "Sombre", "command.sidebar.toggle": "Basculer la barre latérale", "command.project.open": "Ouvrir un projet", + "command.panel.reset": "Réinitialiser l'état du panneau", "command.project.previous": "Projet précédent", "command.project.next": "Projet suivant", "command.project.index": "Passer au projet {{index}}", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 5789239ec..c0b167aed 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "ダーク", "command.sidebar.toggle": "サイドバーの切り替え", "command.project.open": "プロジェクトを開く", + "command.panel.reset": "パネル状態をリセット", "command.project.previous": "前のプロジェクト", "command.project.next": "次のプロジェクト", "command.project.index": "プロジェクト{{index}}に切り替え", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 3c8710575..69c8e150e 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "다크", "command.sidebar.toggle": "사이드바 토글", "command.project.open": "프로젝트 열기", + "command.panel.reset": "패널 상태 초기화", "command.provider.connect": "공급자 연결", "command.server.switch": "서버 전환", "command.settings.open": "설정 열기", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 21fa44389..8fd730ac5 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -26,6 +26,7 @@ export const dict = { "command.sidebar.toggle": "Veksle sidepanel", "command.project.open": "Åpne prosjekt", + "command.panel.reset": "Tilbakestill paneltilstand", "command.provider.connect": "Koble til leverandør", "command.server.switch": "Bytt server", "command.settings.open": "Åpne innstillinger", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 8bee1740b..62009b984 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "Ciemny", "command.sidebar.toggle": "Przełącz pasek boczny", "command.project.open": "Otwórz projekt", + "command.panel.reset": "Zresetuj stan panelu", "command.project.previous": "Poprzedni projekt", "command.project.next": "Następny projekt", "command.project.index": "Przełącz na projekt {{index}}", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 19de4d5d6..3ef745e68 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Переключить боковую панель", "command.project.open": "Открыть проект", + "command.panel.reset": "Сбросить состояние панели", "command.project.previous": "Предыдущий проект", "command.project.next": "Следующий проект", "command.project.index": "Переключиться на проект {{index}}", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 07d1ba248..682da6528 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "สลับแถบข้าง", "command.project.open": "เปิดโปรเจกต์", + "command.panel.reset": "รีเซ็ตสถานะแผง", "command.project.previous": "โปรเจกต์ก่อนหน้า", "command.project.next": "โปรเจกต์ถัดไป", "command.project.index": "สลับไปยังโปรเจกต์ {{index}}", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 09ab55fb9..379c4d614 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -27,6 +27,7 @@ export const dict = { "command.sidebar.toggle": "Kenar çubuğunu aç/kapat", "command.project.open": "Proje aç", + "command.panel.reset": "Panel durumunu sıfırla", "command.project.previous": "Önceki proje", "command.project.next": "Sonraki proje", "command.project.index": "{{index}} numaralı projeye geç", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 43aa325a3..f1953ab83 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Перемкнути бічну панель", "command.project.open": "Відкрити проєкт", + "command.panel.reset": "Скинути стан панелі", "command.project.previous": "Попередній проєкт", "command.project.next": "Наступний проєкт", "command.project.index": "Перемкнути на проєкт {{index}}", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index adfcff20e..d98871fdd 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -28,6 +28,7 @@ export const dict = { "command.sidebar.toggle": "切换侧边栏", "command.project.open": "打开项目", + "command.panel.reset": "重置面板状态", "command.project.previous": "上一个项目", "command.project.next": "下一个项目", "command.project.index": "切换到项目 {{index}}", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 83bc71249..841c5c93e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -27,6 +27,7 @@ export const dict = { "command.sidebar.toggle": "切換側邊欄", "command.project.open": "開啟專案", + "command.panel.reset": "重置面板狀態", "command.project.previous": "上一個專案", "command.project.next": "下一個專案", "command.project.index": "切換至專案 {{index}}", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e10956ec0..07bf9b2eb 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -871,6 +871,16 @@ export default function LegacyLayout(props: ParentProps) { command.register("layout", () => { const commands: CommandOption[] = [ + { + // D2: in-product recovery — clears session caches ONLY; workspace + // preferences survive. Never requires filesystem surgery (#293). + id: "panel.reset", + title: language.t("command.panel.reset"), + category: language.t("command.category.view"), + onSelect: async () => { + serverSync().project.resetSessionCaches() + }, + }, { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), diff --git a/packages/app/src/utils/session-list-state.test.ts b/packages/app/src/utils/session-list-state.test.ts new file mode 100644 index 000000000..df2494505 --- /dev/null +++ b/packages/app/src/utils/session-list-state.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { classifyResetTarget, panelResetTouches, sessionListState } from "./session-list-state" + +describe("sessionListState (D2: honest states)", () => { + test("renders 'not yet fetched' while the list fetch is in flight", () => { + expect(sessionListState({ fetched: false, count: 0 })).toBe("unfetched") + }) + + test("renders 'genuinely empty' only once a fetch has completed", () => { + expect(sessionListState({ fetched: true, count: 0 })).toBe("empty") + }) + + test("a populated list is ready regardless of fetch bookkeeping", () => { + expect(sessionListState({ fetched: false, count: 3 })).toBe("ready") + expect(sessionListState({ fetched: true, count: 3 })).toBe("ready") + }) + + test("a search never shows the unfetched state (results are filtered, not loading)", () => { + expect(sessionListState({ fetched: false, count: 0, searching: true })).toBe("empty") + }) +}) + +describe("panel reset scope (D2: clears session caches only)", () => { + test("session-scoped cache keys are cleared", () => { + expect(classifyResetTarget("session:ses_1:layout")).toBe("session-cache") + expect(classifyResetTarget("session:ses_1:comments")).toBe("session-cache") + }) + + test("workspace preferences survive the reset", () => { + expect(classifyResetTarget("workspace:settings")).toBe("workspace-pref") + expect(classifyResetTarget("workspace:vcs")).toBe("workspace-pref") + expect(classifyResetTarget("workspace:project")).toBe("workspace-pref") + expect(classifyResetTarget("settings.v3")).toBe("workspace-pref") + }) + + test("user drafts are never destroyed by a reset", () => { + expect(classifyResetTarget("session:ses_1:prompt", { draft: true })).toBe("workspace-pref") + }) + + test("the reset's touch-list holds only session caches, never workspace preferences", () => { + const touches = panelResetTouches() + expect(touches).toContain("session") + for (const pref of ["workspace:settings", "workspace:vcs", "workspace:project", "settings.v3", "workspace:archive-cutoff"]) { + expect(touches).not.toContain(pref) + } + for (const touch of touches) { + expect(classifyResetTarget(`session:x:${touch}`)).toBe("session-cache") + } + }) +}) diff --git a/packages/app/src/utils/session-list-state.ts b/packages/app/src/utils/session-list-state.ts new file mode 100644 index 000000000..ead245481 --- /dev/null +++ b/packages/app/src/utils/session-list-state.ts @@ -0,0 +1,42 @@ +// D2 (spec spec-20260905-045114-session-device-lifecycle): honest client +// states and the in-product panel-state reset scope. Pure and vscode/solid- +// free so it unit-tests headless (the consumers wire it to the sync stores). + +export type SessionListState = "unfetched" | "empty" | "ready" + +/** Distinguish "not yet fetched" (a persisted snapshot may exist but no list + * request has completed — incident #293's invisible failure) from "genuinely + * empty" (a fetch completed and the projection is empty). */ +export function sessionListState(input: { fetched: boolean; count: number; searching?: boolean }): SessionListState { + if (input.count > 0) return "ready" + if (!input.fetched && !input.searching) return "unfetched" + return "empty" +} + +export type ResetClass = "session-cache" | "workspace-pref" + +/** Classify a persisted-store key for the "Reset panel state" command: the + * reset clears session caches ONLY — workspace preferences (settings, + * archive cutoff, posture config) and user drafts (prompt content) are + * never destroyed by a recovery action. */ +export function classifyResetTarget(key: string, target?: { draft?: boolean }): ResetClass { + if (target?.draft) return "workspace-pref" + if (key.startsWith("session:")) return "session-cache" + return "workspace-pref" +} + +/** The canonical list of session caches the "Reset panel state" command + * clears: child-store session fields plus the session-list query keys. + * Persisted workspace preferences are not on this list — recovery never + * destroys configuration. */ +export function panelResetTouches(): readonly string[] { + return [ + "session", + "session_status", + "sessionTotal", + "session_diff", + "diff_version", + "loadSessions", + "activeSessions", + ] +} From 95a065f7ecabd74c70269c6a3c5dd78b3720e7c9 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:13:09 -0400 Subject: [PATCH 07/10] feat(app): boot currency-mismatch invalidation for the persisted session snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2 (spec spec-20260905-045114-session-device-lifecycle): the client now persists a session-list snapshot per workspace (session:snapshot, render accelerator — never authority) alongside the server's derived currency token. Every list response verifies the snapshot via bootCurrencyDecision: a stale or tokenless snapshot — the #293 stale-storage shape — is proven stale and invalidated by the overwrite, so the client self-heals on boot with zero manual action. Hydration is gated on sessions_fetched: a real fetch always outranks the snapshot. The panel reset invalidates all snapshots; the reset touch-list pins session:snapshot as a session cache. The vendored client's generated types predate the additive currency field — read structurally until the vendored snapshot is refreshed. --- .../src/context/global-sync/child-store.ts | 43 +++++++++++ .../src/context/global-sync/session-load.ts | 34 +++++++-- .../global-sync/session-snapshot.test.ts | 72 +++++++++++++++++++ .../context/global-sync/session-snapshot.ts | 45 ++++++++++++ packages/app/src/context/global-sync/types.ts | 7 ++ packages/app/src/context/server-sync.tsx | 13 ++++ packages/app/src/utils/session-list-state.ts | 1 + 7 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/context/global-sync/session-snapshot.test.ts create mode 100644 packages/app/src/context/global-sync/session-snapshot.ts diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index d002dacf4..ff01da54f 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -10,9 +10,11 @@ import { type IconCache, type MetaCache, type ProjectMeta, + type SessionSnapshotCache, type State, type VcsCache, } from "./types" +import type { SessionSnapshot } from "./session-snapshot" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQuery } from "@tanstack/solid-query" import { QueryOptionsApi } from "../server-sync" @@ -39,6 +41,7 @@ export function createChildStoreManager(input: { const vcsCache = new Map() const metaCache = new Map() const iconCache = new Map() + const snapshotCache = new Map() const lifecycle = new Map() const pins = new Map() const ownerPins = new WeakMap>() @@ -117,6 +120,7 @@ export function createChildStoreManager(input: { vcsCache.delete(key) metaCache.delete(key) iconCache.delete(key) + snapshotCache.delete(key) lifecycle.delete(key) mcpDirectories.delete(key) mcpToggles.delete(key) @@ -181,6 +185,19 @@ export function createChildStoreManager(input: { if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) + // D2: the persisted session snapshot is a render accelerator, never an + // authority — hydrated only until the first real list fetch resolves, + // and verified against the server's currency token on every boot. + const sessionSnapshot = runWithOwner(input.owner, () => + input.persist(Persist.serverWorkspace(input.scope, directory, "session:snapshot"), createStore({ value: undefined as SessionSnapshot | undefined })), + ) + if (!sessionSnapshot) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) + snapshotCache.set(key, { + store: sessionSnapshot[0], + setStore: sessionSnapshot[1], + ready: sessionSnapshot[3], + }) + const init = () => createRoot((dispose) => { const initialMeta = meta[0].value @@ -289,6 +306,16 @@ export function createChildStoreManager(input: { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) }) + + // D2: hydrate the persisted snapshot as a render accelerator — only + // until a real list fetch resolves (sessions_fetched flips true), + // never over a store a fetch already filled. + onPersistedInit(sessionSnapshot[2], () => { + if (child[0].sessions_fetched) return + const cached = sessionSnapshot[0].value + if (!cached || child[0].session.length > 0) return + child[1]("session", cached.sessions) + }) }) runWithOwner(input.owner, init) @@ -394,5 +421,21 @@ export function createChildStoreManager(input: { vcsCache, metaCache, iconCache, + // D2: the persisted snapshot's read/write seam — server-sync verifies the + // token on every list response and overwrites the snapshot; the panel + // reset invalidates all of them. + sessionSnapshot(directory: string) { + return snapshotCache.get(directoryKey(directory))?.store.value + }, + writeSessionSnapshot(directory: string, next: SessionSnapshot) { + const cache = snapshotCache.get(directoryKey(directory)) + if (!cache) return + cache.setStore("value", next) + }, + resetSessionSnapshots() { + for (const cache of snapshotCache.values()) { + cache.setStore("value", undefined) + } + }, } } diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts index 46d8ec6a0..cf92bedf9 100644 --- a/packages/app/src/context/global-sync/session-load.ts +++ b/packages/app/src/context/global-sync/session-load.ts @@ -1,28 +1,52 @@ import type { SessionApi } from "@opencode-ai/client/promise" import { normalizeSessionInfo } from "@/utils/session" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@opencode-ai/sdk/v2/client" -export async function loadRootSessions(input: { api: Pick; directory: string; limit: number }) { +/** One page of the boot session-list fetch. `currency` carries the server's + * derived currency token (D2) — undefined for v1 hubs, which predate it. */ +export type RootSessionsPage = { + readonly data: Session[] + readonly limit: number + readonly limited: boolean + readonly currency: string | undefined +} + +export async function loadRootSessions(input: { + api: Pick + directory: string + limit: number +}): Promise { const result = await input.api.list({ directory: input.directory, parentID: null, limit: input.limit, order: "desc", }) + // D2: the derived currency token rides the response so the client can + // verify its persisted snapshot against the server on boot. The vendored + // client's generated types predate the additive field — read it + // structurally until the vendored snapshot is refreshed. + const currency = (result as { currency?: string | null }).currency ?? undefined return { data: result.data.map(normalizeSessionInfo), limit: input.limit, limited: true, - } as const + currency, + } } -export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) { +export async function loadRootSessionsV1(input: { + client: OpencodeClient + directory: string + limit: number +}): Promise { try { const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit }) - return { data: result.data, limit: input.limit, limited: true } as const + return { data: result.data ?? [], limit: input.limit, limited: true, currency: undefined } } catch { const result = await input.client.session.list({ directory: input.directory, roots: true }) - return { data: result.data, limit: input.limit, limited: false } as const + return { data: result.data ?? [], limit: input.limit, limited: false, currency: undefined } } } diff --git a/packages/app/src/context/global-sync/session-snapshot.test.ts b/packages/app/src/context/global-sync/session-snapshot.test.ts new file mode 100644 index 000000000..cf8b8ad7a --- /dev/null +++ b/packages/app/src/context/global-sync/session-snapshot.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "./session-snapshot" + +const session = (id: string) => ({ + id, + directory: "/home", + projectID: "p1", + slug: id, + version: "test", + title: `Session ${id}`, + time: { created: 1, updated: 1 }, +}) + +describe("bootCurrencyDecision (D2: the #293 stale-storage shape self-heals on boot)", () => { + test("a snapshot with a stale token is proven stale and the fetched rows are adopted", () => { + const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.3.100.100.build-a" } + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: [session("fresh")], currency: "v1.4.200.200.build-a" }, + }) + + expect(decision.adopt).toBe(true) + expect(decision.stale).toBe(true) + expect(decision.currency).toBe("v1.4.200.200.build-a") + }) + + test("a snapshot written without a token (the #293 shape) cannot be trusted", () => { + const snapshot: SessionSnapshot = { sessions: [], currency: undefined } + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: [session("fresh")], currency: "v1.4.200.200.build-a" }, + }) + + expect(decision.stale).toBe(true) + expect(decision.adopt).toBe(true) + }) + + test("a matching token means the snapshot was fresh", () => { + const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.4.200.200.build-a" } + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: [session("old")], currency: "v1.4.200.200.build-a" }, + }) + + expect(decision.stale).toBe(false) + expect(decision.currency).toBe("v1.4.200.200.build-a") + }) + + test("a hub without a currency token (additive base default) cannot be verified", () => { + const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.4.200.200.build-a" } + const decision = bootCurrencyDecision({ snapshot, response: { sessions: [session("fresh")] } }) + + expect(decision.stale).toBe(false) + expect(decision.currency).toBeUndefined() + }) + + test("a fresh client with no snapshot adopts the fetched rows without a verdict", () => { + const decision = bootCurrencyDecision({ + response: { sessions: [session("fresh")], currency: "v1.4.200.200.build-a" }, + }) + + expect(decision.adopt).toBe(true) + expect(decision.stale).toBe(false) + expect(decision.currency).toBe("v1.4.200.200.build-a") + }) + + test("toSnapshot shapes what gets persisted: rows plus the token", () => { + const snapshot = toSnapshot([session("a"), session("b")], "v1.4.200.200.build-a") + expect(snapshot.sessions).toHaveLength(2) + expect(snapshot.currency).toBe("v1.4.200.200.build-a") + }) +}) diff --git a/packages/app/src/context/global-sync/session-snapshot.ts b/packages/app/src/context/global-sync/session-snapshot.ts new file mode 100644 index 000000000..139c7eecb --- /dev/null +++ b/packages/app/src/context/global-sync/session-snapshot.ts @@ -0,0 +1,45 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" + +/** + * D2 (spec spec-20260905-045114-session-device-lifecycle): the persisted + * session snapshot is a render accelerator, never an authority. Every boot + * compares the snapshot's derived currency token against the server's; a + * stale or absent token marks the snapshot for invalidation, so the #293 + * stale-storage shape self-heals with zero manual action. + */ + +export type SessionSnapshot = { + sessions: Session[] + currency?: string +} + +export type BootCurrencyDecision = { + /** The fetched response is always adopted — it is the authority. */ + adopt: boolean + /** The persisted snapshot contradicts the server (stale or tokenless) and + * must be invalidated (overwritten by the fetched state). */ + stale: boolean + /** The token to persist alongside the adopted rows; undefined when the hub + * does not yet supply one (additive base default — cannot be verified). */ + currency: string | undefined +} + +export function bootCurrencyDecision(input: { + snapshot?: SessionSnapshot + response: { sessions: Session[]; currency?: string | null } +}): BootCurrencyDecision { + const currency = input.response.currency ?? undefined + // A mismatch requires the server to have asserted a token: a hub without + // one (additive base default absent) cannot be verified either way. A + // tokenless snapshot was written by a hub that could not prove its own + // currency — the founding #293 shape — and reads as stale on first proof. + const stale = + currency !== undefined && + input.snapshot !== undefined && + (input.snapshot.currency === undefined || input.snapshot.currency !== currency) + return { adopt: true, stale, currency } +} + +export function toSnapshot(sessions: Session[], currency: string | undefined): SessionSnapshot { + return { sessions, currency } +} diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 30879111e..c669d2218 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -16,6 +16,7 @@ import type { import type { FileDiffInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { SessionSnapshot } from "./session-snapshot" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -98,6 +99,12 @@ export type VcsCache = { ready: Accessor } +export type SessionSnapshotCache = { + store: Store<{ value: SessionSnapshot | undefined }> + setStore: SetStoreFunction<{ value: SessionSnapshot | undefined }> + ready: Accessor +} + export type MetaCache = { store: Store<{ value: ProjectMeta | undefined }> setStore: SetStoreFunction<{ value: ProjectMeta | undefined }> diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 0aa19615d..6a49ad1fc 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -28,6 +28,7 @@ import { import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" +import { bootCurrencyDecision, toSnapshot } from "./global-sync/session-snapshot" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" @@ -459,6 +460,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { setStore("session", reconcile(next, { key: "id" })) }) sessionMeta.set(key, { limit: retained }) + // D2: verify the persisted snapshot against the server's + // derived currency token — a stale or tokenless snapshot is + // invalidated by this overwrite, so the #293 shape self-heals + // on boot with zero manual action. + const decision = bootCurrencyDecision({ + snapshot: children.sessionSnapshot(directory), + response: { sessions: next, currency: x.currency }, + }) + children.writeSessionSnapshot(directory, toSnapshot(next, decision.currency)) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -675,6 +685,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { // filesystem surgery (#293). resetSessionCaches() { sessionMeta.clear() + // D2: the persisted snapshots are session caches — the reset invalidates + // every one of them (they re-prime from the next fetch). + children.resetSessionSnapshots() for (const child of Object.values(children.children)) { const [store, setStore] = child batch(() => { diff --git a/packages/app/src/utils/session-list-state.ts b/packages/app/src/utils/session-list-state.ts index ead245481..64c897062 100644 --- a/packages/app/src/utils/session-list-state.ts +++ b/packages/app/src/utils/session-list-state.ts @@ -36,6 +36,7 @@ export function panelResetTouches(): readonly string[] { "sessionTotal", "session_diff", "diff_version", + "session:snapshot", "loadSessions", "activeSessions", ] From 5c7f83c2b0dde2d1e5fad8d9338b1af91e810422 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:17:53 -0400 Subject: [PATCH 08/10] feat(app): honest session-list states on the home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2: the home sessions view routes through the same sessionListState machine as the dropdown — while the home session-index query has never succeeded, the view renders the loading skeleton instead of the empty state; only a resolved fetch (even an empty one) may render 'genuinely empty'. The controller exposes listState (isSuccess as the fetched source); the glue and view thread it through. Home contract pinned in session-list-state.test.ts. --- .../app/src/pages/home/home-sessions-controller.tsx | 10 ++++++++++ packages/app/src/pages/home/home-sessions-view.tsx | 13 ++++++++++++- packages/app/src/pages/home/home-sessions.tsx | 1 + packages/app/src/utils/session-list-state.test.ts | 8 ++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index a12e9800e..7c7a60c50 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -17,6 +17,7 @@ import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { sessionHasOpenTab, useTabs } from "@/context/tabs" import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" +import { sessionListState } from "@/utils/session-list-state" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" @@ -173,6 +174,15 @@ export function createHomeSessionsController(home: HomeController) { records, groups, loading: () => sessionLoad.isLoading, + // D2 honest states: the home list distinguishes "not yet fetched" (no + // index fetch has ever resolved) from "genuinely empty" (it has, and + // the projection is empty) — never render the empty state during the + // boot fetch (#293's invisible failure shape). + listState: () => + sessionListState({ + fetched: sessionLoad.isSuccess, + count: records().length, + }), searchRecords: allRecords, }, session: { diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index 438267c1b..1c7af954f 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -10,6 +10,7 @@ import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar" import { sessionTitle } from "@/utils/session-title" +import { type SessionListState } from "@/utils/session-list-state" import { shouldOpenSessionInBackground } from "../home-session-open" import { HomeSessionStatusController, @@ -39,6 +40,7 @@ function isBackgroundOpen(event: MouseEvent) { export type HomeSessionsViewProps = { language: ReturnType groups: Accessor + listState: Accessor showProjectName: Accessor server: Accessor canCreateSession: Accessor @@ -113,7 +115,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { } > 0} + when={props.groups().length > 0 || props.listState() === "unfetched"} fallback={ } > + + + + } + >
{(group, index) => ( @@ -140,6 +150,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { )}
+
diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx index 7bda4dd36..59fcc6b74 100644 --- a/packages/app/src/pages/home/home-sessions.tsx +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -12,6 +12,7 @@ export function HomeSessions(props: { { test("a search never shows the unfetched state (results are filtered, not loading)", () => { expect(sessionListState({ fetched: false, count: 0, searching: true })).toBe("empty") }) + + test("home contract: the index query's isSuccess is the fetched source", () => { + // Boot: the home session-index query is pending and has never succeeded. + expect(sessionListState({ fetched: false, count: 0 })).toBe("unfetched") + // After the first successful fetch (even a refetch with data present), + // an empty projection is genuinely empty. + expect(sessionListState({ fetched: true, count: 0 })).toBe("empty") + }) }) describe("panel reset scope (D2: clears session caches only)", () => { From 10b7e96e0f94ca0e6a4530803246f3739c136cf2 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:24:42 -0400 Subject: [PATCH 09/10] fix(app): home boot fetch paginates by cursor, not page fullness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2/D4 (spec spec-20260905-045114-session-device-lifecycle): the home session-index boot fetch stopped paginating whenever a page came back shorter than the requested limit — so a hub that caps page size below the request silently dropped every session past its first short page. The cursor is the only exhaustion signal. Bounded pagination tests seed a 1250-session store: desc order (recent tail first), three continuation pages, single-page stores cost one request, abort signal honored between pages. --- .../global-sync/home-session-index.test.ts | 72 +++++++++++++++++++ .../context/global-sync/home-session-index.ts | 7 +- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts index 4e40cc78e..3842a1657 100644 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -152,3 +152,75 @@ describe("Home V2 session index", () => { expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true) }) }) + +describe("Home boot fetch pagination (D2/D4: recent tail first, continues paging)", () => { + function seed(count: number): SessionV2Info[] { + // time_created ascending with index; the store's newest session is last. + return Array.from({ length: count }, (_, index) => + session({ id: `ses_${String(index).padStart(6, "0")}`, updated: 1_000 + index }), + ) as SessionV2Info[] + } + + test("pages a 1250-session store desc-first via continuation cursors until exhausted", async () => { + const store = seed(1250) + const pages: { order?: string; cursor?: string }[] = [] + const PAGE_SIZE = 500 // a hub that caps page size below the requested limit + const list = async (input: { limit: number; order?: "asc" | "desc"; cursor?: string }) => { + pages.push({ order: input.order, cursor: input.cursor }) + const start = input.cursor ? Number(input.cursor) : 0 + // The store answers desc: the newest (recent tail) first. + const page = store.toReversed().slice(start, start + PAGE_SIZE) + const next = start + PAGE_SIZE < store.length ? String(start + PAGE_SIZE) : undefined + return { data: { data: page, cursor: { next } } } + } + + const result = await loadHomeSessionIndex(list as never) + + // The recent tail arrives first: page one holds the newest sessions. + expect(pages[0]!.order).toBe("desc") + expect(pages[0]!.cursor).toBeUndefined() + // 1250 sessions at 500/page take exactly three pages. + expect(pages).toHaveLength(3) + expect(pages[1]!.cursor).toBeDefined() + expect(pages[2]!.cursor).toBeDefined() + // The full store is assembled, newest session included. + expect(result.sessions).toHaveLength(1250) + const newest = store[store.length - 1]!.id + expect(result.sessions.some((item) => item.id === newest)).toBe(true) + }) + + test("a store smaller than one page needs exactly one request", async () => { + const store = seed(12) + let calls = 0 + const list = async (input: { limit: number }) => { + calls++ + return { data: { data: store.toReversed().slice(0, input.limit), cursor: {} } } + } + + const result = await loadHomeSessionIndex(list as never) + + expect(calls).toBe(1) + expect(result.sessions).toHaveLength(12) + }) + + test("respects an abort signal between pages", async () => { + const store = seed(HOME_V2_SESSION_PAGE_LIMIT * 2) + const controller = new AbortController() + let calls = 0 + const list = async ( + input: { limit: number; order?: "asc" | "desc"; cursor?: string }, + options?: { signal?: AbortSignal }, + ) => { + calls++ + if (calls === 2) controller.abort() + if (options?.signal?.aborted) throw new Error("aborted") + const start = input.cursor ? Number(input.cursor) : 0 + const page = store.toReversed().slice(start, start + HOME_V2_SESSION_PAGE_LIMIT) + const next = start + HOME_V2_SESSION_PAGE_LIMIT < store.length ? String(start + HOME_V2_SESSION_PAGE_LIMIT) : undefined + return { data: { data: page, cursor: { next } } } + } + + await expect(loadHomeSessionIndex(list as never, 0, controller.signal)).rejects.toThrow("aborted") + expect(calls).toBe(2) + }) +}) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 03a085e34..0cab2a0bd 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -45,8 +45,11 @@ export async function loadHomeSessionIndex( ) const page = response.data! data.push(...page.data) - if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) - return { sessions: parseHomeSessionIndex(data), eventSequence } + // The cursor is the only exhaustion signal: a page shorter than the + // requested limit does NOT mean the store ended — a hub that caps page + // size below the request would otherwise silently drop everything past + // its first short page. + if (!page.cursor.next) return { sessions: parseHomeSessionIndex(data), eventSequence } cursor = page.cursor.next } } From 80a57acc8a006d735a3dcc5fe3a227113228fd6e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 02:34:07 -0400 Subject: [PATCH 10/10] =?UTF-8?q?test(app):=20H1=20=E2=80=94=20the=20fresh?= =?UTF-8?q?-client=20boot=20harness=20(headless=20boot=20+=20hub=20request?= =?UTF-8?q?=20log)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2 (spec spec-20260905-045114-session-device-lifecycle): the #288 boot harness extracted as a named, reusable fixture. Headless by design — it composes the real boot pieces (persisted-snapshot hydration, the unconditional boot fetch, bootCurrencyDecision, the sessionListState machine) in the boot order the contract requires, against a seeded hub whose request log is the assertion surface (the same evidence that diagnosed #293). The fetch-before-first-render and boot-self-heal criteria's tests wire through it: a fresh boot's session-list fetch is initiated before the list first renders (timeline assertion + hub log), and a client seeded with a stale or tokenless snapshot — the #293 shape — self-heals with zero manual action (rendered rows are the hub's; the snapshot is re-primed with the server's token). --- .../global-sync/h1-client-boot.test.ts | 77 +++++++++ .../src/context/global-sync/h1-client-boot.ts | 150 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 packages/app/src/context/global-sync/h1-client-boot.test.ts create mode 100644 packages/app/src/context/global-sync/h1-client-boot.ts diff --git a/packages/app/src/context/global-sync/h1-client-boot.test.ts b/packages/app/src/context/global-sync/h1-client-boot.test.ts new file mode 100644 index 000000000..6eb149b53 --- /dev/null +++ b/packages/app/src/context/global-sync/h1-client-boot.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { bootClient, createSeededHub, memorySessionStorage } from "./h1-client-boot" + +const session = (id: string, updated: number) => ({ + id, + directory: "/home", + projectID: "p1", + slug: id, + version: "test", + title: `Session ${id}`, + time: { created: updated, updated }, +}) + +const hubSessions = () => [session("ses_a", 100), session("ses_b", 200)] + +describe("H1 — the fresh-client boot harness (#288's headless boot, hub request log)", () => { + test("a fresh client boots with the session-list fetch initiated before the list first renders", async () => { + const hub = createSeededHub({ sessions: hubSessions(), currency: "v1.4.200.200.build-a" }) + const storage = memorySessionStorage() + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + // The criterion is measured on the hub's request log, the same evidence + // that diagnosed #293: at least one session-list request in the boot + // window. + expect(boot.sessionListRequests.length).toBeGreaterThanOrEqual(1) + expect(boot.fetchedBeforeFirstRender).toBe(true) + // The fetched rows are adopted and the state is honest: a resolved fetch + // over a populated projection renders ready. + expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"]) + expect(boot.state).toBe("ready") + // The snapshot layer now holds the fetched rows plus the token. + const persisted = storage.read("/home") + expect(persisted?.currency).toBe("v1.4.200.200.build-a") + expect(persisted?.sessions).toHaveLength(2) + }) + + test("a client seeded with the #293 stale-storage shape self-heals on boot", async () => { + const hub = createSeededHub({ sessions: hubSessions(), currency: "v1.4.200.200.build-b" }) + const storage = memorySessionStorage({ + "/home": { sessions: [session("ses_stale", 1)], currency: "v1.4.200.200.build-a" }, + }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(true) + // The stale snapshot never wins: the rendered list is the hub's. + expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"]) + expect(boot.state).toBe("ready") + // The persisted snapshot was re-primed from the server's truth. + expect(storage.read("/home")?.currency).toBe("v1.4.200.200.build-b") + }) + + test("a tokenless persisted snapshot (the founding shape) is invalidated too", async () => { + const hub = createSeededHub({ sessions: [], currency: "v1.4.200.200.build-b" }) + const storage = memorySessionStorage({ "/home": { sessions: [session("ses_stale", 1)] } }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(true) + expect(boot.rendered).toHaveLength(0) + // Honest states: a resolved fetch over an empty projection is genuinely + // empty — never "not yet fetched". + expect(boot.state).toBe("empty") + }) + + test("a fresh snapshot is not flagged as self-healed", async () => { + const currency = "v1.4.200.200.build-a" + const hub = createSeededHub({ sessions: hubSessions(), currency }) + const storage = memorySessionStorage({ "/home": { sessions: hubSessions(), currency } }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(false) + expect(boot.rendered).toHaveLength(2) + }) +}) diff --git a/packages/app/src/context/global-sync/h1-client-boot.ts b/packages/app/src/context/global-sync/h1-client-boot.ts new file mode 100644 index 000000000..e8c5f8db7 --- /dev/null +++ b/packages/app/src/context/global-sync/h1-client-boot.ts @@ -0,0 +1,150 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" +import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "./session-snapshot" +import { sessionListState, type SessionListState } from "@/utils/session-list-state" + +/** + * H1 — the fresh-client boot harness (#288's headless boot). + * + * Boots the client's session-list layer against a seeded hub and asserts on + * the hub's request log — the same evidence that diagnosed #293. Headless by + * design (no DOM): it composes the real boot pieces — persisted-snapshot + * hydration, the unconditional boot fetch, bootCurrencyDecision, the honest + * sessionListState machine — in the boot order the D2 contract requires. The + * fetch-before-first-render and boot-self-heal criteria's tests wire through + * this fixture. + */ + +export type HubRequest = { + endpoint: "session.list" + directory?: string + limit?: number + order?: string + cursor?: string + /** Monotonic tick — boot ordering assertions read this. */ + at: number +} + +export type SeededHub = { + /** The v2 session.list surface, shaped like the real client's. */ + api: { list: (input: { directory: string; parentID: null; limit: number; order: "desc"; cursor?: string }) => Promise<{ data: Session[]; cursor: { next?: string }; currency?: string | null }> } + /** Every request the hub saw, in order. */ + log: HubRequest[] + sessions: Session[] + currency: string | undefined +} + +export function createSeededHub(input: { sessions: Session[]; currency?: string; pageSize?: number }): SeededHub { + const log: HubRequest[] = [] + let tick = 0 + const pageSize = input.pageSize ?? input.sessions.length + // desc store: newest first. + const ordered = input.sessions.toReversed() + return { + log, + sessions: input.sessions, + currency: input.currency, + api: { + list: async (request) => { + const start = request.cursor ? Number(request.cursor) : 0 + const page = ordered.slice(start, start + (pageSize || input.sessions.length)) + const next = start + page.length < input.sessions.length && page.length > 0 ? String(start + page.length) : undefined + log.push({ + endpoint: "session.list", + directory: request.directory, + limit: request.limit, + order: request.order, + cursor: request.cursor, + at: tick++, + }) + return { data: page, cursor: { next }, currency: input.currency } + }, + }, + } +} + +export type PersistedSessionStorage = { + read: (key: string) => SessionSnapshot | undefined + write: (key: string, snapshot: SessionSnapshot | undefined) => void +} + +export function memorySessionStorage(seed: Record = {}): PersistedSessionStorage { + const store = new Map(Object.entries(seed)) + return { + read: (key) => structuredClone(store.get(key)), + write: (key, snapshot) => { + if (snapshot === undefined) store.delete(key) + else store.set(key, structuredClone(snapshot)) + }, + } +} + +export type BootResult = { + /** Every session-list request the hub saw during the boot window. */ + sessionListRequests: HubRequest[] + /** The rendered projection after the boot fetch resolved. */ + rendered: Session[] + /** The session-list fetch was initiated before the list first rendered. */ + fetchedBeforeFirstRender: boolean + /** A seeded snapshot was proven stale (the #293 shape) and invalidated. */ + selfHealed: boolean + /** The honest list state after boot. */ + state: SessionListState +} + +/** The persisted snapshot store is per-workspace; the harness keys it by the + * booted directory (the real client namespaces this inside its workspace + * storage as the `session:snapshot` target). */ +const snapshotKey = (directory: string) => directory + +export async function bootClient(input: { + hub: SeededHub + storage: PersistedSessionStorage + directory: string +}): Promise { + const key = snapshotKey(input.directory) + const timeline: string[] = [] + + // Boot order per D2: hydrate the persisted snapshot (accelerator), initiate + // the session-list fetch BEFORE the list first renders, then render. + timeline.push("snapshot-hydrated") + const snapshot = input.storage.read(key) + + // The fetch is initiated unconditionally — a persisted snapshot is never an + // authority, so no boot may skip it (the founding #293 failure). + let fetchFailure: unknown + const pending = input.hub.api + .list({ directory: input.directory, parentID: null, limit: 100, order: "desc" }) + .then((response) => ({ response })) + .catch((error) => { + fetchFailure = error + return undefined + }) + timeline.push("fetch-initiated") + + // First render: the snapshot as accelerator, or the honest unfetched state. + timeline.push("first-render") + const firstRendered = snapshot?.sessions ?? [] + void firstRendered + + const settled = await pending + timeline.push("fetch-resolved") + if (fetchFailure !== undefined) throw fetchFailure + + const response = settled!.response + const decision = bootCurrencyDecision({ snapshot, response: { sessions: response.data, currency: response.currency } }) + // Invalidation is materialized by the overwrite: the fetched rows are the + // authority and the snapshot layer is re-primed from them. + input.storage.write(key, toSnapshot(response.data, decision.currency)) + + return { + sessionListRequests: input.hub.log.filter((request) => request.endpoint === "session.list"), + rendered: response.data, + // The criterion measured on the boot order: the fetch was initiated + // before the list first rendered — and the hub's log carries it. + fetchedBeforeFirstRender: + timeline.indexOf("fetch-initiated") < timeline.indexOf("first-render") && + input.hub.log.some((request) => request.endpoint === "session.list"), + selfHealed: decision.stale, + state: sessionListState({ fetched: true, count: response.data.length }), + } +}