Skip to content

Commit 07c1e3d

Browse files
authored
Merge branch 'main' into feat/checkpoint-rewind-menu
2 parents acfe071 + 99efaa0 commit 07c1e3d

64 files changed

Lines changed: 2411 additions & 466 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -272,19 +272,25 @@ jobs:
272272
exit 0
273273
}
274274
275-
Install-PackageProvider `
276-
-Name NuGet `
277-
-MinimumVersion 2.8.5.201 `
278-
-Force `
279-
-Scope CurrentUser
275+
try {
276+
Install-PackageProvider `
277+
-Name NuGet `
278+
-MinimumVersion 2.8.5.201 `
279+
-Force `
280+
-Scope CurrentUser `
281+
-ErrorAction Stop
282+
} catch {
283+
Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)"
284+
}
280285
281286
Install-Module `
282287
-Name TrustedSigning `
283288
-MinimumVersion 0.5.0 `
284289
-Force `
285290
-AllowClobber `
286291
-Repository PSGallery `
287-
-Scope CurrentUser
292+
-Scope CurrentUser `
293+
-ErrorAction Stop
288294
289295
Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force
290296
Get-Command Invoke-TrustedSigning -ErrorAction Stop
@@ -635,7 +641,7 @@ jobs:
635641
636642
echo "Deploying hosted web app for $channel_name channel."
637643
deployment_url="$(
638-
bunx vercel@53.1.1 deploy apps/web \
644+
bunx vercel@53.1.1 deploy \
639645
--prod \
640646
--skip-domain \
641647
--yes \

REMOTE.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,43 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint.
115115

116116
SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments.
117117

118+
#### SSH Launch Troubleshooting
119+
120+
The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch/<host-key>/`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop.
121+
122+
The remote host must have a compatible Node.js runtime. T3 Code uses the server package's `engines.node` requirement:
123+
124+
```text
125+
^22.16 || ^23.11 || >=24.10
126+
```
127+
128+
During SSH launch, T3 Code first checks whether `node` is already available on `PATH`. If it is missing, the launcher tries common non-interactive shell locations and version-manager shims/activation hooks:
129+
130+
- `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
131+
- Volta via `~/.volta/bin`
132+
- asdf via `~/.asdf/shims`, `~/.asdf/bin`, or `~/.asdf/asdf.sh`
133+
- mise via `~/.local/share/mise/shims`, `~/.mise/shims`, or `mise activate sh`
134+
- fnm via `fnm env --use-on-cd --shell sh` or `fnm env --shell sh`
135+
- nodenv via `~/.nodenv/bin`, `~/.nodenv/shims`, or `nodenv init -`
136+
- nvm via `$NVM_DIR/nvm.sh`, then `nvm use default`, `nvm use node`, or `nvm use --lts`
137+
- installed nvm versions under `$NVM_DIR/versions/node/*/bin`
138+
139+
If launch fails with `node: command not found`, a port-scan failure, or a message that the remote Node version does not satisfy the required range, SSH into the host and check the same non-interactive shell path T3 Code uses:
140+
141+
```bash
142+
ssh user@example.com 'sh -lc "command -v node && node --version"'
143+
```
144+
145+
If that does not print a compatible Node version, configure your version manager for non-interactive shells or install a compatible Node binary in one of the searched locations. For example, with nvm you may need a default alias:
146+
147+
```bash
148+
nvm alias default 24
149+
```
150+
151+
With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above.
152+
153+
If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually.
154+
118155
## How Pairing Works
119156

120157
The remote device does not need a long-lived secret up front.

apps/desktop/src/main.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as Electron from "electron";
1111
import * as NetService from "@t3tools/shared/Net";
1212
import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
1313
import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
14+
import serverPackageJson from "../../server/package.json" with { type: "json" };
1415

1516
import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
1617
import * as DesktopIpc from "./ipc/DesktopIpc.ts";
@@ -65,14 +66,18 @@ const resolveDesktopSshCliRunner = (
6566
): RemoteT3RunnerOptions => {
6667
const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
6768
if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
68-
return { nodeScriptPath: devRemoteEntryPath };
69+
return {
70+
nodeScriptPath: devRemoteEntryPath,
71+
nodeEngineRange: serverPackageJson.engines.node,
72+
};
6973
}
7074
return {
7175
packageSpec: resolveRemoteT3CliPackageSpec({
7276
appVersion: environment.appVersion,
7377
updateChannel: settings.updateChannel,
7478
isDevelopment: environment.isDevelopment,
7579
}),
80+
nodeEngineRange: serverPackageJson.engines.node,
7681
};
7782
};
7883

