diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 50f2a0a12d1a..7dcc695e605b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2795,6 +2795,14 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true }); yield* createThread({ name: "other", worktreePath: "/repo" }); yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath }); + yield* createThread({ + name: "aliased-child", + parent: "other", + root: "other", + worktreePath: `${worktreePath}/`, + }); + yield* createThread({ name: "cased", worktreePath: worktreePath.toUpperCase() }); + yield* createThread({ name: "windows", worktreePath: "C:\\RÉPO\\wt" }); const family = (threadId: ThreadId) => store @@ -2806,14 +2814,23 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => assert.sameMembers([...(yield* family(id("grandchild")))], tree); assert.deepEqual([...(yield* family(id("missing")))], []); - // Includes another root's idle child on the same path, which a shell may leave out. + // Includes another root's idle child on the same path, which a shell may leave out, under any + // spelling of that path. POSIX paths keep their case. const { statements, tracer } = traceSqlStatements(); const onWorktree = yield* store .getShellSnapshot({ location: "active", worktreePath }) .pipe(Effect.withTracer(tracer)); assert.sameMembers( onWorktree.threads.map((thread) => thread.id), - [id("root"), id("child"), id("other-child")], + [id("root"), id("child"), id("other-child"), id("aliased-child")], + ); + const onWindowsWorktree = yield* store.getShellSnapshot({ + location: "active", + worktreePath: "c:/répo/wt/", + }); + assert.deepEqual( + onWindowsWorktree.threads.map((thread) => thread.id), + [id("windows")], ); // A path names a few threads; scanning every thread parses every payload. const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 431426270f22..c7322e578ffc 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -95,6 +95,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import type * as Statement from "effect/sql/Statement"; import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; import { readableSearchText } from "./messageSearchIndex.ts"; import { @@ -1095,6 +1096,7 @@ type ShellThreadRow = { readonly thread_id: string; readonly payload_json: string; readonly forked_from_run_source_thread_id: string | null; + readonly worktree_path: string | null; readonly latest_run_id: string | null; readonly latest_run_status: string | null; readonly latest_run_requested_at: string | null; @@ -7687,6 +7689,7 @@ export const layer: Layer.Layer = THEN json_extract(t.payload_json, '$.forkedFrom.threadId') ELSE NULL END AS forked_from_run_source_thread_id, + json_extract(t.payload_json, '$.worktreePath') AS worktree_path, presented.run_id AS latest_run_id, presented.status AS latest_run_status, presented.requested_at AS latest_run_requested_at, @@ -7883,8 +7886,20 @@ export const layer: Layer.Layer = }${ worktreePath === undefined ? sql`` - : sql` AND CASE WHEN json_valid(t.payload_json) - THEN json_extract(t.payload_json, '$.worktreePath') END = ${worktreePath}` + : // The key matches orchestration_v2_projection_threads_worktree_path_key_idx, so an + // ASCII path does not scan the table. SQLite lower() folds only ASCII, so a query + // that itself contains another character also admits those stored paths; + // readShellSnapshotRows keeps the exact comparison. + sql` AND (CASE WHEN json_valid(t.payload_json) + THEN lower(rtrim(replace(trim(json_extract(t.payload_json, '$.worktreePath')), '\\', '/'), '/')) + END = lower(rtrim(replace(trim(${worktreePath}), '\\', '/'), '/'))${ + /^[\u0000-\u007e]*$/u.test(worktreePath) + ? sql`` + : sql` OR ( + json_valid(t.payload_json) + AND json_extract(t.payload_json, '$.worktreePath') GLOB '*[^ -~]*' + )` + })` }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -8498,7 +8513,16 @@ export const layer: Layer.Layer = const readShellSnapshotRows = Effect.fn("ProjectionStore.readShellSnapshotRows")(function* ( options: ShellSnapshotOptions | undefined, ) { - const targetThreadRows = yield* selectShellThreadRows(undefined, options); + const worktreeKey = + options?.worktreePath === undefined + ? undefined + : normalizeProjectPathForComparison(options.worktreePath); + const targetThreadRows = (yield* selectShellThreadRows(undefined, options)).filter( + (row) => + worktreeKey === undefined || + (row.worktree_path !== null && + normalizeProjectPathForComparison(row.worktree_path) === worktreeKey), + ); const targetThreadIds = new Set(targetThreadRows.map((row) => ThreadId.make(row.thread_id))); const rowsByThreadId = new Map( targetThreadRows.map((row) => [ThreadId.make(row.thread_id), row] as const), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f1b8f6c9d806..cf1f60cd253f 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -99,6 +99,7 @@ import Migration0088 from "./Migrations/088_TurnItemTypeStatusIndex.ts"; import Migration0089 from "./Migrations/089_ShellThreadReadIndexes.ts"; import Migration0090 from "./Migrations/090_AuthConnectDevices.ts"; import Migration0091 from "./Migrations/091_AuthConnectDeniedRequests.ts"; +import Migration0092 from "./Migrations/092_WorktreePathKeyIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -193,6 +194,7 @@ const migrationEntries = [ [89, "ShellThreadReadIndexes", Migration0089], [90, "AuthConnectDevices", Migration0090], [91, "AuthConnectDeniedRequests", Migration0091], + [92, "WorktreePathKeyIndex", Migration0092], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts b/apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts new file mode 100644 index 000000000000..9b1b42647d60 --- /dev/null +++ b/apps/server/src/persistence/Migrations/092_WorktreePathKeyIndex.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep this expression identical to ProjectionStore's loose worktree filter. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_worktree_path_key_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) + THEN lower(rtrim(replace(trim(json_extract(payload_json, '$.worktreePath')), '\\', '/'), '/')) + END + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 89c06944d2bf..1e19762a7afd 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -71,6 +71,7 @@ describe("V2 preview upgrade", () => { [89, "ShellThreadReadIndexes"], [90, "AuthConnectDevices"], [91, "AuthConnectDeniedRequests"], + [92, "WorktreePathKeyIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -185,6 +186,7 @@ describe("V2 preview upgrade", () => { [89, "ShellThreadReadIndexes"], [90, "AuthConnectDevices"], [91, "AuthConnectDeniedRequests"], + [92, "WorktreePathKeyIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), );