authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-19 11:57:58+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-19 11:57:58+02:00
log4f11740510d9040475936a24b08f60852bf5fdc4
tree80b8970a50da6c1692e9873128a29071d7b37693
parent1735e27d9e5106bbb35d5b1dd10363604a54b69e
signature Signed by PGP key B5690EEEBB952194

Add isolated app testing workflow (#4121)

Co-authored-by: codex <codex@users.noreply.github.com>

15 files changed, 711 insertions(+), 87 deletions(-)

.agents/skills/test-t3-app/SKILL.md created+69
...@@ -0,0 +1,69 @@
1---
2name: test-t3-app
3description: Launch and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, test UI behavior in a browser, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite.
4---
5
6# Test T3 App
7
8## Start an isolated web environment
9
101. Run commands from the repository root.
112. Choose a base directory that belongs only to the current worktree or test:
12 - Use the repository's ignored `.t3` directory for reusable worktree-local state.
13 - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
143. Start the full web stack with `vp run dev --home-dir <base-dir>`.
154. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.
16
17Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.
18
19The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.
20
21## Authenticate the browser on the first navigation
22
231. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`.
242. Use the controlled in-app browser or browser-automation surface available to the agent. Do not use a system-browser launch command during automated testing.
253. Open that complete URL exactly once as the controlled browser's first navigation. Preserve the fragment and token verbatim.
264. Wait for the pairing exchange and redirect to finish before navigating elsewhere.
275. Continue in the same browser context so its stored bearer session remains available.
28
29Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it.
30
31## Recover a consumed or expired pairing token
32
33Create another token against the same database and web URL as the running dev server:
34
35```bash
36T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \
37 --base-dir <base-dir> \
38 --dev-url <web-url> \
39 --base-url <web-url> \
40 --ttl 15m \
41 --label agent-ui-test
42```
43
44Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port.
45
46Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.
47
48## Inspect or seed SQLite state
49
50Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database.
51
52- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks.
53- Stop the dev server before using `node apps/server/scripts/t3-sqlite-state.ts exec`, then restart it with the same base directory.
54- Seed projection tables only for disposable UI fixtures. Use application commands and APIs when testing business behavior or projection correctness.
55- Use the auth CLI, not direct `auth_*` table edits, for pairing and sessions.
56
57The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation.
58
59## Finish the test
60
61Stop the dev process with its terminal interrupt. Preserve the isolated base directory when it contains useful reproduction evidence; otherwise remove only a path that was created for this test after resolving and verifying the exact target. A fresh isolated base directory is the safest reset when authentication, migrations, or fixture state becomes ambiguous.
62
63## Troubleshoot predictably
64
65- If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL.
66- If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`.
67- If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL.
68- If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything.
69- If ports move because another instance is running, trust the current dev-runner output rather than assuming ports `13773` and `5733`.
.agents/skills/test-t3-app/agents/openai.yaml created+4
...@@ -0,0 +1,4 @@
1interface:
2 display_name: "T3 App Testing"
3 short_description: "Launch and seed isolated T3 test environments"
4 default_prompt: "Use $test-t3-app to launch an isolated T3 development environment and test it in the browser."
.agents/skills/test-t3-app/references/sqlite-fixtures.md created+58
...@@ -0,0 +1,58 @@
1# SQLite fixtures
2
3Load this reference only when inspecting or seeding local T3 state directly.
4
5## Select the correct database
6
7When `--base-dir` or `--home-dir` is explicit, runtime state lives under `<base-dir>/userdata` and the database path is `<base-dir>/userdata/state.sqlite`. The `<base-dir>/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state.
8
9Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection.
10
11## Use the helper
12
13List tables:
14
15```bash
16node apps/server/scripts/t3-sqlite-state.ts query \
17 --base-dir <base-dir> \
18 --sql "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"
19```
20
21Inspect current columns before writing a fixture:
22
23```bash
24node apps/server/scripts/t3-sqlite-state.ts query \
25 --base-dir <base-dir> \
26 --sql "PRAGMA table_info(projection_threads)"
27```
28
29Apply a SQL fixture from a file:
30
31```bash
32node apps/server/scripts/t3-sqlite-state.ts exec \
33 --base-dir <base-dir> \
34 --file /tmp/t3-seed.sql
35```
36
37Use one statement per invocation for both `query` and `exec`; the helper wraps writes in a transaction and prints the backup path after a successful mutation. Use a single insert with multiple value rows when a fixture needs several records.
38
39## Seed projection data carefully
40
41The web UI primarily reads these projection tables:
42
43- `projection_projects`
44- `projection_threads`
45- `projection_thread_messages`
46- `projection_thread_activities`
47- `projection_thread_sessions`
48- `projection_turns`
49- `projection_pending_approvals`
50- `projection_thread_proposed_plans`
51
52Inspect `PRAGMA table_info(<table>)` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent.
53
54For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current.
55
56Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, activity lists, and similar UI fixtures. They do not create a coherent orchestration event history. Do not modify `orchestration_events` unless the test specifically exercises projector internals, and do not use direct projection writes to claim backend business behavior works.
57
58Use the app's commands or APIs for behavior tests. Use `node apps/server/src/bin.ts auth ...` for auth state rather than editing `auth_pairing_links` or `auth_sessions`.
AGENTS.md+9-24
...@@ -2,27 +2,14 @@...@@ -2,27 +2,14 @@
22
3## Task Completion Requirements3## Task Completion Requirements
44
5- `vp check` and `vp run typecheck` must pass before considering tasks completed.5- Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step.
6 - If changing native mobile code, `vp run lint:mobile` must also pass.6 - Use `vp test run <test-files>` for focused built-in Vite+ tests. Use `vp run test` only when the affected package specifically requires its `test` script.
7- Use `vp test` for the built-in Vite+ test command and `vp run test` when you specifically need the `test` package script.7 - Backend changes must include and run focused tests for the changed behavior.
88 - Run targeted formatting, lint, and type checks for the affected scope when available.
9## Project Snapshot9- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite.
1010- After frontend feature development or any user-visible frontend behavior change, the primary agent must use the `test-t3-app` skill once after integrating the work. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser.
11T3 Code is a minimal web GUI for using coding agents like Codex and Claude.11 - Subagents must not independently launch dev servers or repeat integrated browser verification unless their delegated task explicitly requires it.
1212 - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete.
13This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
14
15## Core Priorities
16
171. Performance first.
182. Reliability first.
193. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
20
21If a tradeoff is required, choose correctness and robustness over short-term convenience.
22
23## Maintainability
24
25Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.
2613
27## Package Roles14## Package Roles
2815
...@@ -35,7 +22,6 @@ Long term maintainability is a core priority. If you add new functionality, firs...@@ -35,7 +22,6 @@ Long term maintainability is a core priority. If you add new functionality, firs
35## Reference Repos22## Reference Repos
3623
37- Open-source Codex repo: https://github.com/openai/codex24- Open-source Codex repo: https://github.com/openai/codex
38- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor
3925
40Use these as implementation references when designing protocol handling, UX flows, and operational safeguards.26Use these as implementation references when designing protocol handling, UX flows, and operational safeguards.
4127
...@@ -47,8 +33,7 @@ agents....@@ -47,8 +33,7 @@ agents.
47- Prefer examples and patterns from the vendored source code over generated guesses or web search results.33- Prefer examples and patterns from the vendored source code over generated guesses or web search results.
48- Do not edit files under `.repos/` unless explicitly asked.34- Do not edit files under `.repos/` unless explicitly asked.
49- Do not import from `.repos/`; application code must continue importing from normal package dependencies.35- Do not import from `.repos/`; application code must continue importing from normal package dependencies.
50- Manage vendored subtrees with `bun run sync:repos`; use `bun run sync:repos --repo <id>` to sync one36- Manage vendored subtrees with `vpr sync:repos`; use `vpr sync:repos --repo <id>` to sync one configured repository.
51 configured repository.
52- When updating a dependency with a configured vendored subtree, sync that subtree in the same change so37- When updating a dependency with a configured vendored subtree, sync that subtree in the same change so
53 `.repos/` matches the installed dependency version.38 `.repos/` matches the installed dependency version.
54- When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for39- When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for
apps/desktop/src/app/DesktopEnvironment.test.ts+24-8
...@@ -53,13 +53,16 @@ describe("DesktopEnvironment", () => {...@@ -53,13 +53,16 @@ describe("DesktopEnvironment", () => {
53 assert.equal(environment.isDevelopment, true);53 assert.equal(environment.isDevelopment, true);
54 assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support");54 assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support");
55 assert.equal(environment.baseDir, "/tmp/t3");55 assert.equal(environment.baseDir, "/tmp/t3");
56 assert.equal(environment.stateDir, "/tmp/t3/dev");56 assert.equal(environment.stateDir, "/tmp/t3/userdata");
57 assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json");57 assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json");
58 assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json");58 assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json");
59 assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");59 assert.equal(
60 assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");60 environment.savedEnvironmentRegistryPath,
61 assert.equal(environment.logDir, "/tmp/t3/dev/logs");61 "/tmp/t3/userdata/saved-environments.json",
62 assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts");62 );
63 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
64 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
65 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
63 assert.equal(environment.rootDir, "/repo");66 assert.equal(environment.rootDir, "/repo");
64 assert.equal(environment.appRoot, "/repo");67 assert.equal(environment.appRoot, "/repo");
65 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");68 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
...@@ -78,7 +81,7 @@ describe("DesktopEnvironment", () => {...@@ -78,7 +81,7 @@ describe("DesktopEnvironment", () => {
78 }),81 }),
79 );82 );
8083
81 it.effect("derives production state paths under userdata", () =>84 it.effect("stores production state under userdata in an explicit home", () =>
82 Effect.gen(function* () {85 Effect.gen(function* () {
83 const environment = yield* makeEnvironment(86 const environment = yield* makeEnvironment(
84 {},87 {},
...@@ -95,6 +98,19 @@ describe("DesktopEnvironment", () => {...@@ -95,6 +98,19 @@ describe("DesktopEnvironment", () => {
95 }),98 }),
96 );99 );
97100
101 it.effect("keeps implicit development state separate from production state", () =>
102 Effect.gen(function* () {
103 const development = yield* makeEnvironment(
104 {},
105 { VITE_DEV_SERVER_URL: "http://localhost:5173" },
106 );
107 const production = yield* makeEnvironment();
108
109 assert.equal(development.stateDir, "/Users/alice/.t3/dev");
110 assert.equal(production.stateDir, "/Users/alice/.t3/userdata");
111 }),
112 );
113
98 it.effect("uses a configured app user model id override", () =>114 it.effect("uses a configured app user model id override", () =>
99 Effect.gen(function* () {115 Effect.gen(function* () {
100 const environment = yield* makeEnvironment(116 const environment = yield* makeEnvironment(
apps/desktop/src/app/DesktopEnvironment.ts+6-2
...@@ -147,7 +147,8 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -147,7 +147,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
147 : input.platform === "darwin"147 : input.platform === "darwin"
148 ? path.join(homeDirectory, "Library", "Application Support")148 ? path.join(homeDirectory, "Library", "Application Support")
149 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));149 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
150 const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3"));150 const configuredBaseDir = config.t3Home;
151 const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3"));
151 const rootDir = path.resolve(input.dirname, "../../..");152 const rootDir = path.resolve(input.dirname, "../../..");
152 const appRoot = input.isPackaged ? input.appPath : rootDir;153 const appRoot = input.isPackaged ? input.appPath : rootDir;
153 const branding = resolveDesktopAppBranding({154 const branding = resolveDesktopAppBranding({
...@@ -155,7 +156,10 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -155,7 +156,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
155 appVersion: input.appVersion,156 appVersion: input.appVersion,
156 });157 });
157 const displayName = branding.displayName;158 const displayName = branding.displayName;
158 const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata");159 const stateDir = path.join(
160 baseDir,
161 isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata",
162 );
159 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";163 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
160 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";164 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
161 const resourcesPath = input.resourcesPath;165 const resourcesPath = input.resourcesPath;
apps/server/scripts/t3-sqlite-state.test.ts created+119
...@@ -0,0 +1,119 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as SqlClient from "effect/unstable/sql/SqlClient";
7
8import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
9import { runSqliteState } from "./t3-sqlite-state.ts";
10
11const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (
12 baseDir: string,
13) {
14 const fs = yield* FileSystem.FileSystem;
15 const path = yield* Path.Path;
16 const stateDir = path.join(baseDir, "userdata");
17 const databasePath = path.join(stateDir, "state.sqlite");
18 yield* fs.makeDirectory(stateDir, { recursive: true });
19 yield* Effect.gen(function* () {
20 const sql = yield* SqlClient.SqlClient;
21 yield* sql`CREATE TABLE fixtures (id INTEGER PRIMARY KEY, label TEXT NOT NULL)`;
22 yield* sql`INSERT INTO fixtures (id, label) VALUES (1, 'existing')`;
23 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath })));
24});
25
26it.layer(NodeServices.layer)("t3-sqlite-state", (it) => {
27 it.effect("reports each invalid SQL source with a specific error", () =>
28 Effect.gen(function* () {
29 const fs = yield* FileSystem.FileSystem;
30 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-input-" });
31
32 const multipleSources = yield* runSqliteState({
33 operation: "query",
34 baseDir,
35 sql: "SELECT 1",
36 file: "fixture.sql",
37 }).pipe(Effect.flip);
38 assert.equal(multipleSources._tag, "SqliteStateMultipleSqlSourcesError");
39 assert.equal(multipleSources.message, "Provide only one of --sql or --file.");
40
41 const missingSource = yield* runSqliteState({ operation: "query", baseDir }).pipe(
42 Effect.flip,
43 );
44 assert.equal(missingSource._tag, "SqliteStateMissingSqlSourceError");
45 assert.equal(missingSource.message, "Provide one of --sql or --file.");
46
47 const emptySql = yield* runSqliteState({
48 operation: "query",
49 baseDir,
50 sql: " ",
51 }).pipe(Effect.flip);
52 assert.equal(emptySql._tag, "SqliteStateEmptySqlError");
53 assert.equal(emptySql.message, "SQL input is empty.");
54 }),
55 );
56
57 it.effect("queries an isolated database through Effect SQL", () =>
58 Effect.gen(function* () {
59 const fs = yield* FileSystem.FileSystem;
60 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-query-" });
61 yield* createFixtureDatabase(baseDir);
62
63 const result = yield* runSqliteState({
64 operation: "query",
65 baseDir,
66 sql: "SELECT id, label FROM fixtures",
67 });
68
69 assert.equal(result.operation, "query");
70 if (result.operation === "query") {
71 assert.deepStrictEqual(result.rows, [{ id: 1, label: "existing" }]);
72 }
73 }),
74 );
75
76 it.effect("backs up isolated state before writes and refuses the shared home", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-exec-" });
81 yield* createFixtureDatabase(baseDir);
82
83 const mutation = yield* runSqliteState({
84 operation: "exec",
85 baseDir,
86 sql: "INSERT INTO fixtures (id, label) VALUES (2, 'seeded')",
87 });
88 assert.equal(mutation.operation, "exec");
89 if (mutation.operation === "exec") {
90 assert.equal((yield* fs.stat(mutation.backup)).mode & 0o777, 0o600);
91 }
92
93 const error = yield* runSqliteState(
94 {
95 operation: "exec",
96 baseDir,
97 sql: "DELETE FROM fixtures",
98 },
99 { sharedHome: baseDir },
100 ).pipe(Effect.flip);
101 assert.equal(error._tag, "SqliteStateSharedHomeMutationError");
102
103 const aliasParent = yield* fs.makeTempDirectoryScoped({
104 prefix: "t3-sqlite-state-alias-",
105 });
106 const aliasBaseDir = path.join(aliasParent, "shared-home-alias");
107 yield* fs.symlink(baseDir, aliasBaseDir);
108 const aliasError = yield* runSqliteState(
109 {
110 operation: "exec",
111 baseDir: aliasBaseDir,
112 sql: "DELETE FROM fixtures",
113 },
114 { sharedHome: baseDir },
115 ).pipe(Effect.flip);
116 assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError");
117 }),
118 );
119});
apps/server/scripts/t3-sqlite-state.ts created+285
...@@ -0,0 +1,285 @@
1#!/usr/bin/env node
2
3// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
4import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as NodeOS from "node:os";
7import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
8import * as Console from "effect/Console";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";
15import * as SqlClient from "effect/unstable/sql/SqlClient";
16import { Argument, Command, Flag } from "effect/unstable/cli";
17
18import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
19
20export const SqliteStateOperation = Schema.Literals(["query", "exec"]);
21export type SqliteStateOperation = typeof SqliteStateOperation.Type;
22
23export class SqliteStateMultipleSqlSourcesError extends Schema.TaggedErrorClass<SqliteStateMultipleSqlSourcesError>()(
24 "SqliteStateMultipleSqlSourcesError",
25 {},
26) {
27 override get message(): string {
28 return "Provide only one of --sql or --file.";
29 }
30}
31
32export class SqliteStateMissingSqlSourceError extends Schema.TaggedErrorClass<SqliteStateMissingSqlSourceError>()(
33 "SqliteStateMissingSqlSourceError",
34 {},
35) {
36 override get message(): string {
37 return "Provide one of --sql or --file.";
38 }
39}
40
41export class SqliteStateEmptySqlError extends Schema.TaggedErrorClass<SqliteStateEmptySqlError>()(
42 "SqliteStateEmptySqlError",
43 {},
44) {
45 override get message(): string {
46 return "SQL input is empty.";
47 }
48}
49
50export class SqliteStateDatabaseMissingError extends Schema.TaggedErrorClass<SqliteStateDatabaseMissingError>()(
51 "SqliteStateDatabaseMissingError",
52 {
53 databasePath: Schema.String,
54 },
55) {
56 override get message(): string {
57 return `Database does not exist at '${this.databasePath}'. Start T3 once to run migrations.`;
58 }
59}
60
61export class SqliteStateSharedHomeMutationError extends Schema.TaggedErrorClass<SqliteStateSharedHomeMutationError>()(
62 "SqliteStateSharedHomeMutationError",
63 {},
64) {
65 override get message(): string {
66 return "Refusing to mutate the shared ~/.t3 database. Use an isolated --base-dir.";
67 }
68}
69
70export class SqliteStateSqlFileError extends Schema.TaggedErrorClass<SqliteStateSqlFileError>()(
71 "SqliteStateSqlFileError",
72 {
73 filePath: Schema.String,
74 cause: Schema.Defect(),
75 },
76) {
77 override get message(): string {
78 return `Failed to read SQL from '${this.filePath}'.`;
79 }
80}
81
82export class SqliteStateDatabaseError extends Schema.TaggedErrorClass<SqliteStateDatabaseError>()(
83 "SqliteStateDatabaseError",
84 {
85 operation: SqliteStateOperation,
86 databasePath: Schema.String,
87 cause: Schema.Defect(),
88 },
89) {
90 override get message(): string {
91 return `Failed to ${this.operation} SQLite database at '${this.databasePath}'.`;
92 }
93}
94
95const SqliteStateValue = Schema.Union([
96 Schema.Null,
97 Schema.String,
98 Schema.Number,
99 Schema.Array(Schema.Number),
100]);
101const SqliteStateRow = Schema.Record(Schema.String, SqliteStateValue);
102const SqliteStateQueryResult = Schema.Struct({
103 operation: Schema.Literal("query"),
104 database: Schema.String,
105 rows: Schema.Array(SqliteStateRow),
106});
107const SqliteStateExecResult = Schema.Struct({
108 operation: Schema.Literal("exec"),
109 database: Schema.String,
110 backup: Schema.String,
111});
112const SqliteStateResult = Schema.Union([SqliteStateQueryResult, SqliteStateExecResult]);
113const encodeSqliteStateResult = Schema.encodeEffect(fromJsonStringPretty(SqliteStateResult));
114
115export type SqliteStateResult = typeof SqliteStateResult.Type;
116
117type RawSqliteValue = null | string | number | bigint | Uint8Array;
118type RawSqliteRow = Readonly<Record<string, RawSqliteValue>>;
119
120export interface RunSqliteStateInput {
121 readonly operation: SqliteStateOperation;
122 readonly baseDir: string;
123 readonly sql?: string | undefined;
124 readonly file?: string | undefined;
125}
126
127export interface RunSqliteStateOptions {
128 readonly sharedHome?: string | undefined;
129}
130
131const resolveSqlSource = Effect.fn("resolveSqliteStateSqlSource")(function* (
132 sql: string | undefined,
133 file: string | undefined,
134) {
135 if (sql !== undefined && file !== undefined) {
136 return yield* new SqliteStateMultipleSqlSourcesError();
137 }
138 if (sql === undefined && file === undefined) {
139 return yield* new SqliteStateMissingSqlSourceError();
140 }
141
142 const fs = yield* FileSystem.FileSystem;
143 const path = yield* Path.Path;
144 let source: string;
145 if (sql !== undefined) {
146 source = sql;
147 } else {
148 const filePath = path.resolve(file as string);
149 source = yield* fs
150 .readFileString(filePath)
151 .pipe(Effect.mapError((cause) => new SqliteStateSqlFileError({ filePath, cause })));
152 }
153
154 const trimmed = source.trim();
155 if (trimmed.length === 0) {
156 return yield* new SqliteStateEmptySqlError();
157 }
158 return trimmed;
159});
160
161function normalizeSqliteValue(value: RawSqliteValue): typeof SqliteStateValue.Type {
162 if (typeof value === "bigint") {
163 const numericValue = Number(value);
164 return Number.isSafeInteger(numericValue) ? numericValue : value.toString();
165 }
166 if (value instanceof Uint8Array) {
167 return Array.from(value);
168 }
169 return value;
170}
171
172function normalizeSqliteRow(row: RawSqliteRow): typeof SqliteStateRow.Type {
173 return Object.fromEntries(
174 Object.entries(row).map(([key, value]) => [key, normalizeSqliteValue(value)]),
175 );
176}
177
178export const runSqliteState = Effect.fn("runSqliteState")(function* (
179 input: RunSqliteStateInput,
180 options: RunSqliteStateOptions = {},
181) {
182 const fs = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 const baseDir = path.resolve(input.baseDir);
185 const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
186 const databasePath = path.join(baseDir, "userdata", "state.sqlite");
187 const source = yield* resolveSqlSource(input.sql, input.file);
188
189 if (!(yield* fs.exists(databasePath))) {
190 return yield* new SqliteStateDatabaseMissingError({ databasePath });
191 }
192 if (input.operation === "exec") {
193 const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([
194 fs.realPath(baseDir),
195 fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
196 ]);
197 if (canonicalBaseDir === canonicalSharedHome) {
198 return yield* new SqliteStateSharedHomeMutationError();
199 }
200 }
201
202 const program = Effect.gen(function* () {
203 const sql = yield* SqlClient.SqlClient;
204 yield* sql.unsafe("PRAGMA busy_timeout = 5000").unprepared;
205
206 if (input.operation === "query") {
207 const rows = yield* sql.unsafe<RawSqliteRow>(source).unprepared.pipe(
208 Effect.provideService(SqlClient.SafeIntegers, true),
209 Effect.map((rows) => rows.map(normalizeSqliteRow)),
210 );
211 return {
212 operation: "query",
213 database: databasePath,
214 rows,
215 } as const;
216 }
217
218 const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-");
219 const backupPath = `${databasePath}.backup-${timestamp}`;
220 yield* sql`VACUUM INTO ${backupPath}`;
221 yield* fs.chmod(backupPath, 0o600);
222 yield* sql.withTransaction(sql.unsafe(source).unprepared);
223
224 return {
225 operation: "exec",
226 database: databasePath,
227 backup: backupPath,
228 } as const;
229 });
230
231 return yield* program.pipe(
232 Effect.provide(
233 NodeSqliteClient.layer({
234 filename: databasePath,
235 readonly: input.operation === "query",
236 }),
237 ),
238 Effect.mapError(
239 (cause) =>
240 new SqliteStateDatabaseError({
241 operation: input.operation,
242 databasePath,
243 cause,
244 }),
245 ),
246 );
247});
248
249export const t3SqliteStateCommand = Command.make(
250 "t3-sqlite-state",
251 {
252 operation: Argument.choice("operation", SqliteStateOperation.literals).pipe(
253 Argument.withDescription("Run a read-only query or a backed-up fixture mutation."),
254 ),
255 baseDir: Flag.string("base-dir").pipe(
256 Flag.withDescription("Explicit T3 base directory containing userdata/state.sqlite."),
257 ),
258 sql: Flag.string("sql").pipe(
259 Flag.optional,
260 Flag.withDescription("SQL source supplied directly on the command line."),
261 ),
262 file: Flag.string("file").pipe(
263 Flag.optional,
264 Flag.withDescription("Path to a SQL source file."),
265 ),
266 },
267 ({ operation, baseDir, sql, file }) =>
268 runSqliteState({
269 operation,
270 baseDir,
271 sql: Option.getOrUndefined(sql),
272 file: Option.getOrUndefined(file),
273 }).pipe(Effect.flatMap(encodeSqliteStateResult), Effect.flatMap(Console.log)),
274).pipe(
275 Command.withDescription(
276 "Inspect or seed an isolated T3 SQLite database with automatic backups for writes.",
277 ),
278);
279
280if (import.meta.main) {
281 Command.run(t3SqliteStateCommand, { version: "0.0.0" }).pipe(
282 Effect.provide(NodeServices.layer),
283 NodeRuntime.runMain,
284 );
285}
apps/server/src/cli/config.test.ts+23-6
...@@ -18,6 +18,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -18,6 +18,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
18import { deriveServerPaths } from "../config.ts";18import { deriveServerPaths } from "../config.ts";
19import { resolveServerConfig } from "./config.ts";19import { resolveServerConfig } from "./config.ts";
2020
21const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
22 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
23
21const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));24const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
2225
23const makeDesktopBootstrap = (26const makeDesktopBootstrap = (
...@@ -60,7 +63,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -60,7 +63,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
60 Effect.gen(function* () {63 Effect.gen(function* () {
61 const { join } = yield* Path.Path;64 const { join } = yield* Path.Path;
62 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-base");65 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-base");
63 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));66 const derivedPaths = yield* deriveExplicitServerPaths(
67 baseDir,
68 new URL("http://127.0.0.1:5173"),
69 );
64 const resolved = yield* resolveServerConfig(70 const resolved = yield* resolveServerConfig(
65 {71 {
66 mode: Option.none(),72 mode: Option.none(),
...@@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -119,6 +125,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
119 tailscaleServeEnabled: false,125 tailscaleServeEnabled: false,
120 tailscaleServePort: 443,126 tailscaleServePort: 443,
121 });127 });
128 assert.equal(resolved.stateDir, join(baseDir, "userdata"));
122 }),129 }),
123 );130 );
124131
...@@ -126,7 +133,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -126,7 +133,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
126 Effect.gen(function* () {133 Effect.gen(function* () {
127 const { join } = yield* Path.Path;134 const { join } = yield* Path.Path;
128 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-flags-base");135 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-flags-base");
129 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));136 const derivedPaths = yield* deriveExplicitServerPaths(
137 baseDir,
138 new URL("http://127.0.0.1:4173"),
139 );
130 const resolved = yield* resolveServerConfig(140 const resolved = yield* resolveServerConfig(
131 {141 {
132 mode: Option.some("web"),142 mode: Option.some("web"),
...@@ -185,6 +195,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -185,6 +195,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
185 tailscaleServeEnabled: true,195 tailscaleServeEnabled: true,
186 tailscaleServePort: 8443,196 tailscaleServePort: 8443,
187 });197 });
198 assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite"));
188 }),199 }),
189 );200 );
190201
...@@ -199,7 +210,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -199,7 +210,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
199 tailscaleServePort: 443,210 tailscaleServePort: 443,
200 }),211 }),
201 );212 );
202 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));213 const derivedPaths = yield* deriveExplicitServerPaths(
214 baseDir,
215 new URL("http://127.0.0.1:4173"),
216 );
203217
204 const resolved = yield* resolveServerConfig(218 const resolved = yield* resolveServerConfig(
205 {219 {
...@@ -396,7 +410,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -396,7 +410,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
396 tailscaleServePort: 443,410 tailscaleServePort: 443,
397 }),411 }),
398 );412 );
399 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));413 const derivedPaths = yield* deriveExplicitServerPaths(
414 baseDir,
415 new URL("http://127.0.0.1:4173"),
416 );
400417
401 const resolved = yield* resolveServerConfig(418 const resolved = yield* resolveServerConfig(
402 {419 {
...@@ -461,7 +478,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -461,7 +478,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
461 const fs = yield* FileSystem.FileSystem;478 const fs = yield* FileSystem.FileSystem;
462 const path = yield* Path.Path;479 const path = yield* Path.Path;
463 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });480 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" });
464 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);481 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
465 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });482 yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true });
466 yield* fs.writeFileString(483 yield* fs.writeFileString(
467 derivedPaths.settingsPath,484 derivedPaths.settingsPath,
...@@ -529,7 +546,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -529,7 +546,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
529 Effect.gen(function* () {546 Effect.gen(function* () {
530 const { join } = yield* Path.Path;547 const { join } = yield* Path.Path;
531 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-headless-base");548 const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-headless-base");
532 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);549 const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined);
533550
534 const resolved = yield* resolveServerConfig(551 const resolved = yield* resolveServerConfig(
535 {552 {
apps/server/src/cli/config.ts+11-7
...@@ -31,7 +31,9 @@ export const hostFlag = Flag.string("host").pipe(...@@ -31,7 +31,9 @@ export const hostFlag = Flag.string("host").pipe(
31 Flag.optional,31 Flag.optional,
32);32);
33export const baseDirFlag = Flag.string("base-dir").pipe(33export const baseDirFlag = Flag.string("base-dir").pipe(
34 Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."),34 Flag.withDescription(
35 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).",
36 ),
35 Flag.optional,37 Flag.optional,
36);38);
37export const devUrlFlag = Flag.string("dev-url").pipe(39export const devUrlFlag = Flag.string("dev-url").pipe(
...@@ -259,19 +261,21 @@ export const resolveServerConfig = (...@@ -259,19 +261,21 @@ export const resolveServerConfig = (
259 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),261 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
260 () => undefined,262 () => undefined,
261 );263 );
264 const explicitBaseDir = resolveOptionPrecedence(
265 normalizedFlags.baseDir,
266 Option.fromUndefinedOr(env.t3Home),
267 ).pipe(Option.filter((value) => value.trim().length > 0));
262 const baseDir = yield* resolveBaseDir(268 const baseDir = yield* resolveBaseDir(
263 Option.getOrUndefined(269 Option.getOrUndefined(
264 resolveOptionPrecedence(270 resolveOptionPrecedence(explicitBaseDir, Option.fromUndefinedOr(bootstrap?.t3Home)),
265 normalizedFlags.baseDir,
266 Option.fromUndefinedOr(env.t3Home),
267 Option.fromUndefinedOr(bootstrap?.t3Home),
268 ),
269 ),271 ),
270 );272 );
271 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());273 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
272 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));274 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
273 yield* fs.makeDirectory(cwd, { recursive: true });275 yield* fs.makeDirectory(cwd, { recursive: true });
274 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl);276 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
277 baseDirIsExplicit: Option.isSome(explicitBaseDir),
278 });
275 yield* ServerConfig.ensureServerDirectories(derivedPaths);279 yield* ServerConfig.ensureServerDirectories(derivedPaths);
276 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(280 const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings(
277 derivedPaths.settingsPath,281 derivedPaths.settingsPath,
apps/server/src/config.ts+9-1
...@@ -45,6 +45,10 @@ export interface ServerDerivedPaths {...@@ -45,6 +45,10 @@ export interface ServerDerivedPaths {
45 readonly secretsDir: string;45 readonly secretsDir: string;
46}46}
4747
48export interface DeriveServerPathsOptions {
49 readonly baseDirIsExplicit?: boolean;
50}
51
48/**52/**
49 * ServerConfig - Service tag for server runtime configuration.53 * ServerConfig - Service tag for server runtime configuration.
50 */54 */
...@@ -91,9 +95,13 @@ export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerCo...@@ -91,9 +95,13 @@ export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerCo
91export const deriveServerPaths = Effect.fn(function* (95export const deriveServerPaths = Effect.fn(function* (
92 baseDir: ServerConfig["Service"]["baseDir"],96 baseDir: ServerConfig["Service"]["baseDir"],
93 devUrl: ServerConfig["Service"]["devUrl"],97 devUrl: ServerConfig["Service"]["devUrl"],
98 options: DeriveServerPathsOptions = {},
94): Effect.fn.Return<ServerDerivedPaths, never, Path.Path> {99): Effect.fn.Return<ServerDerivedPaths, never, Path.Path> {
95 const { join } = yield* Path.Path;100 const { join } = yield* Path.Path;
96 const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata");101 const stateDir = join(
102 baseDir,
103 devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata",
104 );
97 const dbPath = join(stateDir, "state.sqlite");105 const dbPath = join(stateDir, "state.sqlite");
98 const attachmentsDir = join(stateDir, "attachments");106 const attachmentsDir = join(stateDir, "attachments");
99 const logsDir = join(stateDir, "logs");107 const logsDir = join(stateDir, "logs");
docs/operations/observability.md+17-9
...@@ -165,16 +165,24 @@ The backend reads observability config at process start. If you change OTLP env...@@ -165,16 +165,24 @@ The backend reads observability config at process start. If you change OTLP env
165165
166The trace file is the fastest way to inspect raw span data.166The trace file is the fastest way to inspect raw span data.
167167
168Resolve the production or explicitly configured trace file once. Runtime state lives under the
169base directory's `userdata` folder:
170
171```bash
172TRACE_FILE="${T3CODE_HOME:-$HOME/.t3}/userdata/logs/server.trace.ndjson"
173```
174
168Tail it:175Tail it:
169176
170```bash177```bash
171tail -f "$T3CODE_HOME/userdata/logs/server.trace.ndjson"178tail -f "$TRACE_FILE"
172```179```
173180
174In monorepo dev, use:181For an implicit monorepo dev server, use:
175182
176```bash183```bash
177tail -f ./dev/logs/server.trace.ndjson184TRACE_FILE="$HOME/.t3/dev/logs/server.trace.ndjson"
185tail -f "$TRACE_FILE"
178```186```
179187
180Show failed spans:188Show failed spans:
...@@ -185,7 +193,7 @@ jq -c 'select(.exit._tag != "Success") | {...@@ -185,7 +193,7 @@ jq -c 'select(.exit._tag != "Success") | {
185 durationMs,193 durationMs,
186 exit,194 exit,
187 attributes195 attributes
188}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"196}' "$TRACE_FILE"
189```197```
190198
191Show slow spans:199Show slow spans:
...@@ -196,7 +204,7 @@ jq -c 'select(.durationMs > 1000) | {...@@ -196,7 +204,7 @@ jq -c 'select(.durationMs > 1000) | {
196 durationMs,204 durationMs,
197 traceId,205 traceId,
198 spanId206 spanId
199}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"207}' "$TRACE_FILE"
200```208```
201209
202Inspect embedded log events:210Inspect embedded log events:
...@@ -213,7 +221,7 @@ jq -c 'select(any(.events[]?; .attributes["effect.logLevel"] != null)) | {...@@ -213,7 +221,7 @@ jq -c 'select(any(.events[]?; .attributes["effect.logLevel"] != null)) | {
213 level: .attributes["effect.logLevel"]221 level: .attributes["effect.logLevel"]
214 }222 }
215 ]223 ]
216}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"224}' "$TRACE_FILE"
217```225```
218226
219Follow one trace:227Follow one trace:
...@@ -224,7 +232,7 @@ jq -r 'select(.traceId == "TRACE_ID_HERE") | [...@@ -224,7 +232,7 @@ jq -r 'select(.traceId == "TRACE_ID_HERE") | [
224 .spanId,232 .spanId,
225 (.parentSpanId // "-"),233 (.parentSpanId // "-"),
226 .durationMs234 .durationMs
227] | @tsv' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"235] | @tsv' "$TRACE_FILE"
228```236```
229237
230Filter orchestration commands:238Filter orchestration commands:
...@@ -235,7 +243,7 @@ jq -c 'select(.attributes["orchestration.command_type"] != null) | {...@@ -235,7 +243,7 @@ jq -c 'select(.attributes["orchestration.command_type"] != null) | {
235 durationMs,243 durationMs,
236 commandType: .attributes["orchestration.command_type"],244 commandType: .attributes["orchestration.command_type"],
237 aggregateKind: .attributes["orchestration.aggregate_kind"]245 aggregateKind: .attributes["orchestration.aggregate_kind"]
238}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"246}' "$TRACE_FILE"
239```247```
240248
241Filter git activity:249Filter git activity:
...@@ -250,7 +258,7 @@ jq -c 'select(.attributes["git.operation"] != null) | {...@@ -250,7 +258,7 @@ jq -c 'select(.attributes["git.operation"] != null) | {
250 .events[]258 .events[]
251 | select(.name == "git.hook.started" or .name == "git.hook.finished")259 | select(.name == "git.hook.started" or .name == "git.hook.finished")
252 ]260 ]
253}' "$T3CODE_HOME/userdata/logs/server.trace.ndjson"261}' "$TRACE_FILE"
254```262```
255263
256### Use Tempo When You Need A Real Trace Viewer264### Use Tempo When You Need A Real Trace Viewer
docs/reference/scripts.md+5-3
...@@ -3,13 +3,15 @@...@@ -3,13 +3,15 @@
3- `vp run dev` — Starts contracts, server, and web in watch mode.3- `vp run dev` — Starts contracts, server, and web in watch mode.
4- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`.4- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`.
5- `vp run dev:web` — Starts just the Vite dev server for the web app.5- `vp run dev:web` — Starts just the Vite dev server for the web app.
6- Dev commands default `T3CODE_HOME` to `~/.t3` — the same shared home the desktop/production app uses. Override with `--home-dir` (see below) to keep dev state separate.6- Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared data.
7- Override server CLI-equivalent flags from root dev commands with `--`, for example:7- Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional.
8 `vp run dev -- --home-dir ~/.t3-2`8- Pass dev-runner flags directly after the root task name, for example:
9 `vp run dev --home-dir /tmp/t3code-dev`
9- `vp run start` — Runs the production server (serves built web app as static files).10- `vp run start` — Runs the production server (serves built web app as static files).
10- `vp run build` — Builds contracts, web app, and server.11- `vp run build` — Builds contracts, web app, and server.
11- `vp run typecheck` — Strict TypeScript checks for all packages.12- `vp run typecheck` — Strict TypeScript checks for all packages.
12- `vp run test` — Runs workspace tests.13- `vp run test` — Runs workspace tests.
14- `node apps/server/scripts/t3-sqlite-state.ts <query|exec> --base-dir <path> ...` — Inspects or seeds an isolated T3 SQLite database; writes create a private backup first.
13- `vp run dist:desktop:artifact -- --platform <mac|linux|win> --target <target> --arch <arch>` — Builds a desktop artifact for a specific platform/target/arch.15- `vp run dist:desktop:artifact -- --platform <mac|linux|win> --target <target> --arch <arch>` — Builds a desktop artifact for a specific platform/target/arch.
14- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.16- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
15- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.17- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
scripts/dev-runner.test.ts+51-12
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeOS from "node:os";
3import * as NetService from "@t3tools/shared/Net";2import * as NetService from "@t3tools/shared/Net";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import { assert, describe, it } from "@effect/vitest";4import { assert, describe, it } from "@effect/vitest";
...@@ -52,7 +51,7 @@ function mockProcess(exit: number | PlatformError.PlatformError) {...@@ -52,7 +51,7 @@ function mockProcess(exit: number | PlatformError.PlatformError) {
52const devServerInput = {51const devServerInput = {
53 mode: "dev:server",52 mode: "dev:server",
54 t3Home: "/tmp/t3code-dev-runner",53 t3Home: "/tmp/t3code-dev-runner",
55 noBrowser: undefined,54 browser: undefined,
56 autoBootstrapProjectFromCwd: undefined,55 autoBootstrapProjectFromCwd: undefined,
57 logWebSocketEvents: undefined,56 logWebSocketEvents: undefined,
58 host: undefined,57 host: undefined,
...@@ -127,16 +126,56 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -127,16 +126,56 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
127 });126 });
128127
129 describe("createDevRunnerEnv", () => {128 describe("createDevRunnerEnv", () => {
130 it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () =>129 it.effect("leaves the shared home implicit and disables browser auto-open", () =>
130 Effect.gen(function* () {
131 const env = yield* createDevRunnerEnv({
132 mode: "dev",
133 baseEnv: {},
134 serverOffset: 0,
135 webOffset: 0,
136 t3Home: undefined,
137 browser: undefined,
138 autoBootstrapProjectFromCwd: undefined,
139 logWebSocketEvents: undefined,
140 host: undefined,
141 port: undefined,
142 devUrl: undefined,
143 });
144
145 assert.equal(env.T3CODE_HOME, undefined);
146 assert.equal(env.T3CODE_NO_BROWSER, "1");
147 }),
148 );
149
150 it.effect("allows browser auto-open to be explicitly enabled", () =>
131 Effect.gen(function* () {151 Effect.gen(function* () {
132 const path = yield* Path.Path;
133 const env = yield* createDevRunnerEnv({152 const env = yield* createDevRunnerEnv({
134 mode: "dev",153 mode: "dev",
135 baseEnv: {},154 baseEnv: {},
136 serverOffset: 0,155 serverOffset: 0,
137 webOffset: 0,156 webOffset: 0,
138 t3Home: undefined,157 t3Home: undefined,
139 noBrowser: undefined,158 browser: true,
159 autoBootstrapProjectFromCwd: undefined,
160 logWebSocketEvents: undefined,
161 host: undefined,
162 port: undefined,
163 devUrl: undefined,
164 });
165
166 assert.equal(env.T3CODE_NO_BROWSER, "0");
167 }),
168 );
169
170 it.effect("requires the browser flag even when the environment enables auto-open", () =>
171 Effect.gen(function* () {
172 const env = yield* createDevRunnerEnv({
173 mode: "dev",
174 baseEnv: { T3CODE_NO_BROWSER: "0" },
175 serverOffset: 0,
176 webOffset: 0,
177 t3Home: undefined,
178 browser: false,
140 autoBootstrapProjectFromCwd: undefined,179 autoBootstrapProjectFromCwd: undefined,
141 logWebSocketEvents: undefined,180 logWebSocketEvents: undefined,
142 host: undefined,181 host: undefined,
...@@ -144,7 +183,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -144,7 +183,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
144 devUrl: undefined,183 devUrl: undefined,
145 });184 });
146185
147 assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".t3"));186 assert.equal(env.T3CODE_NO_BROWSER, "1");
148 }),187 }),
149 );188 );
150189
...@@ -157,7 +196,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -157,7 +196,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
157 serverOffset: 0,196 serverOffset: 0,
158 webOffset: 0,197 webOffset: 0,
159 t3Home: "/tmp/custom-t3",198 t3Home: "/tmp/custom-t3",
160 noBrowser: true,199 browser: false,
161 autoBootstrapProjectFromCwd: false,200 autoBootstrapProjectFromCwd: false,
162 logWebSocketEvents: true,201 logWebSocketEvents: true,
163 host: "0.0.0.0",202 host: "0.0.0.0",
...@@ -187,7 +226,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -187,7 +226,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
187 serverOffset: 0,226 serverOffset: 0,
188 webOffset: 0,227 webOffset: 0,
189 t3Home: undefined,228 t3Home: undefined,
190 noBrowser: undefined,229 browser: undefined,
191 autoBootstrapProjectFromCwd: undefined,230 autoBootstrapProjectFromCwd: undefined,
192 logWebSocketEvents: undefined,231 logWebSocketEvents: undefined,
193 host: undefined,232 host: undefined,
...@@ -210,7 +249,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -210,7 +249,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
210 serverOffset: 0,249 serverOffset: 0,
211 webOffset: 0,250 webOffset: 0,
212 t3Home: undefined,251 t3Home: undefined,
213 noBrowser: undefined,252 browser: undefined,
214 autoBootstrapProjectFromCwd: undefined,253 autoBootstrapProjectFromCwd: undefined,
215 logWebSocketEvents: false,254 logWebSocketEvents: false,
216 host: undefined,255 host: undefined,
...@@ -231,7 +270,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -231,7 +270,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
231 serverOffset: 0,270 serverOffset: 0,
232 webOffset: 0,271 webOffset: 0,
233 t3Home: "/tmp/my-t3",272 t3Home: "/tmp/my-t3",
234 noBrowser: undefined,273 browser: undefined,
235 autoBootstrapProjectFromCwd: undefined,274 autoBootstrapProjectFromCwd: undefined,
236 logWebSocketEvents: undefined,275 logWebSocketEvents: undefined,
237 host: undefined,276 host: undefined,
...@@ -259,7 +298,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -259,7 +298,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
259 serverOffset: 0,298 serverOffset: 0,
260 webOffset: 0,299 webOffset: 0,
261 t3Home: "/tmp/my-t3",300 t3Home: "/tmp/my-t3",
262 noBrowser: true,301 browser: true,
263 autoBootstrapProjectFromCwd: undefined,302 autoBootstrapProjectFromCwd: undefined,
264 logWebSocketEvents: undefined,303 logWebSocketEvents: undefined,
265 host: "127.0.0.1",304 host: "127.0.0.1",
...@@ -288,7 +327,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -288,7 +327,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
288 serverOffset: 0,327 serverOffset: 0,
289 webOffset: 0,328 webOffset: 0,
290 t3Home: undefined,329 t3Home: undefined,
291 noBrowser: undefined,330 browser: undefined,
292 autoBootstrapProjectFromCwd: undefined,331 autoBootstrapProjectFromCwd: undefined,
293 logWebSocketEvents: undefined,332 logWebSocketEvents: undefined,
294 host: undefined,333 host: undefined,
scripts/dev-runner.ts+21-15
...@@ -221,7 +221,7 @@ interface CreateDevRunnerEnvInput {...@@ -221,7 +221,7 @@ interface CreateDevRunnerEnvInput {
221 readonly serverOffset: number;221 readonly serverOffset: number;
222 readonly webOffset: number;222 readonly webOffset: number;
223 readonly t3Home: string | undefined;223 readonly t3Home: string | undefined;
224 readonly noBrowser: boolean | undefined;224 readonly browser: boolean | undefined;
225 readonly autoBootstrapProjectFromCwd: boolean | undefined;225 readonly autoBootstrapProjectFromCwd: boolean | undefined;
226 readonly logWebSocketEvents: boolean | undefined;226 readonly logWebSocketEvents: boolean | undefined;
227 readonly host: string | undefined;227 readonly host: string | undefined;
...@@ -235,7 +235,7 @@ export function createDevRunnerEnv({...@@ -235,7 +235,7 @@ export function createDevRunnerEnv({
235 serverOffset,235 serverOffset,
236 webOffset,236 webOffset,
237 t3Home,237 t3Home,
238 noBrowser,238 browser,
239 autoBootstrapProjectFromCwd,239 autoBootstrapProjectFromCwd,
240 logWebSocketEvents,240 logWebSocketEvents,
241 host,241 host,
...@@ -245,7 +245,8 @@ export function createDevRunnerEnv({...@@ -245,7 +245,8 @@ export function createDevRunnerEnv({
245 return Effect.gen(function* () {245 return Effect.gen(function* () {
246 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;246 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
247 const webPort = BASE_WEB_PORT + webOffset;247 const webPort = BASE_WEB_PORT + webOffset;
248 const resolvedBaseDir = yield* resolveBaseDir(t3Home);248 const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined;
249 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
249 const isDesktopMode = mode === "dev:desktop";250 const isDesktopMode = mode === "dev:desktop";
250251
251 const output: NodeJS.ProcessEnv = {252 const output: NodeJS.ProcessEnv = {
...@@ -254,9 +255,14 @@ export function createDevRunnerEnv({...@@ -254,9 +255,14 @@ export function createDevRunnerEnv({
254 VITE_DEV_SERVER_URL:255 VITE_DEV_SERVER_URL:
255 devUrl?.toString() ??256 devUrl?.toString() ??
256 `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`,257 `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`,
257 T3CODE_HOME: resolvedBaseDir,
258 };258 };
259259
260 if (configuredBaseDir !== undefined) {
261 output.T3CODE_HOME = resolvedBaseDir;
262 } else {
263 delete output.T3CODE_HOME;
264 }
265
260 if (!isDesktopMode) {266 if (!isDesktopMode) {
261 output.T3CODE_PORT = String(serverPort);267 output.T3CODE_PORT = String(serverPort);
262 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;268 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
...@@ -274,10 +280,8 @@ export function createDevRunnerEnv({...@@ -274,10 +280,8 @@ export function createDevRunnerEnv({
274 output.T3CODE_HOST = host;280 output.T3CODE_HOST = host;
275 }281 }
276282
277 if (!isDesktopMode && noBrowser !== undefined) {283 if (!isDesktopMode) {
278 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";284 output.T3CODE_NO_BROWSER = browser === true ? "0" : "1";
279 } else if (!isDesktopMode) {
280 delete output.T3CODE_NO_BROWSER;
281 }285 }
282286
283 if (autoBootstrapProjectFromCwd !== undefined) {287 if (autoBootstrapProjectFromCwd !== undefined) {
...@@ -469,7 +473,7 @@ export function resolveModePortOffsets<R = NetService.NetService>({...@@ -469,7 +473,7 @@ export function resolveModePortOffsets<R = NetService.NetService>({
469interface DevRunnerCliInput {473interface DevRunnerCliInput {
470 readonly mode: DevMode;474 readonly mode: DevMode;
471 readonly t3Home: string | undefined;475 readonly t3Home: string | undefined;
472 readonly noBrowser: boolean | undefined;476 readonly browser: boolean | undefined;
473 readonly autoBootstrapProjectFromCwd: boolean | undefined;477 readonly autoBootstrapProjectFromCwd: boolean | undefined;
474 readonly logWebSocketEvents: boolean | undefined;478 readonly logWebSocketEvents: boolean | undefined;
475 readonly host: string | undefined;479 readonly host: string | undefined;
...@@ -507,7 +511,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -507,7 +511,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
507 serverOffset,511 serverOffset,
508 webOffset,512 webOffset,
509 t3Home: input.t3Home,513 t3Home: input.t3Home,
510 noBrowser: input.noBrowser,514 browser: input.browser,
511 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,515 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
512 logWebSocketEvents: input.logWebSocketEvents,516 logWebSocketEvents: input.logWebSocketEvents,
513 host: input.host,517 host: input.host,
...@@ -519,9 +523,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -519,9 +523,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
519 serverOffset !== offset || webOffset !== offset523 serverOffset !== offset || webOffset !== offset
520 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`524 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`
521 : "";525 : "";
526 const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME);
522527
523 yield* Effect.logInfo(528 yield* Effect.logInfo(
524 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${String(env.T3CODE_HOME)}`,529 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
525 );530 );
526531
527 if (input.dryRun) {532 if (input.dryRun) {
...@@ -586,12 +591,13 @@ const devRunnerCli = Command.make("dev-runner", {...@@ -586,12 +591,13 @@ const devRunnerCli = Command.make("dev-runner", {
586 Argument.withDescription("Development mode to run."),591 Argument.withDescription("Development mode to run."),
587 ),592 ),
588 t3Home: Flag.string("home-dir").pipe(593 t3Home: Flag.string("home-dir").pipe(
589 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),594 Flag.withDescription(
595 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).",
596 ),
590 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),597 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),
591 ),598 ),
592 noBrowser: Flag.boolean("no-browser").pipe(599 browser: Flag.boolean("browser").pipe(
593 Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."),600 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),
594 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")),
595 ),601 ),
596 autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe(602 autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
597 Flag.withDescription(603 Flag.withDescription(