apps/server/src/auth/http.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
1414
import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
1515
import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
1616
import { deriveAuthClientMetadata } from "./utils.ts";
17+
import { browserApiCorsHeaders } from "../httpCors.ts";
1718

1819
export const respondToAuthError = (error: AuthError) =>
1920
Effect.gen(function* () {
@@ -27,7 +28,7 @@ export const respondToAuthError = (error: AuthError) =>
2728
{
2829
error: error.message,
2930
},
30-
{ status: error.status ?? 500 },
31+
{ status: error.status ?? 500, headers: browserApiCorsHeaders },
3132
);
3233
});
3334

@@ -38,7 +39,10 @@ export const authSessionRouteLayer = HttpRouter.add(
3839
const request = yield* HttpServerRequest.HttpServerRequest;
3940
const serverAuth = yield* ServerAuth;
4041
const session = yield* serverAuth.getSessionState(request);
41-
return HttpServerResponse.jsonUnsafe(session, { status: 200 });
42+
return HttpServerResponse.jsonUnsafe(session, {
43+
status: 200,
44+
headers: browserApiCorsHeaders,
45+
});
4246
}),
4347
);
4448

@@ -81,7 +85,10 @@ export const authBootstrapRouteLayer = HttpRouter.add(
8185
deriveAuthClientMetadata({ request }),
8286
);
8387

84-
return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe(
88+
return yield* HttpServerResponse.jsonUnsafe(result.response, {
89+
status: 200,
90+
headers: browserApiCorsHeaders,
91+
}).pipe(
8592
HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
8693
expires: DateTime.toDate(result.response.expiresAt),
8794
httpOnly: true,
@@ -114,6 +121,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add(
114121
);
115122
return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
116123
status: 200,
124+
headers: browserApiCorsHeaders,
117125
});
118126
}).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
119127
);
@@ -128,6 +136,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add(
128136
const result = yield* serverAuth.issueWebSocketToken(session);
129137
return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
130138
status: 200,
139+
headers: browserApiCorsHeaders,
131140
});
132141
}).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
133142
);

apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ describe("CheckpointDiffQueryLive", () => {
9494
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
9595
getShellSnapshot: () =>
9696
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
97+
getArchivedShellSnapshot: () =>
98+
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
9799
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
98100
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
99101
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
@@ -174,6 +176,8 @@ describe("CheckpointDiffQueryLive", () => {
174176
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
175177
getShellSnapshot: () =>
176178
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
179+
getArchivedShellSnapshot: () =>
180+
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
177181
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
178182
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
179183
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
@@ -222,6 +226,8 @@ describe("CheckpointDiffQueryLive", () => {
222226
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
223227
getShellSnapshot: () =>
224228
Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
229+
getArchivedShellSnapshot: () =>
230+
Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
225231
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
226232
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
227233
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),

apps/server/src/http.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,20 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve
2828
import { ServerAuth } from "./auth/Services/ServerAuth.ts";
2929
import { respondToAuthError } from "./auth/http.ts";
3030
import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
31+
import {
32+
browserApiCorsAllowedHeaders,
33+
browserApiCorsAllowedMethods,
34+
browserApiCorsHeaders,
35+
} from "./httpCors.ts";
3136

3237
const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
3338
const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
3439
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
3540
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
3641

3742
export const browserApiCorsLayer = HttpRouter.cors({
38-
allowedMethods: ["GET", "POST", "OPTIONS"],
39-
allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
43+
allowedMethods: [...browserApiCorsAllowedMethods],
44+
allowedHeaders: [...browserApiCorsAllowedHeaders],
4045
maxAge: 600,
4146
});
4247

@@ -69,7 +74,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add(
6974
const descriptor = yield* Effect.service(ServerEnvironment).pipe(
7075
Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
7176
);
72-
return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
77+
return HttpServerResponse.jsonUnsafe(descriptor, {
78+
status: 200,
79+
headers: browserApiCorsHeaders,
80+
});
7381
}),
7482
);
7583

apps/server/src/httpCors.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
2+
export const browserApiCorsAllowedHeaders = [
3+
"authorization",
4+
"b3",
5+
"traceparent",
6+
"content-type",
7+
] as const;
8+
9+
export const browserApiCorsHeaders = {
10+
"access-control-allow-origin": "*",
11+
"access-control-allow-methods": browserApiCorsAllowedMethods.join(", "),
12+
"access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "),
13+
} as const;

apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,13 @@ describe("OrchestrationEngine", () => {
183183
threads: [],
184184
updatedAt: projectionSnapshot.updatedAt,
185185
}),
186+
getArchivedShellSnapshot: () =>
187+
Effect.succeed({
188+
snapshotSequence: projectionSnapshot.snapshotSequence,
189+
projects: [],
190+
threads: [],
191+
updatedAt: projectionSnapshot.updatedAt,
192+
}),
186193
getSnapshotSequence: () =>
187194
Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }),
188195
getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,126 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
441441
}),
442442
);
443443

