diff --git a/README.md b/README.md index 1e9b0517945..c2349e72860 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) +- [Customize a project icon](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14..c4297f24b09 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,14 +1,21 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; - -/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ -const loadedFaviconUrls = new Set(); +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -17,19 +24,29 @@ export function ProjectFavicon(props: { readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null; + readonly faviconPath?: string | null; }) { const size = props.size ?? 42; const faviconUrl = useAssetUrl( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null - : { _tag: "project-favicon", cwd: props.workspaceRoot }, + : { + _tag: "project-favicon", + cwd: props.workspaceRoot, + ...(props.faviconPath ? { path: props.faviconPath } : {}), + }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; return ( createProjectFaviconRequest(props.cacheKey, props.faviconUrl), + [props.cacheKey, props.faviconUrl], + ); + const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + useLayoutEffect(() => { + if (faviconRequest === null) return; + + const endRequest = beginProjectFaviconRequest(faviconRequest); + setActiveFaviconRequest(faviconRequest); + return endRequest; + }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; + const showImage = requestIsActive && status === "loaded"; return ( { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (!markProjectFaviconLoaded(faviconRequest)) return; setStatus("loaded"); }} - onError={() => setStatus("error")} + onError={() => { + if (!markProjectFaviconFailed(faviconRequest)) return; + setStatus("error"); + }} /> ) : null} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 916802e9faf..662a39437c2 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -360,6 +360,7 @@ function ProjectGroupLabel(props: { { + if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; + return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; + }, [t3ProjectFileData]); + const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFile: t3ProjectFileDefaultMode, + globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + }); + // While unsettled the resolved default is provisional. Nothing may write + // it into the draft during that window (the auto-branch effect does), or + // the frozen interim value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ + explicitMode: selectedProjectDraft.workspaceSelection?.mode, + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFilePending: t3ProjectFileQuery.isPending, + }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; @@ -620,7 +653,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, [refreshBranches, selectedProject]); useEffect(() => { - if (workspaceMode !== "worktree" || selectedBranchName !== null) { + if ( + !defaultWorkspaceModeSettled || + workspaceMode !== "worktree" || + selectedBranchName !== null + ) { return; } // The default may only exist as origin/ (isRemote), which @@ -632,7 +669,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (preferredBranch) { selectBranch(preferredBranch); } - }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [ + allBranchRefs, + availableBranches, + defaultWorkspaceModeSettled, + selectBranch, + selectedBranchName, + workspaceMode, + ]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 2c9f72f575e..b66d9474165 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -185,6 +185,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: > ( + databasePath: string, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); + +/** A migrated source db with one thread per lifecycle state. Only + * `stopped-thread` qualifies for the clone. */ +const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( + baseDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase( + databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + // The real shared db carries this column from a branch build without a + // matching migration; reproduce that drift so the filter is exercised. + yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; + + yield* sql`INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) + VALUES + ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL), + ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`; + + const threads = [ + ["stopped-thread", "project-kept", "stopped", null, null], + ["running-thread", "project-kept", "running", null, null], + ["settled-thread", "project-kept", "stopped", "2026-08-01", null], + ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'], + ["deleted-project-thread", "project-deleted", "stopped", null, null], + ] as const; + for (const [threadId, projectId, status, settledAt, monitorJson] of threads) { + yield* sql`INSERT INTO projection_threads + (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json) + VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at) + VALUES (${threadId}, ${status}, '2026-08-01')`; + yield* sql`INSERT INTO orchestration_events + (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) + VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; + } + yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) + VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; + }), + ); + return databasePath; +}); + +it.layer(NodeServices.layer)("migrate-dev-db", (it) => { + it.effect("keeps only stopped threads from live projects and clears auth state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-dest-" }); + const source = yield* createFixtureSource(sourceDir); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + + assert.equal(result.databasePath, path.join(destDir, "userdata", "state.sqlite")); + const kept = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const threads = yield* sql<{ thread_id: string }>` + SELECT thread_id FROM projection_threads ORDER BY thread_id`; + const events = yield* sql<{ stream_id: string }>` + SELECT stream_id FROM orchestration_events`; + const [auth] = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM auth_sessions`; + return { threads, events, authCount: auth?.count ?? 0 }; + }), + ); + assert.deepStrictEqual( + kept.threads.map((row) => row.thread_id), + ["stopped-thread"], + ); + assert.deepStrictEqual( + kept.events.map((row) => row.stream_id), + ["stopped-thread"], + ); + assert.equal(kept.authCount, 0); + }), + ); + + it.effect("fails loudly on a migration slot collision", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-dest-" }); + const source = yield* createFixtureSource(sourceDir); + // Simulate another branch having claimed slot 1 first: the id is + // recorded, so this checkout's migration 1 silently never runs. + yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`UPDATE effect_sql_migrations + SET name = 'SomebodyElsesMigration' WHERE migration_id = 1`; + }), + ); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSlotCollisionError"); + if (error._tag === "MigrateDevDbSlotCollisionError") { + assert.equal(error.slot, 1); + assert.equal(error.appliedName, "SomebodyElsesMigration"); + } + }), + ); + + it.effect("refuses while a dev server holds the destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-dest-" }); + const source = yield* createFixtureSource(sourceDir); + // This test process stands in for a live dev server. + const stateDir = path.join(destDir, "userdata"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, "server-runtime.json"), + `{"version":1,"pid":${process.pid}}`, + ); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbServerRunningError"); + if (error._tag === "MigrateDevDbServerRunningError") { + assert.equal(error.pid, process.pid); + } + }), + ); + + it.effect("refuses a source that resolves to a destination path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-dest-" }); + // A leftover snapshot from a prior failed run, passed as --source: it + // must not be deleted before it is read. + const leftoverSnapshot = path.join(destDir, "userdata", "state.sqlite.migrate-dev-db-tmp"); + yield* fs.makeDirectory(path.dirname(leftoverSnapshot), { recursive: true }); + yield* fs.writeFileString(leftoverSnapshot, "not a real db"); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source: leftoverSnapshot, projects: 5, threadsPerProject: 10 }, + { sharedHome: sharedDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSourceIsDestinationError"); + assert.equal(yield* fs.exists(leftoverSnapshot), true); + }), + ); + + it.effect("refuses to rebuild the shared home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-shared-" }); + const source = yield* createFixtureSource(sourceDir); + + const error = yield* runMigrateDevDb( + { baseDir: sourceDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSharedHomeError"); + }), + ); +}); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts new file mode 100644 index 00000000000..0958f2149f4 --- /dev/null +++ b/apps/server/scripts/migrate-dev-db.ts @@ -0,0 +1,560 @@ +#!/usr/bin/env node + +/** + * Rebuild an isolated dev database from a pruned snapshot of the real + * ~/.t3 database, then run this checkout's migrations against it. + * + * `vp run migrate-dev-db` from a worktree: + * 1. Nukes `/.t3/userdata/state.sqlite`. + * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the + * most recently updated projects and, per project, the most recent + * threads that have fully stopped. Working, settled, and monitored + * threads are skipped so the dev server never adopts live work. + * Auth sessions, pairing links, command receipts, and provider + * runtime rows are dropped — pair a fresh browser against dev. + * 3. Runs migrations on the result. Because the clone carries the real + * `effect_sql_migrations` table, this proves a new migration applies + * on top of the real applied set, and the slot check below catches + * the silent failure where two branches claim the same + * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped). + * + * The event log (`orchestration_events`) is pruned per stream while + * `sqlite_sequence` and `projection_state` carry over untouched, so new + * events keep appending after the old high-water mark and projection + * cursors never rewind. + */ + +// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { Command, Flag } from "effect/unstable/cli"; + +import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; +import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; + +export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( + "MigrateDevDbNotInWorktreeError", + {}, +) { + override get message(): string { + return "Not inside a linked git worktree. Pass --base-dir to target an isolated .t3 directory."; + } +} + +export class MigrateDevDbSharedHomeError extends Schema.TaggedErrorClass()( + "MigrateDevDbSharedHomeError", + {}, +) { + override get message(): string { + return "Refusing to rebuild the shared ~/.t3 database. Use an isolated --base-dir."; + } +} + +export class MigrateDevDbSourceMissingError extends Schema.TaggedErrorClass()( + "MigrateDevDbSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Source database does not exist at '${this.sourcePath}'.`; + } +} + +export class MigrateDevDbSourceIsDestinationError extends Schema.TaggedErrorClass()( + "MigrateDevDbSourceIsDestinationError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Source database '${this.sourcePath}' resolves to a path this command rewrites. Pick a different --source or --base-dir.`; + } +} + +export class MigrateDevDbServerRunningError extends Schema.TaggedErrorClass()( + "MigrateDevDbServerRunningError", + { + databasePath: Schema.String, + pid: Schema.Number, + }, +) { + override get message(): string { + return `Dev database at '${this.databasePath}' is open by a running server (pid ${this.pid} per server-runtime.json). Stop that server first; if that pid is not actually a T3 server (stale descriptor, reused pid), delete the server-runtime.json next to the database and retry.`; + } +} + +export class MigrateDevDbDestinationBusyError extends Schema.TaggedErrorClass()( + "MigrateDevDbDestinationBusyError", + { + databasePath: Schema.String, + reason: Schema.Literals(["write-locked", "wal-held"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const detail = + this.reason === "write-locked" + ? "the database is write-locked" + : "another connection is holding its WAL"; + return `Dev database at '${this.databasePath}' looks in use (${detail}). Stop the dev server first; if none is running, delete the -wal/-shm files next to it.`; + } +} + +/** + * Two branches claimed the same Migrations/NNN_ slot: the id was already + * recorded under a different name, so this checkout's migration was + * silently skipped and its schema changes never applied. + */ +export class MigrateDevDbSlotCollisionError extends Schema.TaggedErrorClass()( + "MigrateDevDbSlotCollisionError", + { + slot: Schema.Number, + codeName: Schema.String, + appliedName: Schema.String, + }, +) { + override get message(): string { + return `Migration slot collision at ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`; + } +} + +export class MigrateDevDbPhaseError extends Schema.TaggedErrorClass()( + "MigrateDevDbPhaseError", + { + phase: Schema.Literals(["snapshot", "prune", "compact", "migrate", "verify"]), + databasePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `migrate-dev-db failed during ${this.phase} on '${this.databasePath}'.`; + } +} + +export interface RunMigrateDevDbInput { + /** Isolated .t3 directory. Defaults to `/.t3` of the cwd. */ + readonly baseDir?: string | undefined; + /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */ + readonly source?: string | undefined; + readonly projects: number; + readonly threadsPerProject: number; +} + +export interface RunMigrateDevDbOptions { + /** Overridable for tests; the directory writes must never target. */ + readonly sharedHome?: string | undefined; +} + +interface KeptProject { + readonly title: string; + readonly threads: number; +} + +const removeDatabaseFiles = Effect.fn("removeDatabaseFiles")(function* (databasePath: string) { + const fs = yield* FileSystem.FileSystem; + for (const suffix of ["", "-wal", "-shm"]) { + yield* fs.remove(`${databasePath}${suffix}`).pipe(Effect.orElseSucceed(() => undefined)); + } +}); + +/** The slice of server-runtime.json this script cares about. */ +const ServerRuntimeState = Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })); +const decodeServerRuntimeState = Schema.decodeEffect(ServerRuntimeState); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to someone else. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +}; + +/** Liveness probe for a running dev server. The server writes its pid to + * server-runtime.json next to the database, which also catches an idle + * server holding an open-but-inactive connection. The SQL probes below back + * that up: BEGIN IMMEDIATE fails while a writer is active, and + * wal_checkpoint(TRUNCATE) reports busy while another connection holds the + * WAL. A leftover -shm alone is not a signal — read-only connections cannot + * clean it up on close. */ +const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const runtimeStatePath = path.join(path.dirname(databasePath), "server-runtime.json"); + const runtimeState = yield* fs.readFileString(runtimeStatePath).pipe( + Effect.flatMap(decodeServerRuntimeState), + // A missing or malformed descriptor is not a liveness signal. + Effect.option, + ); + if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) { + return yield* new MigrateDevDbServerRunningError({ + databasePath, + pid: runtimeState.value.pid, + }); + } + + if (!(yield* fs.exists(databasePath))) { + return; + } + const checkpoint = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("PRAGMA busy_timeout = 0").unprepared; + yield* sql.unsafe("BEGIN IMMEDIATE").unprepared; + yield* sql.unsafe("ROLLBACK").unprepared; + return yield* sql.unsafe<{ busy: number }>("PRAGMA wal_checkpoint(TRUNCATE)").unprepared; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: databasePath })), + Effect.mapError( + (cause) => + new MigrateDevDbDestinationBusyError({ + databasePath, + reason: "write-locked", + cause, + }), + ), + ); + if (checkpoint[0] !== undefined && Number(checkpoint[0].busy) !== 0) { + return yield* new MigrateDevDbDestinationBusyError({ + databasePath, + reason: "wal-held", + }); + } +}); + +const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { + const sql = yield* SqlClient.SqlClient; + + // The shared db can carry monitor_json from a branch build even though no + // migration in this checkout creates it, so filter it only when present. + const threadColumns = yield* sql<{ name: string }>` + SELECT name FROM pragma_table_info('projection_threads')`; + const monitorFilter = threadColumns.some((column) => column.name === "monitor_json") + ? "AND t.monitor_json IS NULL" + : ""; + + // "Stopped" is the persisted subset of the UI's thread status: the session + // reached status 'stopped' and nothing marks the thread settled or + // monitored. The in-memory working/monitoring liveness never persists, so + // filtering the session status is sufficient. + yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS + SELECT t.thread_id, t.project_id, t.updated_at + FROM projection_threads t + JOIN projection_thread_sessions s ON s.thread_id = t.thread_id + WHERE t.deleted_at IS NULL + AND t.archived_at IS NULL + AND t.settled_at IS NULL + AND (t.settled_override IS NULL OR t.settled_override <> 'settled') + ${monitorFilter} + AND s.status = 'stopped'`).unprepared; + + // Projects with clonable threads outrank empty-but-recent ones: the point + // of the exercise is thread data, not the project list. + yield* sql`CREATE TEMP TABLE kept_projects AS + SELECT p.project_id + FROM projection_projects p + LEFT JOIN ( + SELECT project_id, MAX(updated_at) AS last_stopped_at + FROM stopped_threads + GROUP BY project_id + ) q ON q.project_id = p.project_id + WHERE p.deleted_at IS NULL + ORDER BY (q.last_stopped_at IS NULL) ASC, + COALESCE(q.last_stopped_at, p.updated_at) DESC + LIMIT ${input.projects}`; + + yield* sql`CREATE TEMP TABLE kept_threads AS + SELECT thread_id FROM ( + SELECT + st.thread_id, + ROW_NUMBER() OVER ( + PARTITION BY st.project_id + ORDER BY st.updated_at DESC + ) AS recency_rank + FROM stopped_threads st + JOIN kept_projects kp ON kp.project_id = st.project_id + ) + WHERE recency_rank <= ${input.threadsPerProject}`; + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`DELETE FROM projection_projects + WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; + yield* sql`DELETE FROM projection_threads + WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`; + for (const table of [ + "projection_thread_messages", + "projection_thread_activities", + "projection_thread_sessions", + "projection_turns", + "projection_pending_approvals", + "projection_thread_proposed_plans", + "checkpoint_diff_blobs", + ]) { + yield* sql.unsafe( + `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, + ).unprepared; + } + yield* sql`DELETE FROM orchestration_events + WHERE (aggregate_kind = 'thread' + AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) + OR (aggregate_kind = 'project' + AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; + yield* sql`DELETE FROM orchestration_command_receipts`; + yield* sql`DELETE FROM provider_session_runtime`; + yield* sql`DELETE FROM auth_sessions`; + yield* sql`DELETE FROM auth_pairing_links`; + }), + ); + + const keptProjects = yield* sql<{ title: string; threads: number }>` + SELECT + p.title, + (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads + FROM projection_projects p + ORDER BY p.updated_at DESC`; + const [events] = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM orchestration_events`; + + return { + projects: keptProjects as ReadonlyArray, + eventCount: events?.count ?? 0, + }; +}); + +/** Compare this checkout's migration registry against what the cloned + * database recorded: same slot under a different name means the migration + * was skipped, not applied. */ +const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () { + const sql = yield* SqlClient.SqlClient; + const applied = yield* sql<{ migration_id: number; name: string }>` + SELECT migration_id, name FROM effect_sql_migrations`; + const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name])); + for (const [slot, codeName] of migrationManifest) { + const appliedName = appliedById.get(slot); + if (appliedName !== undefined && appliedName !== codeName) { + return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName }); + } + } +}); + +export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( + input: RunMigrateDevDbInput, + options: RunMigrateDevDbOptions = {}, +) { + // SQLite treats a negative LIMIT as "no limit", which would clone + // everything. The CLI flags validate this too; this covers direct callers. + if (input.projects < 1 || input.threadsPerProject < 0) { + return yield* Effect.die("projects must be >= 1 and threadsPerProject >= 0"); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); + const sourcePath = path.resolve( + input.source ?? path.join(sharedHome, "userdata", "state.sqlite"), + ); + + const baseDir = + input.baseDir !== undefined + ? path.resolve(input.baseDir) + : yield* resolveWorktreeT3Home(process.cwd()); + if (baseDir === undefined) { + return yield* new MigrateDevDbNotInWorktreeError(); + } + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + const snapshotPath = `${databasePath}.migrate-dev-db-tmp`; + + if (!(yield* fs.exists(sourcePath))) { + return yield* new MigrateDevDbSourceMissingError({ sourcePath }); + } + const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([ + fs.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)), + fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)), + ]); + if (canonicalBaseDir === canonicalSharedHome) { + return yield* new MigrateDevDbSharedHomeError(); + } + // The destination db and snapshot both get deleted below; a --source that + // resolves to either (e.g. a leftover snapshot file) would be destroyed + // before it is ever read. + const canonicalSourcePath = yield* fs + .realPath(sourcePath) + .pipe(Effect.orElseSucceed(() => sourcePath)); + for (const destination of [databasePath, snapshotPath]) { + const canonicalDestination = yield* fs + .realPath(destination) + .pipe(Effect.orElseSucceed(() => destination)); + if (canonicalSourcePath === canonicalDestination) { + return yield* new MigrateDevDbSourceIsDestinationError({ sourcePath }); + } + } + + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* ensureNotInUse(databasePath); + + const wrapPhase = + (phase: MigrateDevDbPhaseError["phase"], phaseDatabasePath: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => new MigrateDevDbPhaseError({ phase, databasePath: phaseDatabasePath, cause }), + ), + ); + + yield* removeDatabaseFiles(snapshotPath); + // The snapshot is a full-size copy of the source; make sure it is removed + // even when a phase fails partway through. + const { executedMigrations, pruned } = yield* Effect.gen(function* () { + yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${snapshotPath}`; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + wrapPhase("snapshot", sourcePath), + ); + + // Migrate before pruning: a source older than this checkout would + // otherwise crash the prune queries on columns that don't exist yet. + // Running against the full snapshot also exercises new migrations on the + // same data volume the real database would face. + yield* Console.log("Running migrations on the snapshot..."); + const executed = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // Mirror server boot (persistence/Layers/Sqlite.ts). + yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; + return yield* runMigrations(); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("migrate", snapshotPath), + ); + + // Verify while the snapshot is still the only thing touched: a slot + // collision must abort before the old worktree db gets replaced with a + // schema whose colliding migration was silently skipped. + yield* verifyMigrationSlots().pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new MigrateDevDbPhaseError({ phase: "verify", databasePath: snapshotPath, cause }), + ), + }), + ); + + yield* Console.log( + `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`, + ); + const result = yield* pruneSnapshot(input).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("prune", snapshotPath), + ); + + yield* Console.log(`Compacting into ${databasePath}...`); + // Re-check right before the swap: a dev server started while the + // snapshot was migrating and pruning must not lose its database. + yield* ensureNotInUse(databasePath); + yield* removeDatabaseFiles(databasePath); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${databasePath}`; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("compact", databasePath), + ); + return { executedMigrations: executed, pruned: result }; + }).pipe(Effect.ensuring(removeDatabaseFiles(snapshotPath))); + yield* fs.chmod(databasePath, 0o600); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // WAL does not survive VACUUM INTO; set it so first `vp run dev` finds + // the database exactly as server boot would have left it. + yield* sql.unsafe("PRAGMA journal_mode = WAL").unprepared; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: databasePath })), + wrapPhase("compact", databasePath), + ); + + const size = (yield* fs.stat(databasePath)).size; + return { + databasePath, + sizeBytes: Number(size), + projects: pruned.projects, + eventCount: pruned.eventCount, + executedMigrations: executedMigrations.map(([id, name]) => `${id}_${name}`), + }; +}); + +const formatSize = (bytes: number): string => + bytes >= 1024 * 1024 + ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(0)} KB`; + +export const migrateDevDbCommand = Command.make( + "migrate-dev-db", + { + projects: Flag.integer("projects").pipe( + Flag.withDefault(5), + Flag.withDescription("How many recently updated projects to keep."), + ), + threadsPerProject: Flag.integer("threads-per-project").pipe( + Flag.withDefault(10), + Flag.withDescription("How many recent stopped threads to keep per project."), + ), + baseDir: Flag.string("base-dir").pipe( + Flag.optional, + Flag.withDescription("Isolated .t3 directory. Defaults to the current worktree's .t3."), + ), + source: Flag.string("source").pipe( + Flag.optional, + Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."), + ), + }, + ({ projects, threadsPerProject, baseDir, source }) => + Effect.gen(function* () { + const result = yield* runMigrateDevDb({ + projects, + threadsPerProject, + baseDir: Option.getOrUndefined(baseDir), + source: Option.getOrUndefined(source), + }); + yield* Console.log(""); + yield* Console.log( + `Dev database ready: ${result.databasePath} (${formatSize(result.sizeBytes)})`, + ); + for (const project of result.projects) { + yield* Console.log(` ${project.title}: ${project.threads} threads`); + } + yield* Console.log(` ${result.eventCount} orchestration events kept`); + yield* Console.log( + result.executedMigrations.length === 0 + ? " Migrations: already current (no new migrations in this checkout)" + : ` Migrations applied: ${result.executedMigrations.join(", ")}`, + ); + }), +).pipe( + Command.withDescription( + "Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.", + ), +); + +if (import.meta.main) { + Command.run(migrateDevDbCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index e52d9dd17a2..fd257dd2036 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,12 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -255,6 +257,13 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.sourcePath).toBe("favicon.svg"); + expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + expect( + yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }), + ).toEqual(faviconResult); const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( @@ -269,6 +278,7 @@ describe("AssetAccess", () => { resource: { _tag: "project-favicon", cwd: root }, }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); + expect(fallbackResult.sourcePath).toBeUndefined(); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -280,6 +290,111 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues project favicon capabilities for a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-override-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), ""); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "auto"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "brand/custom.svg", + }); + + expect(result.sourcePath).toBe("brand/custom.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("ignores a client favicon path hint", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-hint-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "hint.svg"), "hint"); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" }, + projectFaviconPath: "brand/saved.svg", + }); + + expect(result.sourcePath).toBe("brand/saved.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps automatic favicon resolution separate from a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-automatic-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "automatic"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + + expect(result.sourcePath).toBe("favicon.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a resolved project favicon with a non-image extension", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-type-", + }); + yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image"); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "secret.txt", + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("buckets project favicon expiry after content hashing", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-expiry-", + }); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); + + const bucketMs = 30 * 60 * 1000; + yield* TestClock.setTime(bucketMs - 1); + const crossingCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (algorithm, data) => + TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), + }); + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); + + expect(result.expiresAt).toBe(3 * bucketMs); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index ac48f0198c9..d8903f4921a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,7 +21,9 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; +const PROJECT_FAVICON_VERSION_PREFIX = "v"; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -165,13 +169,15 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly projectFaviconPath?: string; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; + let sourcePath: string | undefined; switch (input.resource._tag) { case "workspace-file": { @@ -302,28 +308,34 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if ( - relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + const faviconPath = yield* faviconResolver + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) + .pipe( Effect.mapError( (cause) => - new AssetProjectFaviconInspectionError({ + new AssetProjectFaviconResolutionError({ resource: input.resource, cause, }), ), - )) - ) { + ); + const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + sourcePath = relativePath ?? undefined; + const canonicalFaviconPath = relativePath + ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : null; + if (relativePath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); @@ -343,7 +355,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; + if (relativePath && canonicalFaviconPath) { + const crypto = yield* Crypto.Crypto; + const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + } else { + fileName = PROJECT_FAVICON_FALLBACK_MARKER; + } break; } } @@ -358,11 +394,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (claims.kind === "project-favicon") { + const issuedAt = yield* Clock.currentTimeMillis; + expiresAt = + (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * + PROJECT_FAVICON_TOKEN_BUCKET_MS; + claims = { ...claims, expiresAt }; + } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, + ...(sourcePath !== undefined ? { sourcePath } : {}), }; }); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 12eebbbd223..b86f6b43893 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -35,6 +35,18 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", expect(unit).not.toContain("versions/1.2.3"); }); +it("survives the kernel OOM-killing a greedy agent child", () => { + const unit = BootService.renderBootServiceUnit({ + nodePath: "/usr/bin/node", + launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + expect(unit).toContain("OOMPolicy=continue"); +}); + const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index a57585be4b2..7eef6feba50 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -67,6 +67,11 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", + // Agent tool calls run as children of the server, so they share this cgroup. + // With the systemd default of OOMPolicy=stop, the kernel killing one greedy + // child stops the whole unit: the server, every live agent, and the user's + // connection. Keep running and let Restart=always cover the main process. + "OOMPolicy=continue", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 72591798aa5..d159057af52 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2896,15 +2896,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, + faviconPath: "brand/icon.svg", }); const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModelSelection: string; + readonly faviconPath: string | null; }>` SELECT scripts_json AS "scriptsJson", - default_model_selection_json AS "defaultModelSelection" + default_model_selection_json AS "defaultModelSelection", + favicon_path AS "faviconPath" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -2913,6 +2916,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', + faviconPath: "brand/icon.svg", }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d2b27b7b060..ed873bfe60f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -501,6 +501,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, + defaultThreadEnvMode: null, + faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -524,6 +526,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } + : {}), + ...(event.payload.faviconPath !== undefined + ? { faviconPath: event.payload.faviconPath } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ea994430ad9..bd91f7b0ed2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -276,6 +276,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -285,6 +286,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -397,6 +399,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -406,6 +409,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 424907ef2a7..afc96c344e5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -361,6 +361,8 @@ function mapProjectShellRow( workspaceRoot: row.workspaceRoot, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -473,6 +475,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1016,6 +1020,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1038,6 +1044,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1871,6 +1879,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2035,6 +2045,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2566,6 +2578,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: option.value.workspaceRoot, repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, + defaultThreadEnvMode: option.value.defaultThreadEnvMode, + faviconPath: option.value.faviconPath ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 229944e47b7..00b70fc0408 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, + type OrchestrationCommand, ProjectId, ProviderItemId, type ServerSettings, @@ -257,57 +258,52 @@ describe("ProviderRuntimeIngestion", () => { scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); + const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-provider-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), + await dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-provider-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + await dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + await dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + status: "ready", + providerName: "codex", runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: createdAt, - lastError: null, - }, - createdAt, - }), - ); + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }); provider.setSession({ provider: ProviderDriverKind.make("codex"), status: "ready", @@ -319,6 +315,7 @@ describe("ProviderRuntimeIngestion", () => { return { engine, + dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, @@ -844,6 +841,82 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("rejects an untargeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A turn start is pending: the session reads "starting" with no active + // turn tracked yet. This is the window the Claude resume handshake's + // phantom (turn.completed with no turnId) used to slip through, stomping + // "starting" back to "ready" for a turn that never existed. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-untargeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-untargeted"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + status: "completed", + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + + it("accepts a targeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A completion that names its turn still lands even when no active turn + // is tracked (e.g. its turn.started was lost). Only untargeted + // completions are rejected. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-targeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-targeted-late"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late"), + status: "completed", + }); + + await waitForThread(harness.readModel, (thread) => thread.session?.status === "ready"); + }); + it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b0c554b4d62..db42dc73cd1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1648,8 +1648,14 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // If no active turn is tracked, accept completion scoped to this thread. - return true; + // No active turn tracked: accept only completions that name their + // turn (covers a real completion whose turn.started was lost). An + // untargeted completion cannot prove it belongs to any turn this + // thread ran — the known emitter was the Claude resume handshake + // (system/init + result(num_turns: 0)), which is not a turn at + // all — and applying it here stomps the "starting" lifecycle + // state while a turn start is pending. + return eventTurnId !== undefined; default: return true; } diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a0c06840733..bf5c509fa16 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,47 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("propagates a project favicon path in project.meta.update", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const readModel = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: asEventId("evt-project-create-favicon"), + aggregateKind: "project", + aggregateId: asProjectId("project-favicon"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-favicon"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-favicon"), + metadata: {}, + payload: { + projectId: asProjectId("project-favicon"), + title: "Favicon", + workspaceRoot: "/tmp/favicon", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-favicon"), + projectId: asProjectId("project-favicon"), + faviconPath: "brand/icon.svg", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg"); + }), + ); + it.effect("rejects project.create for an active workspace root that already exists", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts new file mode 100644 index 00000000000..40f1f56c2ab --- /dev/null +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -0,0 +1,112 @@ +import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HashMap from "effect/HashMap"; +import * as Option from "effect/Option"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const projectId = ProjectId.make("project-env-mode"); + +const projectFromModel = (model: { + readonly projects: HashMap.HashMap; +}) => + Option.getOrUndefined(HashMap.get(model.projects, projectId)) as + | { readonly defaultThreadEnvMode: string | null } + | undefined; + +const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ + sequence, + eventId: EventId.make(`evt-project-env-mode-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: now, + commandId: CommandId.make(`cmd-project-env-mode-${sequence}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-project-env-mode-${sequence}`), + metadata: {}, + payload: { + projectId, + title: "Env mode", + workspaceRoot: "/tmp/env-mode", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, +}); + +it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { + it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + expect(projectFromModel(readModel)?.defaultThreadEnvMode).toBeNull(); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { defaultThreadEnvMode?: unknown }).defaultThreadEnvMode).toBe( + "worktree", + ); + + const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); + expect(projectFromModel(updated)?.defaultThreadEnvMode).toBe("worktree"); + }), + ); + + it.effect("omits the field when unset and clears it on explicit null", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + + const unrelated = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-title"), + projectId, + title: "Renamed", + }, + readModel, + }); + const unrelatedEvent = Array.isArray(unrelated) ? unrelated[0] : unrelated; + expect("defaultThreadEnvMode" in (unrelatedEvent.payload as object)).toBe(false); + + const set = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + const setEvent = Array.isArray(set) ? set[0] : set; + const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 }); + + const clear = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-clear"), + projectId, + defaultThreadEnvMode: null, + }, + readModel: afterSet, + }); + const clearEvent = Array.isArray(clear) ? clear[0] : clear; + const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 }); + expect(projectFromModel(afterClear)?.defaultThreadEnvMode).toBeNull(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 471730a1e34..dbb6120c1d0 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -520,4 +520,55 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); + + it.effect("drops an onlyIfSettled session stop when the thread was re-engaged", () => + Effect.gen(function* () { + const stopCommand = (commandId: string) => + ({ + type: "thread.session.stop", + commandId: CommandId.make(commandId), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + onlyIfSettled: true, + }) as const; + + // Still settled with an idle session: the cleanup stop goes through. + const stopped = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-settled-idle"), + readModel: makeReadModel("settled", null, makeSession("ready")), + }); + const stoppedEvents = Array.isArray(stopped) ? stopped : [stopped]; + expect(stoppedEvents.map((event) => event.type)).toEqual(["thread.session-stop-requested"]); + + // Re-engaged before the stop was decided (a turn start unsettles the + // thread): the stale cleanup stop must not kill the new session. + const unsettledError = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-unsettled"), + readModel: makeReadModel(null, null, makeSession("starting")), + }).pipe(Effect.flip); + expect(unsettledError._tag).toBe("OrchestrationCommandInvariantError"); + + // Still settled but the session is already coming alive: same drop. + const aliveError = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-session-alive"), + readModel: makeReadModel("settled", null, makeSession("starting")), + }).pipe(Effect.flip); + expect(aliveError._tag).toBe("OrchestrationCommandInvariantError"); + + // Without the flag the stop stays unconditional (archive, stop button). + const unconditional = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-unconditional"), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + }, + readModel: makeReadModel(null, null, makeSession("starting")), + }); + const unconditionalEvents = Array.isArray(unconditional) ? unconditional : [unconditional]; + expect(unconditionalEvents.map((event) => event.type)).toEqual([ + "thread.session-stop-requested", + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index bc893897e7b..9f7df30b327 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -485,6 +485,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, + faviconPath: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -522,6 +523,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), + ...(command.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: command.defaultThreadEnvMode } + : {}), + ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, @@ -1487,11 +1492,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.stop": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + // Settle-cleanup stops are conditional: between the settle landing and + // this command, another client may have re-engaged the thread (a turn + // start unsettles it and brings the session alive). Commands are + // decided serially against this read model, so checking here — not in + // the dispatcher's pre-settle snapshot — closes that race. + if (command.onlyIfSettled === true) { + const sessionComingAlive = + thread.session?.status === "starting" || thread.session?.status === "running"; + if ( + thread.settledOverride !== "settled" || + sessionComingAlive || + threadHasQueuedTurnStart(thread, command.createdAt) + ) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, + }), + ); + } + } return { ...(yield* withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 9e5bb40c59d..d05890c7cca 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -250,6 +250,8 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, + defaultThreadEnvMode: null, + faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -275,6 +277,10 @@ export function projectEvent( ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), + ...(payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } + : {}), + ...(payload.faviconPath !== undefined ? { faviconPath: payload.faviconPath } : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, })), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e..ba133bb24a4 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -35,6 +35,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, default_model_selection_json, + default_thread_env_mode, + favicon_path, scripts_json, created_at, updated_at, @@ -45,6 +47,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.title}, ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${row.defaultThreadEnvMode}, + ${row.faviconPath ?? null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -55,6 +59,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, + default_thread_env_mode = excluded.default_thread_env_mode, + favicon_path = excluded.favicon_path, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -72,6 +78,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -91,6 +99,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 8c780013e94..c725fefe2e0 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + defaultThreadEnvMode: null, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/MigrationNamespaces.test.ts b/apps/server/src/persistence/MigrationNamespaces.test.ts index a43c72eac9d..7b5e218e4fc 100644 --- a/apps/server/src/persistence/MigrationNamespaces.test.ts +++ b/apps/server/src/persistence/MigrationNamespaces.test.ts @@ -8,10 +8,10 @@ describe("migration namespaces", () => { it("keeps upstream and fork manifests in independent ledgers", () => { assert.notEqual(upstreamMigrationTable, forkMigrationTable); assert.deepStrictEqual(migrationManifest.slice(-4), [ - [35, "ProjectionThreadTitleRegeneration"], - [36, "ProjectionThreadsPinned"], [37, "ProjectionTurnsKeysetIndex"], [38, "ProjectionThreadsPinOrderKey"], + [39, "ProjectionProjectsDefaultThreadEnvMode"], + [40, "ProjectionProjectFaviconPath"], ]); assert.deepStrictEqual(forkMigrationManifest, [ [1, "ProjectionQueuedMessages"], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 47f6547b8be..c9ff2977cf9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,8 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; +import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; +import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; /** * Migration loader with all migrations defined inline. @@ -104,6 +106,8 @@ export const migrationEntries = [ [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], [38, "ProjectionThreadsPinOrderKey", Migration0038], + [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], + [40, "ProjectionProjectFaviconPath", Migration0040], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts index ee700809b7b..55163a43fa6 100644 --- a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts +++ b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts @@ -87,10 +87,10 @@ layer("b18 desktop migration namespace repair", (it) => { readonly name: string; }>`SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id`; assert.deepStrictEqual(upstreamMigrations.slice(-4), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, - { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, + { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, + { migration_id: 40, name: "ProjectionProjectFaviconPath" }, ]); const forkMigrations = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts new file mode 100644 index 00000000000..2ac6f78e6de --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "default_thread_env_mode")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_thread_env_mode TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts new file mode 100644 index 00000000000..7fd43d9b2ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("040_ProjectionProjectFaviconPath", (it) => { + it.effect("adds the nullable favicon path to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 39 }); + yield* runMigrations({ toMigrationInclusive: 40 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const faviconPath = columns.find((column) => column.name === "favicon_path"); + + assert.equal(faviconPath?.name, "favicon_path"); + assert.equal(faviconPath?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts new file mode 100644 index 00000000000..8424e8d5e72 --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "favicon_path")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN favicon_path TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts index de5d52b8d91..f7716ca7508 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts @@ -39,10 +39,10 @@ layer("fork migration namespace for a repaired database", (it) => { SELECT migration_id, name FROM ${sql(legacyMigrationBackupTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, - { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, + { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, + { migration_id: 40, name: "ProjectionProjectFaviconPath" }, ]); assert.deepStrictEqual(fork, [ { migration_id: 1, name: "ProjectionQueuedMessages" }, diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts index 3a502604c82..674fdbfe53e 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts @@ -72,10 +72,10 @@ layer("smart migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, - { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, + { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, + { migration_id: 40, name: "ProjectionProjectFaviconPath" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts index b2b03f93b8f..a4c0997d6ea 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts @@ -29,12 +29,12 @@ layer("t3vm migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-6), [ - { migration_id: 33, name: "ProjectionThreadsSettled" }, - { migration_id: 34, name: "ProjectionThreadsSnoozed" }, { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, + { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, + { migration_id: 40, name: "ProjectionProjectFaviconPath" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a269..339439fdfcb 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,13 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { + IsoDateTime, + ModelSelection, + ProjectId, + ProjectScript, + ThreadEnvMode, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -19,6 +25,8 @@ export const ProjectionProject = Schema.Struct({ title: Schema.String, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + faviconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a5..34ad74da621 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -77,6 +77,33 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon override", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "brand/custom.svg", "custom"); + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand/custom.svg"); + }), + ); + + it.effect("falls back when a saved override is missing from a checkout", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630..0b447bd4f3e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -91,6 +91,7 @@ export class ProjectFaviconResolver extends Context.Service< */ readonly resolvePath: ( cwd: string, + faviconPath?: string, ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -168,7 +169,7 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd) { + )(function* (cwd, faviconPath) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -179,6 +180,15 @@ export const make = Effect.gen(function* () { }), ), ); + // A grouped project's saved path can be absent from one checkout. Use it + // where it exists and retain automatic discovery for the other checkouts. + if (faviconPath !== undefined) { + const existing = yield* findExistingFile(projectCwd, [faviconPath]); + if (existing) { + return existing; + } + } + // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 22422b270af..895844c7ead 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1018,6 +1018,75 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not emit turn.completed for a result with no active turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Collect through session.exited so the window after the second result + // is deterministically inside the collection: both results are queued + // after sendTurn returns and drain in order on the one stream consumer. + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 1, + session_id: "sdk-session-1", + uuid: "result-real", + } as unknown as SDKMessage); + + // Second result with no turn in flight — the shape the resume + // handshake (system/init + result(num_turns: 0)) delivers, and the + // same completeTurn branch every no-turnState result lands in. This + // used to emit an untargeted turn.completed; it must emit nothing. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 0, + usage: { input_tokens: 0, output_tokens: 0 }, + session_id: "sdk-session-1", + uuid: "result-handshake", + } as unknown as SDKMessage); + + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const completions = runtimeEvents.filter((event) => event.type === "turn.completed"); + // Exactly one completion — the real turn's, targeted at its turn id. + // The buggy branch produced a second, untargeted one here. + assert.equal(completions.length, 1); + const completed = completions[0]; + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9520743fb81..77b003988a1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2251,24 +2251,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: result ?? { status }, }); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, + // A result with no local turn is never a turn this adapter started: + // real turns get turnState in sendTurn, and assistant messages that + // arrive outside a turn auto-start a synthetic one. What lands here is + // the resume handshake (system/init + result(num_turns: 0)), a late + // result for a turn already completed locally (steer auto-close, + // stream teardown), or a stream failure with no turn in flight. The + // untargeted turn.completed this branch used to emit carried no turnId, + // so ingestion could not attribute it — and whenever the projection had + // no active turn (a pending turn start included) it flipped the session + // lifecycle for a turn that never existed. Keep the usage emission, + // drop the lifecycle event, and leave a tripwire so the upstream + // trigger stays measurable in the field. + yield* Effect.logInfo("claude.turn.result-without-active-turn", { threadId: context.session.threadId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: {}, + status, + numTurns: result?.num_turns, + hasUsage: result?.usage !== undefined, + ...(errorMessage ? { errorMessage } : {}), }); return; } @@ -4126,6 +4126,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(ultracode ? { ultracode: true } : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + // The attachments dir grant lets the agent Read/copy pasted images at + // the paths ProviderService injects into the turn text, without an + // approval prompt. It is a leaf directory holding only attachment + // files; siblings like secrets/ and state.sqlite stay ungranted. + // `cwd` (resolved/trimmed), not the raw `input.cwd`: the grant has to + // name the same path the query actually runs in. + const additionalDirectories = [...(cwd ? [cwd] : []), serverConfig.attachmentsDir]; const queryOptions: ClaudeQueryOptions = { ...(cwd ? { cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -4149,7 +4156,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( includePartialMessages: true, canUseTool, env: sessionEnvironment, - ...(cwd ? { additionalDirectories: [cwd] } : {}), + additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -4184,7 +4191,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": cwd ? [cwd] : [], + "claude.query.additional_directories": additionalDirectories, "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 3a02c45b23f..0da77f61efb 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -192,6 +192,7 @@ describe("CodexSessionRuntime collab integration", () => { Effect.sync(() => { NodeFS.rmSync(scriptPath, { force: true }); NodeFS.rmSync(interruptsPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.turns`, { force: true }); }), ); @@ -245,4 +246,113 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + // The fork steers the running turn first; rejecting the steer is what + // sends the follow-up back through turn/start, which is the path + // whose response carries the queued turn id. + rejectSteer: true, + turnIds: [activeTurnId, queuedTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.turns`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-queued-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.sendTurn({ input: "queued follow-up" }); + yield* runtime.interruptTurn(); + + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); + assert.deepEqual(interrupts.at(-1), { + threadId: ROOT, + turnId: activeTurnId, + }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("Stop targets the steered turn when Codex accepts a mid-turn follow-up", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + turnIds: [activeTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.turns`, { force: true }); + }), + ); + + const turnsPath = `${scriptPath}.turns`; + NodeFS.rmSync(turnsPath, { force: true }); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-steered-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + // Steered, not a second turn: the follow-up never opens a new turn id. + yield* runtime.sendTurn({ input: "steered follow-up" }); + yield* runtime.interruptTurn(); + + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); + assert.deepEqual(interrupts.at(-1), { + threadId: ROOT, + turnId: activeTurnId, + }); + // An accepted steer must not open a second provider turn. + const turns = NodeFS.readFileSync(turnsPath, "utf8").trim().split("\n"); + assert.equal(turns.length, 1); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 627b56feab4..a5ac896bf8d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -824,13 +824,13 @@ function currentProviderThreadId(session: ProviderSession): string | undefined { function updateSession( sessionRef: Ref.Ref, - updates: Partial, + updates: Partial | ((session: ProviderSession) => Partial), ): Effect.Effect { return Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* Ref.update(sessionRef, (session) => ({ ...session, - ...updates, + ...(typeof updates === "function" ? updates(session) : updates), updatedAt, })); }); @@ -1838,11 +1838,14 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, { + yield* updateSession(sessionRef, (session) => ({ status: "running", - activeTurnId: turnId, + // Codex accepts follow-ups while the current turn is still + // running. The response contains the queued turn id, but + // turn/interrupt only accepts the id that is active now. + activeTurnId: session.activeTurnId ?? turnId, ...(normalizedModel ? { model: normalizedModel } : {}), - }); + })); const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); return { threadId: options.threadId, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f1f56b89119..3e6df3a32b3 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -55,12 +55,16 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; import { readProviderRestartRecoveryMarker } from "../ProviderRestartRecovery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); +const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provide(NodeServices.layer), +); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -293,6 +297,7 @@ function makeProviderServiceLayer() { Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -344,6 +349,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -391,6 +397,7 @@ it.effect("ProviderServiceLive bounds a provider that wedges during shutdown", ( Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), + Layer.provide(serverConfigTestLayer), Layer.provide( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -440,6 +447,7 @@ it.effect("graceful shutdown preserves recovery intent only for working sessions Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(serverConfigTestLayer), Layer.provide( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -554,6 +562,7 @@ it.effect("graceful shutdown recovers live bindings missing from adapter listSes Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), + Layer.provide(serverConfigTestLayer), Layer.provide( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -639,6 +648,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -723,6 +733,7 @@ it.effect( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -793,6 +804,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -848,6 +860,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -908,6 +921,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -974,6 +988,7 @@ it.effect( ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1033,6 +1048,7 @@ it.effect( ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1164,6 +1180,54 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("appends attachment file paths to the turn input text", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-attach"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-attach"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 123, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "use this screenshot", + attachments: [attachment], + }); + + const turnInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(typeof turnInput.input, "string"); + const turnText = turnInput.input ?? ""; + assert.equal(turnText.startsWith("use this screenshot"), true); + assert.include(turnText, '[Attached image "screenshot.png" is saved at: '); + assert.equal(turnText.endsWith(`${attachment.id}.png]`), true); + + // An attachment-only turn stays valid and the injected line becomes the + // whole input text, so the agent still learns the path. + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + attachments: [attachment], + }); + const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + + yield* provider.stopSession({ threadId: session.threadId }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1570,6 +1634,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1608,6 +1673,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1676,6 +1742,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1709,6 +1776,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8c38d49be1b..fd1be521416 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -38,6 +38,8 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; import { increment, providerMetricAttributes, @@ -246,6 +248,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical @@ -827,16 +830,44 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - attachments: parsed.attachments ?? [], - }; - if (!input.input && input.attachments.length === 0) { + const attachments = parsed.attachments ?? []; + if (!parsed.input && attachments.length === 0) { return yield* toValidationError( "ProviderService.sendTurn", "Either input text or at least one attachment is required", ); } + + // Adapters inline attachment pixels into the model prompt, but the model's + // tools cannot dereference pixels. Appending the on-disk path is what lets + // a turn like "include this screenshot in the PR" copy the actual file. + // This runs after schema decode, so the appended lines are exempt from the + // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so + // the overhead is bounded. Unresolvable ids are skipped here and surface + // as adapter errors when the file is read for inlining. + const attachmentPathLines = attachments.flatMap((attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return attachmentPath === null + ? [] + : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; + }); + const inputTextWithAttachmentPaths = + attachmentPathLines.length === 0 + ? parsed.input + : [parsed.input, attachmentPathLines.join("\n")] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + const input = { + ...parsed, + ...(inputTextWithAttachmentPaths !== undefined + ? { input: inputTextWithAttachmentPaths } + : {}), + attachments, + }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 59580d2c7e6..e9badc5a2b3 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -16,6 +16,7 @@ const fixture = JSON.parse( const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +let turnStartCount = 0; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -43,14 +44,26 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { - write({ id, result: fixture.responses.turnStart }); + const turnId = script.turnIds?.[turnStartCount]; + const turn = turnId + ? { ...fixture.responses.turnStart.turn, id: turnId } + : fixture.responses.turnStart.turn; + turnStartCount += 1; + // Append-only sidecar the tests read to tell a steered follow-up (no new + // turn) apart from one that opened a second provider turn. + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.turns`, + `${JSON.stringify({ turnId: turn.id })}\n`, + ); + write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; - const turn = fixture.responses.turnStart.turn; - write({ - jsonrpc: "2.0", - method: "turn/started", - params: { threadId: rootThreadId, turn }, - }); + if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { + write({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: rootThreadId, turn }, + }); + } for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } @@ -66,6 +79,18 @@ rl.on("line", (line) => { } return; } + if (method === "turn/steer") { + // The fork appends follow-up input to the running turn instead of + // opening a second provider turn. `rejectSteer` models a turn that + // cannot accept steering (review, manual compact), which sends the + // runtime back to turn/start. + if (script.rejectSteer === true) { + write({ id, error: { code: -32000, message: "turn is not steerable" } }); + return; + } + write({ id, result: { turnId: message.params?.expectedTurnId } }); + return; + } if (method === "turn/interrupt") { // Record which thread/turn was interrupted (append-only sidecar file the // test reads) so Stop coverage can assert every live child was reached. @@ -75,6 +100,20 @@ rl.on("line", (line) => { `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, ); + if ( + script.expectedActiveTurnId && + message.params?.threadId === script.rootThreadId && + message.params?.turnId !== script.expectedActiveTurnId + ) { + write({ + id, + error: { + code: -32000, + message: `expected active turn id ${message.params?.turnId} but found ${script.expectedActiveTurnId}`, + }, + }); + return; + } if (script.failInterruptFor && script.failInterruptFor === target) { write({ id, error: { code: -32000, message: "thread already closed" } }); return; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c401942323a..ab93dd5fda2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7322,6 +7322,126 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("stops the provider session after settle without closing terminals", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); + assert.equal(sessionStopCommand.onlyIfSettled, true); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("settles without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("archives and still closes terminals when session stop fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-stop-failure"); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37..28a30481b1b 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -245,7 +245,7 @@ export const make = Effect.gen(function* () { }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly); }).pipe( Effect.provide( workspaceSearchIndexes.get( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 15572837030..1fdf956447d 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,10 @@ -import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; +import { + FileFinder, + type FileItem, + type GrepCursor, + type GrepOptions, + type GrepResult, +} from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -11,6 +17,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +function fileItem(relativePath: string): FileItem { + return { + relativePath, + fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1), + size: 1, + modified: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + totalFrecencyScore: 0, + gitStatus: "clean", + }; +} + +it.effect("filters image searches before applying the result limit", () => + Effect.scoped( + Effect.gen(function* () { + const items = [ + ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), + fileItem("public/icon.svg"), + ]; + const fileSearch = vi.fn(() => ({ + ok: true as const, + value: { + items, + scores: [], + totalMatched: items.length, + totalFiles: items.length, + }, + })); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + fileSearch, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); + const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); + + expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(fileSearch).toHaveBeenCalledTimes(2); + expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); + }), + ), +); + it.effect("preserves unexpected FileFinder creation failures", () => Effect.gen(function* () { const cause = new Error("native initialization failed"); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8bf36b7a80a..eeb2df342c2 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,6 +23,7 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; @@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service< query: string, limit: number, kind?: ProjectEntryKind, + imageOnly?: boolean, ) => Effect.Effect; readonly searchContents: ( input: Omit, @@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null { return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; } -function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { +function mapFileSearchResult( + result: SearchResult, + limit: number, + imageOnly = false, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toFileEntry(item); + return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; + }); return { - entries: result.items - .flatMap((item) => { - const entry = toFileEntry(item); - return entry ? [entry] : []; - }) - .slice(0, limit), - truncated: result.totalMatched > limit, + entries: entries.slice(0, limit), + truncated: entries.length > limit || result.totalMatched > result.items.length, }; } @@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit, kind) { - const pageSize = Math.max(1, limit + 1); - if (kind === "file") { + )(function* (query, limit, kind, imageOnly) { + const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); + if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - return mapFileSearchResult(result, limit); + return mapFileSearchResult(result, limit, imageOnly); } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 427dd41b005..11e153ace46 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1290,21 +1290,38 @@ const makeWsRpcLayer = ( clientDeviceType, people: mapPeople, }); - const shouldStopSessionAfterArchive = - normalizedCommand.type === "thread.archive" - ? yield* projectionSnapshotQuery - .getThreadShellById(normalizedCommand.threadId) - .pipe( - Effect.map( - Option.match({ - onNone: () => false, - onSome: (thread) => - thread.session !== null && thread.session.status !== "stopped", - }), - ), - Effect.orElseSucceed(() => false), - ) - : false; + // Archive and settle both mean "done with this thread", so a + // live provider session must not keep running background work + // (PR monitors, dev servers, subagent fleets) after either + // lands. The decider rejects settling a starting/running + // session, so for settle this only ever stops an idle one; a + // stopped session-set does not count as activity, so the stop + // cannot un-settle the thread it follows. + const parkingCommand = + normalizedCommand.type === "thread.archive" || + normalizedCommand.type === "thread.settle" + ? normalizedCommand + : undefined; + // Best-effort on purpose: the user's archive/settle must not + // fail because this cleanup read blipped, so a failed read + // logs and skips the stop instead of propagating. + const shouldStopSessionAfterCommand = parkingCommand + ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catchCause((cause) => + Effect.logWarning( + "failed to read thread session state before session-stop check", + { threadId: parkingCommand.threadId, cause }, + ).pipe(Effect.as(false)), + ), + ) + : false; // Unarchive restores a missing worktree from the retained // branch before the command commits; a failed restoration // leaves the thread archived instead of silently detaching it @@ -1325,37 +1342,50 @@ const makeWsRpcLayer = ( ), ) : yield* dispatchNormalizedCommand(normalizedCommand); - if (normalizedCommand.type === "thread.archive") { - if (shouldStopSessionAfterArchive) { + if (parkingCommand) { + const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-archive:${normalizedCommand.commandId}`, + `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, ), - threadId: normalizedCommand.threadId, + threadId: parkingCommand.threadId, createdAt: yield* nowIso, + // A settled thread can be re-engaged before this stop is + // decided; the decider then drops the stop instead of + // killing the new session. Archive stops stay + // unconditional: turn starts on archived threads are + // rejected, so there is no new session to protect. + ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider session during archive", { - threadId: normalizedCommand.threadId, + Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { + threadId: parkingCommand.threadId, cause, }), ), ); } - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); + // Terminals are user-opened panes, not thread background + // work: archive removes the thread from view so they close + // with it, but a settled thread stays reachable and may be + // un-settled, so its terminals stay up. + if (parkingCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: parkingCommand.threadId, + error: error.message, + }), + ), + ); + } } return result; }).pipe( @@ -2086,9 +2116,33 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { - if (input.resource._tag !== "workspace-file") { + if (input.resource._tag === "attachment") { return yield* issueAssetUrl({ resource: input.resource }); } + if (input.resource._tag === "project-favicon") { + const project = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(input.resource.cwd) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + ...(project.value.faviconPath + ? { projectFaviconPath: project.value.faviconPath } + : {}), + }); + } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.resource.threadId) .pipe( diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 701af3a79fc..f8c0b5ae75f 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = | { readonly _tag: "Loading" } | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; + | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; export function useAssetUrlState( environmentId: EnvironmentId, @@ -32,7 +32,13 @@ export function useAssetUrlState( return { _tag: "Loading" }; } const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return url === null + ? { _tag: "Failure" } + : { + _tag: "Success", + url, + ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + }; } export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6cfe9732f5e..747219fbaee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6489,6 +6489,11 @@ function ChatViewContent(props: ChatViewProps) { rightPanelAvailable={activeProject !== null} rightPanelOpen={rightPanelOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + // Suppressed while the Agents surface is visible: the roster itself is + // on screen, so the toggle badge would be pointing at nothing. + liveAgentCount={ + rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> @@ -6619,6 +6624,7 @@ function ChatViewContent(props: ChatViewProps) { changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} + activeProjectFaviconPath={activeProject?.faviconPath ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ @@ -7034,6 +7040,7 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -7062,6 +7069,7 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1ec22a09d8f..ddd3d2b88ff 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -162,6 +162,7 @@ function projectFavicon(project: Project) { ); @@ -1522,6 +1523,17 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + icon: , + run: async () => { + await navigate({ to: "/settings/projects" }); + }, + }); + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f4c1a0d0380..2c96da0f772 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -79,7 +79,6 @@ import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -315,7 +314,8 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); + const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index a7d58f32670..4bb5159cc44 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2521,7 +2521,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 4ee235bb2f3..4e7e2756b22 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", + lastResource: null as unknown, })); const hooks = vi.hoisted(() => { @@ -52,6 +53,10 @@ vi.mock("react", async (importOriginal) => { vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); const assetUrlMocks = vi.hoisted(() => ({ + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.lastResource = resource; + return { _tag: "Success", url: testState.faviconUrl }; + }, useAssetUrl: vi.fn(() => testState.faviconUrl), })); @@ -130,4 +135,18 @@ describe("ProjectFavicon", () => { expect(afterDisplayedError[0]).not.toBeNull(); expect(afterDisplayedError[1]).toBeNull(); }); + + it("requests a saved favicon path when one is set", () => { + ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + faviconPath: "brand/icon.svg", + }); + + expect(testState.lastResource).toEqual({ + _tag: "project-favicon", + cwd: "/workspace-test", + path: "brand/icon.svg", + }); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index de26ae36778..20b4da66319 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -6,7 +6,7 @@ import { import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; -import { useAssetUrl } from "../assets/assetUrls"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); @@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + faviconPath?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAssetUrl(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - }); + const state = useProjectFaviconAsset(input); + const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { @@ -40,6 +39,18 @@ export function ProjectFavicon(input: { ); } +export function useProjectFaviconAsset(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +}) { + return useAssetUrlState(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + ...(input.faviconPath ? { path: input.faviconPath } : {}), + }); +} + export function ProjectFaviconFallback({ className, icon: Icon = FolderIcon, diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 069885f22e0..93b6159cfbe 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,62 +1,32 @@ import type { ProjectScript, - ProjectScriptIcon, ResolvedKeybindingsConfig, T3ProjectFileScript, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - BugIcon, - ChevronDownIcon, - DownloadIcon, - FlaskConicalIcon, - HammerIcon, - ListChecksIcon, - PlayIcon, - PlusIcon, - SettingsIcon, - WrenchIcon, -} from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; -import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, - nextProjectScriptId, primaryProjectScript, projectScriptMenuLabel, } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { Group, GroupSeparator } from "./ui/group"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; import { Menu, MenuGroup, @@ -67,50 +37,9 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Switch } from "./ui/switch"; -import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ - { id: "play", label: "Play" }, - { id: "test", label: "Test" }, - { id: "lint", label: "Lint" }, - { id: "configure", label: "Configure" }, - { id: "build", label: "Build" }, - { id: "debug", label: "Debug" }, -]; - -function ScriptIcon({ - icon, - className = "size-3.5", -}: { - icon: ProjectScriptIcon; - className?: string; -}) { - if (icon === "test") return ; - if (icon === "lint") return ; - if (icon === "configure") return ; - if (icon === "build") return ; - if (icon === "debug") return ; - return ; -} - -export interface NewProjectScriptInput { - name: string; - command: string; - icon: ProjectScriptIcon; - runOnWorktreeCreate: boolean; - runOnWorktreeRemove: boolean; - runOnPrMerged: boolean; - keybinding: string | null; - /** Optional URL to open in the in-app preview when this script runs. */ - previewUrl: string | null; - /** When true, automatically open the preview panel pointed at `previewUrl`. */ - autoOpenPreview: boolean; -} - -export type ProjectScriptActionResult = AtomCommandResult; +export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -139,25 +68,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, }); - const [dialogOpen, setDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [runOnWorktreeRemove, setRunOnWorktreeRemove] = useState(false); - const [runOnPrMerged, setRunOnPrMerged] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [validationError, setValidationError] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -178,118 +93,23 @@ export default function ProjectScriptsControl({ ), [fileScripts, scripts], ); - const isEditing = editingScriptId !== null; const dropdownItemClassName = "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromKeyboardEvent(event, navigator.platform); - if (!next) return; - setKeybinding(next); - }; - - const submitAddScript = async (event: FormEvent) => { - event.preventDefault(); - const trimmedName = name.trim(); - const trimmedCommand = command.trim(); - if (trimmedName.length === 0) { - setValidationError("Name is required."); - return; - } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); - return; - } - - setValidationError(null); - let payload: NewProjectScriptInput; - try { - const scriptIdForValidation = - editingScriptId ?? - nextProjectScriptId( - trimmedName, - scripts.map((script) => script.id), - ); - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: commandForProjectScript(scriptIdForValidation), - }); - const trimmedPreviewUrl = previewUrl.trim(); - payload = { - name: trimmedName, - command: trimmedCommand, - icon, - runOnWorktreeCreate, - runOnWorktreeRemove, - runOnPrMerged, - keybinding: keybindingRule?.key ?? null, - previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, - autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, - } satisfies NewProjectScriptInput; - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - return; - } - - const result = editingScriptId - ? await onUpdateScript(editingScriptId, payload) - : await onAddScript(payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - } - return; - } - setDialogOpen(false); - setIconPickerOpen(false); - }; - const openAddDialog = () => { - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setIconPickerOpen(false); - setRunOnWorktreeCreate(false); - setRunOnWorktreeRemove(false); - setRunOnPrMerged(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; const openEditDialog = (script: ProjectScript) => { setActionsMenuOpen({ scripts: false, imports: false }); - setEditingScriptId(script.id); - setName(script.name); - setCommand(script.command); - setIcon(script.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(script.runOnWorktreeCreate); - setRunOnWorktreeRemove(script.runOnWorktreeRemove === true); - setRunOnPrMerged(script.runOnPrMerged === true); - setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); - setPreviewUrl(script.previewUrl ?? ""); - setAutoOpenPreview(script.autoOpenPreview ?? false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -308,17 +128,11 @@ export default function ProjectScriptsControl({ // Surface the failure through the regular add dialog, prefilled so the // user can adjust and retry. const error = squashAtomCommandFailure(result); - setEditingScriptId(null); - setName(payload.name); - setCommand(payload.command); - setIcon(payload.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(payload.runOnWorktreeCreate); - setKeybinding(""); - setPreviewUrl(payload.previewUrl ?? ""); - setAutoOpenPreview(payload.autoOpenPreview); - setValidationError(error instanceof Error ? error.message : "Failed to import action."); - setDialogOpen(true); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -477,198 +291,13 @@ export default function ProjectScriptsControl({ )} - { - setDialogOpen(open); - if (!open) { - setIconPickerOpen(false); - } - }} - onOpenChangeComplete={(open) => { - if (open) return; - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - }} - open={dialogOpen} - > - - - {isEditing ? "Edit Action" : "Add Action"} - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - -
-
- -
- - - } - > - - - -
- {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
-
-
- setName(event.target.value)} - /> -
-
-
- - -

- Press a shortcut. Use Backspace to clear. -

-
-
- -