444+
it.effect("keeps archived threads out of the main shell snapshot", () =>
445+
Effect.gen(function* () {
446+
const snapshotQuery = yield* ProjectionSnapshotQuery;
447+
const sql = yield* SqlClient.SqlClient;
448+
449+
yield* sql`DELETE FROM projection_projects`;
450+
yield* sql`DELETE FROM projection_threads`;
451+
yield* sql`DELETE FROM projection_state`;
452+
453+
yield* sql`
454+
INSERT INTO projection_projects (
455+
project_id,
456+
title,
457+
workspace_root,
458+
default_model_selection_json,
459+
scripts_json,
460+
created_at,
461+
updated_at,
462+
deleted_at
463+
)
464+
VALUES (
465+
'project-archive-test',
466+
'Archive Test',
467+
'/tmp/archive-test',
468+
'{"provider":"codex","model":"gpt-5-codex"}',
469+
'[]',
470+
'2026-04-06T00:00:00.000Z',
471+
'2026-04-06T00:00:01.000Z',
472+
NULL
473+
)
474+
`;
475+
476+
yield* sql`
477+
INSERT INTO projection_threads (
478+
thread_id,
479+
project_id,
480+
title,
481+
model_selection_json,
482+
runtime_mode,
483+
interaction_mode,
484+
branch,
485+
worktree_path,
486+
latest_turn_id,
487+
latest_user_message_at,
488+
pending_approval_count,
489+
pending_user_input_count,
490+
has_actionable_proposed_plan,
491+
created_at,
492+
updated_at,
493+
archived_at,
494+
deleted_at
495+
)
496+
VALUES
497+
(
498+
'thread-active',
499+
'project-archive-test',
500+
'Active Thread',
501+
'{"provider":"codex","model":"gpt-5-codex"}',
502+
'full-access',
503+
'default',
504+
NULL,
505+
NULL,
506+
NULL,
507+
NULL,
508+
0,
509+
0,
510+
0,
511+
'2026-04-06T00:00:02.000Z',
512+
'2026-04-06T00:00:03.000Z',
513+
NULL,
514+
NULL
515+
),
516+
(
517+
'thread-archived',
518+
'project-archive-test',
519+
'Archived Thread',
520+
'{"provider":"codex","model":"gpt-5-codex"}',
521+
'full-access',
522+
'default',
523+
NULL,
524+
NULL,
525+
NULL,
526+
NULL,
527+
0,
528+
0,
529+
0,
530+
'2026-04-06T00:00:04.000Z',
531+
'2026-04-06T00:00:05.000Z',
532+
'2026-04-06T00:00:06.000Z',
533+
NULL
534+
)
535+
`;
536+
537+
yield* sql`
538+
INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
539+
VALUES
540+
(${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'),
541+
(${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'),
542+
(${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'),
543+
(${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'),
544+
(${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'),
545+
(${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'),
546+
(${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z')
547+
`;
548+
549+
const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
550+
assert.deepEqual(
551+
shellSnapshot.threads.map((thread) => thread.id),
552+
[ThreadId.make("thread-active")],
553+
);
554+
555+
const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot();
556+
assert.deepEqual(
557+
archivedShellSnapshot.threads.map((thread) => thread.id),
558+
[ThreadId.make("thread-archived")],
559+
);
560+
assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");
561+
}),
562+
);
563+
444564
it.effect(
445565
"reads targeted project, thread, and count queries without hydrating the full snapshot",
446566
() =>

0 commit comments

Comments
 (0)