| author | |
| committer | |
| log | 09aac71563c66a4f65f6fbe701aa9596cb677767 |
| tree | 760b4cd35654abbbab050aff3884bd0e75f09f3f |
| parent | c843c19294bcea9a4f5cf19632b135459a16d214 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>122 files changed, 15520 insertions(+), 494 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts+17-6| ... | @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer<FileSystem.FileSystem>) => { | ... | @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer<FileSystem.FileSystem>) => { |
| 54 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 54 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 55 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | 55 | const settings = yield* DesktopClientSettings.DesktopClientSettings; |
| 56 | return { | 56 | return { |
| 57 | result: yield* settings.get, | 57 | result: yield* Effect.result(settings.get), |
| 58 | settingsPath: environment.clientSettingsPath, | 58 | settingsPath: environment.clientSettingsPath, |
| 59 | records, | 59 | records, |
| 60 | }; | 60 | }; |
| ... | @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { | ... | @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { |
| 73 | Effect.gen(function* () { | 73 | Effect.gen(function* () { |
| 74 | const result = yield* readWithLogs(FileSystem.layerNoop({})); | 74 | const result = yield* readWithLogs(FileSystem.layerNoop({})); |
| 75 | 75 | ||
| 76 | assert.isTrue(Option.isNone(result.result)); | 76 | if (result.result._tag !== "Success") return assert.fail("expected a successful read"); |
| 77 | assert.isTrue(Option.isNone(result.result.success)); | ||
| 77 | assert.deepEqual(result.records, []); | 78 | assert.deepEqual(result.records, []); |
| 78 | }), | 79 | }), |
| 79 | ); | 80 | ); |
| 80 | 81 | ||
| 81 | it.effect("logs non-missing filesystem failures with the settings path", () => { | 82 | it.effect("reports non-missing filesystem failures and logs the settings path", () => { |
| 82 | const permissionError = PlatformError.systemError({ | 83 | const permissionError = PlatformError.systemError({ |
| 83 | _tag: "PermissionDenied", | 84 | _tag: "PermissionDenied", |
| 84 | module: "FileSystem", | 85 | module: "FileSystem", |
| ... | @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { | ... | @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { |
| 93 | }), | 94 | }), |
| 94 | ); | 95 | ); |
| 95 | 96 | ||
| 96 | assert.isTrue(Option.isNone(result.result)); | 97 | if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); |
| 98 | assert.instanceOf( | ||
| 99 | result.result.failure, | ||
| 100 | DesktopClientSettings.DesktopClientSettingsReadError, | ||
| 101 | ); | ||
| 102 | assert.strictEqual(result.result.failure.cause, permissionError); | ||
| 97 | assert.equal(result.records.length, 1); | 103 | assert.equal(result.records.length, 1); |
| 98 | assert.deepEqual(result.records[0]?.message, [ | 104 | assert.deepEqual(result.records[0]?.message, [ |
| 99 | "Could not read desktop client settings.", | 105 | "Could not read desktop client settings.", |
| ... | @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { | ... | @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { |
| 103 | }); | 109 | }); |
| 104 | }); | 110 | }); |
| 105 | 111 | ||
| 106 | it.effect("logs malformed settings documents with the settings path", () => | 112 | it.effect("reports malformed settings documents and logs the settings path", () => |
| 107 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 108 | const result = yield* readWithLogs( | 114 | const result = yield* readWithLogs( |
| 109 | FileSystem.layerNoop({ | 115 | FileSystem.layerNoop({ |
| ... | @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { | ... | @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { |
| 111 | }), | 117 | }), |
| 112 | ); | 118 | ); |
| 113 | 119 | ||
| 114 | assert.isTrue(Option.isNone(result.result)); | 120 | if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); |
| 121 | assert.instanceOf( | ||
| 122 | result.result.failure, | ||
| 123 | DesktopClientSettings.DesktopClientSettingsReadError, | ||
| 124 | ); | ||
| 125 | assert.equal(result.result.failure.operation, "decode-document"); | ||
| 115 | assert.equal(result.records.length, 1); | 126 | assert.equal(result.records.length, 1); |
| 116 | const message = result.records[0]?.message; | 127 | const message = result.records[0]?.message; |
| 117 | if (!Array.isArray(message)) { | 128 | if (!Array.isArray(message)) { |
apps/desktop/src/settings/DesktopClientSettings.test.ts+80-12| ... | @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { | ... | @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { |
| 44 | fontSizeTerminal: 12, | 44 | fontSizeTerminal: 12, |
| 45 | fontSmoothing: true, | 45 | fontSmoothing: true, |
| 46 | glassOpacity: 80, | 46 | glassOpacity: 80, |
| 47 | onboardingCompletedAt: null, | ||
| 47 | panelAnimationDurationMs: 0, | 48 | panelAnimationDurationMs: 0, |
| 48 | planModeEnabled: false, | 49 | planModeEnabled: false, |
| 49 | proactivePanelsEnabled: true, | 50 | proactivePanelsEnabled: true, |
| ... | @@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => { | ... | @@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => { |
| 136 | ), | 137 | ), |
| 137 | ); | 138 | ); |
| 138 | 139 | ||
| 140 | for (const failure of [ | ||
| 141 | { label: "permission", reason: "PermissionDenied" }, | ||
| 142 | { label: "I/O", reason: "Unknown" }, | ||
| 143 | ] as const) { | ||
| 144 | it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => | ||
| 145 | withClientSettings( | ||
| 146 | Effect.gen(function* () { | ||
| 147 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 148 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 149 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | ||
| 150 | const savedSettings = { | ||
| 151 | ...clientSettings, | ||
| 152 | onboardingCompletedAt: "2026-09-05T12:00:00.000Z", | ||
| 153 | }; | ||
| 154 | yield* settings.set(savedSettings); | ||
| 155 | const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); | ||
| 156 | const cause = PlatformError.systemError({ | ||
| 157 | _tag: failure.reason, | ||
| 158 | module: "FileSystem", | ||
| 159 | method: "readFileString", | ||
| 160 | pathOrDescriptor: environment.clientSettingsPath, | ||
| 161 | }); | ||
| 162 | let failRead = true; | ||
| 163 | const retryableSettings = yield* DesktopClientSettings.make.pipe( | ||
| 164 | Effect.provideService( | ||
| 165 | FileSystem.FileSystem, | ||
| 166 | FileSystem.FileSystem.of({ | ||
| 167 | ...fileSystem, | ||
| 168 | readFileString: (path) => | ||
| 169 | Effect.suspend(() => | ||
| 170 | failRead ? Effect.fail(cause) : fileSystem.readFileString(path), | ||
| 171 | ), | ||
| 172 | }), | ||
| 173 | ), | ||
| 174 | ); | ||
| 175 | |||
| 176 | const error = yield* retryableSettings.get.pipe(Effect.flip); | ||
| 177 | assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); | ||
| 178 | assert.equal(error.operation, "read-file"); | ||
| 179 | assert.equal(error.path, environment.clientSettingsPath); | ||
| 180 | assert.strictEqual(error.cause, cause); | ||
| 181 | assert.equal( | ||
| 182 | yield* fileSystem.readFileString(environment.clientSettingsPath), | ||
| 183 | savedContents, | ||
| 184 | ); | ||
| 185 | |||
| 186 | failRead = false; | ||
| 187 | assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); | ||
| 188 | }), | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | } | ||
| 192 | |||
| 139 | it.effect("reports the failed client settings write operation and path", () => | 193 | it.effect("reports the failed client settings write operation and path", () => |
| 140 | withClientSettings( | 194 | withClientSettings( |
| 141 | Effect.gen(function* () { | 195 | Effect.gen(function* () { |
| ... | @@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => { | ... | @@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => { |
| 222 | ), | 276 | ), |
| 223 | ); | 277 | ); |
| 224 | 278 | ||
| 225 | it.effect("treats malformed client settings documents as absent", () => | 279 | for (const document of [ |
| 226 | withClientSettings( | 280 | { label: "malformed JSON", contents: "{not-json" }, |
| 227 | Effect.gen(function* () { | 281 | { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, |
| 228 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 282 | { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, |
| 229 | const fileSystem = yield* FileSystem.FileSystem; | 283 | ]) { |
| 230 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | 284 | it.effect(`reports ${document.label} without treating the settings file as absent`, () => |
| 231 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | 285 | withClientSettings( |
| 232 | yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); | 286 | Effect.gen(function* () { |
| 287 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 288 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 289 | const settings = yield* DesktopClientSettings.DesktopClientSettings; | ||
| 290 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | ||
| 291 | yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); | ||
| 233 | 292 | ||
| 234 | assert.isTrue(Option.isNone(yield* settings.get)); | 293 | const error = yield* settings.get.pipe(Effect.flip); |
| 235 | }), | 294 | assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); |
| 236 | ), | 295 | assert.equal(error.operation, "decode-document"); |
| 237 | ); | 296 | assert.equal(error.path, environment.clientSettingsPath); |
| 297 | assert.instanceOf(error.cause, Schema.SchemaError); | ||
| 298 | assert.equal( | ||
| 299 | yield* fileSystem.readFileString(environment.clientSettingsPath), | ||
| 300 | document.contents, | ||
| 301 | ); | ||
| 302 | }), | ||
| 303 | ), | ||
| 304 | ); | ||
| 305 | } | ||
| 238 | }); | 306 | }); |
apps/desktop/src/settings/DesktopClientSettings.ts+42-18| ... | @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; | ... | @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; |
| 12 | 12 | ||
| 13 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 13 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 14 | 14 | ||
| 15 | const ClientSettingsDocumentSchema = Schema.Struct({ | ||
| 16 | settings: ClientSettingsSchema, | ||
| 17 | }); | ||
| 18 | |||
| 19 | const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); | 15 | const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); |
| 20 | const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); | 16 | const decodeClientSettingsDocument = Schema.decodeEffect( |
| 21 | const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( | 17 | fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), |
| 22 | LegacyClientSettingsDocumentJson, | ||
| 23 | ); | 18 | ); |
| 24 | const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); | 19 | const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); |
| 25 | const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> => | 20 | const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { |
| 26 | decodeLegacyClientSettingsDocumentJson(raw).pipe( | 21 | const document = yield* decodeClientSettingsDocument(raw); |
| 27 | Effect.map((document) => document.settings), | 22 | // Select the shape before validation so invalid legacy settings cannot become defaults. |
| 28 | Effect.catchTags({ | 23 | return yield* decodeClientSettingsValue( |
| 29 | SchemaError: () => decodeClientSettingsJsonValue(raw), | 24 | Object.hasOwn(document, "settings") ? document.settings : document, |
| 30 | }), | ||
| 31 | ); | 25 | ); |
| 26 | }); | ||
| 32 | const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); | 27 | const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); |
| 33 | 28 | ||
| 29 | export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass<DesktopClientSettingsReadError>()( | ||
| 30 | "DesktopClientSettingsReadError", | ||
| 31 | { | ||
| 32 | operation: Schema.Literals(["read-file", "decode-document"]), | ||
| 33 | path: Schema.String, | ||
| 34 | cause: Schema.Defect(), | ||
| 35 | }, | ||
| 36 | ) { | ||
| 37 | override get message(): string { | ||
| 38 | return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 34 | const DesktopClientSettingsWriteOperation = Schema.Literals([ | 42 | const DesktopClientSettingsWriteOperation = Schema.Literals([ |
| 35 | "create-temporary-file-name", | 43 | "create-temporary-file-name", |
| 36 | "encode-document", | 44 | "encode-document", |
| ... | @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<Des | ... | @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<Des |
| 55 | export class DesktopClientSettings extends Context.Service< | 63 | export class DesktopClientSettings extends Context.Service< |
| 56 | DesktopClientSettings, | 64 | DesktopClientSettings, |
| 57 | { | 65 | { |
| 58 | readonly get: Effect.Effect<Option.Option<ClientSettings>>; | 66 | readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>; |
| 59 | readonly set: ( | 67 | readonly set: ( |
| 60 | settings: ClientSettings, | 68 | settings: ClientSettings, |
| 61 | ) => Effect.Effect<void, DesktopClientSettingsWriteError>; | 69 | ) => Effect.Effect<void, DesktopClientSettingsWriteError>; |
| ... | @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< | ... | @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< |
| 65 | const readClientSettings = ( | 73 | const readClientSettings = ( |
| 66 | fileSystem: FileSystem.FileSystem, | 74 | fileSystem: FileSystem.FileSystem, |
| 67 | settingsPath: string, | 75 | settingsPath: string, |
| 68 | ): Effect.Effect<Option.Option<ClientSettings>> => | 76 | ): Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError> => |
| 69 | fileSystem.readFileString(settingsPath).pipe( | 77 | fileSystem.readFileString(settingsPath).pipe( |
| 70 | Effect.map(Option.some), | 78 | Effect.map(Option.some), |
| 71 | Effect.catchTags({ | 79 | Effect.catchTags({ |
| ... | @@ -74,7 +82,15 @@ const readClientSettings = ( | ... | @@ -74,7 +82,15 @@ const readClientSettings = ( |
| 74 | ? Effect.succeed(Option.none<string>()) | 82 | ? Effect.succeed(Option.none<string>()) |
| 75 | : Effect.logWarning("Could not read desktop client settings.", cause).pipe( | 83 | : Effect.logWarning("Could not read desktop client settings.", cause).pipe( |
| 76 | Effect.annotateLogs({ settingsPath }), | 84 | Effect.annotateLogs({ settingsPath }), |
| 77 | Effect.as(Option.none<string>()), | 85 | Effect.andThen( |
| 86 | Effect.fail( | ||
| 87 | new DesktopClientSettingsReadError({ | ||
| 88 | operation: "read-file", | ||
| 89 | path: settingsPath, | ||
| 90 | cause, | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | ), | ||
| 78 | ), | 94 | ), |
| 79 | }), | 95 | }), |
| 80 | Effect.flatMap( | 96 | Effect.flatMap( |
| ... | @@ -87,7 +103,15 @@ const readClientSettings = ( | ... | @@ -87,7 +103,15 @@ const readClientSettings = ( |
| 87 | SchemaError: (cause) => | 103 | SchemaError: (cause) => |
| 88 | Effect.logWarning("Could not decode desktop client settings.", cause).pipe( | 104 | Effect.logWarning("Could not decode desktop client settings.", cause).pipe( |
| 89 | Effect.annotateLogs({ settingsPath }), | 105 | Effect.annotateLogs({ settingsPath }), |
| 90 | Effect.as(Option.none<ClientSettings>()), | 106 | Effect.andThen( |
| 107 | Effect.fail( | ||
| 108 | new DesktopClientSettingsReadError({ | ||
| 109 | operation: "decode-document", | ||
| 110 | path: settingsPath, | ||
| 111 | cause, | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | ), | ||
| 91 | ), | 115 | ), |
| 92 | }), | 116 | }), |
| 93 | ), | 117 | ), |
apps/server/src/auth/RpcAuthorization.test.ts+9| ... | @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { | ... | @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { |
| 43 | ); | 43 | ); |
| 44 | }); | 44 | }); |
| 45 | 45 | ||
| 46 | it("requires write access to import agent session history", () => { | ||
| 47 | expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( | ||
| 48 | AuthOrchestrationReadScope, | ||
| 49 | ); | ||
| 50 | expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( | ||
| 51 | AuthOrchestrationOperateScope, | ||
| 52 | ); | ||
| 53 | }); | ||
| 54 | |||
| 46 | it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { | 55 | it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { |
| 47 | // The candidate list is a read like the detail beside it, and asking somebody for a review is | 56 | // The candidate list is a read like the detail beside it, and asking somebody for a review is |
| 48 | // a write like every other pull request operation. | 57 | // a write like every other pull request operation. |
apps/server/src/auth/RpcAuthorization.ts+2| ... | @@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 99 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, | 99 | [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, |
| 100 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | 100 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 101 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, | 101 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, |
| 102 | [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, | ||
| 103 | [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, | ||
| 102 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, | 104 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, |
| 103 | [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, | 105 | [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, |
| 104 | [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, | 106 | [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, |
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5| ... | @@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 90 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 90 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 91 | getProjectShellById: () => Effect.succeed(Option.none()), | 91 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 92 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 92 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 93 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 93 | getThreadCheckpointContext: () => | 94 | getThreadCheckpointContext: () => |
| 94 | Effect.sync(() => { | 95 | Effect.sync(() => { |
| 95 | getThreadCheckpointContextCalls += 1; | 96 | getThreadCheckpointContextCalls += 1; |
| ... | @@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 202 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 203 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 203 | getProjectShellById: () => Effect.succeed(Option.none()), | 204 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 204 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 205 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 206 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 205 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 207 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 206 | getFullThreadDiffContext: () => Effect.die("unused"), | 208 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 207 | getThreadRuntimeContext: () => Effect.die("unused"), | 209 | getThreadRuntimeContext: () => Effect.die("unused"), |
| ... | @@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 289 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 291 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 290 | getProjectShellById: () => Effect.succeed(Option.none()), | 292 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 291 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 293 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 294 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 292 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 295 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 293 | getFullThreadDiffContext: () => Effect.die("unused"), | 296 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 294 | getThreadRuntimeContext: () => Effect.die("unused"), | 297 | getThreadRuntimeContext: () => Effect.die("unused"), |
| ... | @@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 361 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 364 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 362 | getProjectShellById: () => Effect.succeed(Option.none()), | 365 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 363 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 366 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 367 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 364 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 368 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 365 | getFullThreadDiffContext: () => Effect.die("unused"), | 369 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 366 | getThreadRuntimeContext: () => Effect.die("unused"), | 370 | getThreadRuntimeContext: () => Effect.die("unused"), |
| ... | @@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => { | ... | @@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 418 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 422 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 419 | getProjectShellById: () => Effect.succeed(Option.none()), | 423 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 420 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 424 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 425 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 421 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 426 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 422 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 427 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 423 | getThreadRuntimeContext: () => Effect.die("unused"), | 428 | getThreadRuntimeContext: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+30| ... | @@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => { | ... | @@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => { |
| 1342 | ).toBe("v1\n"); | 1342 | ).toBe("v1\n"); |
| 1343 | }); | 1343 | }); |
| 1344 | 1344 | ||
| 1345 | it("does not create checkpoints while importing historical user messages", async () => { | ||
| 1346 | const harness = await createHarness({ | ||
| 1347 | hasSession: false, | ||
| 1348 | seedFilesystemCheckpoints: false, | ||
| 1349 | threadWorktreePath: null, | ||
| 1350 | }); | ||
| 1351 | if (runtime === null) throw new Error("Checkpoint test runtime was not initialized."); | ||
| 1352 | |||
| 1353 | await runtime.runPromise( | ||
| 1354 | harness.engine.dispatch({ | ||
| 1355 | type: "thread.history.import", | ||
| 1356 | commandId: CommandId.make("cmd-import-history-without-checkpoint"), | ||
| 1357 | threadId: ThreadId.make("thread-1"), | ||
| 1358 | messages: [ | ||
| 1359 | { | ||
| 1360 | messageId: MessageId.make("imported-user-message"), | ||
| 1361 | role: "user", | ||
| 1362 | text: "A message from an existing agent session", | ||
| 1363 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 1364 | }, | ||
| 1365 | ], | ||
| 1366 | }), | ||
| 1367 | ); | ||
| 1368 | await harness.drain(); | ||
| 1369 | |||
| 1370 | expect( | ||
| 1371 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), | ||
| 1372 | ).toBe(false); | ||
| 1373 | }); | ||
| 1374 | |||
| 1345 | it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { | 1375 | it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { |
| 1346 | const harness = await createHarness({ | 1376 | const harness = await createHarness({ |
| 1347 | hasSession: false, | 1377 | hasSession: false, |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+1| ... | @@ -615,6 +615,7 @@ const make = Effect.gen(function* () { | ... | @@ -615,6 +615,7 @@ const make = Effect.gen(function* () { |
| 615 | ) { | 615 | ) { |
| 616 | if (event.type === "thread.message-sent") { | 616 | if (event.type === "thread.message-sent") { |
| 617 | if ( | 617 | if ( |
| 618 | event.metadata.historyImport === true || | ||
| 618 | event.payload.role !== "user" || | 619 | event.payload.role !== "user" || |
| 619 | event.payload.streaming || | 620 | event.payload.streaming || |
| 620 | event.payload.turnId !== null | 621 | event.payload.turnId !== null |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1| ... | @@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => { |
| 418 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 418 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 419 | getProjectShellById: () => Effect.succeed(Option.none()), | 419 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 420 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 420 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 421 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 421 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 422 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 422 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 423 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 423 | getThreadRuntimeContext: () => Effect.die("unused"), | 424 | getThreadRuntimeContext: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+96| ... | @@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs | ... | @@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs |
| 108 | }, | 108 | }, |
| 109 | ); | 109 | ); |
| 110 | 110 | ||
| 111 | it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( | ||
| 112 | "imported thread shell projection", | ||
| 113 | (it) => { | ||
| 114 | it.effect("does not mark imported user messages as queued work in thread shells", () => | ||
| 115 | Effect.gen(function* () { | ||
| 116 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 117 | const eventStore = yield* OrchestrationEventStore; | ||
| 118 | const sql = yield* SqlClient.SqlClient; | ||
| 119 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 120 | const threadId = ThreadId.make("import:codex:shell-session"); | ||
| 121 | |||
| 122 | yield* eventStore.append({ | ||
| 123 | type: "thread.created", | ||
| 124 | eventId: EventId.make("evt-import-shell-thread"), | ||
| 125 | aggregateKind: "thread", | ||
| 126 | aggregateId: threadId, | ||
| 127 | occurredAt: createdAt, | ||
| 128 | commandId: CommandId.make("cmd-import-shell-thread"), | ||
| 129 | causationEventId: null, | ||
| 130 | correlationId: CommandId.make("cmd-import-shell-thread"), | ||
| 131 | metadata: {}, | ||
| 132 | payload: { | ||
| 133 | threadId, | ||
| 134 | projectId: ProjectId.make("project-import-shell"), | ||
| 135 | title: "Imported thread", | ||
| 136 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 137 | runtimeMode: "full-access", | ||
| 138 | interactionMode: "default", | ||
| 139 | branch: null, | ||
| 140 | worktreePath: null, | ||
| 141 | createdAt, | ||
| 142 | updatedAt: createdAt, | ||
| 143 | }, | ||
| 144 | }); | ||
| 145 | yield* eventStore.append({ | ||
| 146 | type: "thread.message-sent", | ||
| 147 | eventId: EventId.make("evt-import-shell-message"), | ||
| 148 | aggregateKind: "thread", | ||
| 149 | aggregateId: threadId, | ||
| 150 | occurredAt: createdAt, | ||
| 151 | commandId: CommandId.make("cmd-import-shell-message"), | ||
| 152 | causationEventId: null, | ||
| 153 | correlationId: CommandId.make("cmd-import-shell-message"), | ||
| 154 | metadata: { historyImport: true }, | ||
| 155 | payload: { | ||
| 156 | threadId, | ||
| 157 | messageId: MessageId.make("import:codex:shell-session:0"), | ||
| 158 | role: "user", | ||
| 159 | text: "Imported user prompt", | ||
| 160 | turnId: null, | ||
| 161 | streaming: false, | ||
| 162 | createdAt, | ||
| 163 | updatedAt: createdAt, | ||
| 164 | }, | ||
| 165 | }); | ||
| 166 | |||
| 167 | yield* projectionPipeline.bootstrap; | ||
| 168 | |||
| 169 | const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>` | ||
| 170 | SELECT latest_user_message_at AS "latestUserMessageAt" | ||
| 171 | FROM projection_threads | ||
| 172 | WHERE thread_id = ${threadId} | ||
| 173 | `; | ||
| 174 | assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); | ||
| 175 | |||
| 176 | const sessionEvent = yield* eventStore.append({ | ||
| 177 | type: "thread.session-set", | ||
| 178 | eventId: EventId.make("evt-import-shell-session"), | ||
| 179 | aggregateKind: "thread", | ||
| 180 | aggregateId: threadId, | ||
| 181 | occurredAt: createdAt, | ||
| 182 | commandId: CommandId.make("cmd-import-shell-session"), | ||
| 183 | causationEventId: null, | ||
| 184 | correlationId: CommandId.make("cmd-import-shell-session"), | ||
| 185 | metadata: {}, | ||
| 186 | payload: { | ||
| 187 | threadId, | ||
| 188 | session: { | ||
| 189 | threadId, | ||
| 190 | status: "ready", | ||
| 191 | providerName: "codex", | ||
| 192 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 193 | runtimeMode: "full-access", | ||
| 194 | activeTurnId: null, | ||
| 195 | lastError: null, | ||
| 196 | updatedAt: createdAt, | ||
| 197 | }, | ||
| 198 | }, | ||
| 199 | }); | ||
| 200 | yield* projectionPipeline.projectEvent(sessionEvent); | ||
| 201 | assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); | ||
| 202 | }), | ||
| 203 | ); | ||
| 204 | }, | ||
| 205 | ); | ||
| 206 | |||
| 111 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | 207 | it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 112 | it.effect("bootstraps all projection states and writes projection rows", () => | 208 | it.effect("bootstraps all projection states and writes projection rows", () => |
| 113 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+14-5| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { | 1 | import { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | isImportedAgentSessionMessageId, | ||
| 3 | type ChatAttachment, | 4 | type ChatAttachment, |
| 4 | type OrchestrationEvent, | 5 | type OrchestrationEvent, |
| 5 | type OrchestrationSessionStatus, | 6 | type OrchestrationSessionStatus, |
| 6 | ThreadId, | 7 | ThreadId, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| ... | @@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert( | ... | @@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert( |
| 248 | } | 250 | } |
| 249 | 251 | ||
| 250 | for (const message of messages) { | 252 | for (const message of messages) { |
| 251 | if (message.role === "system") { | 253 | if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) { |
| 252 | retainedMessageIds.add(message.messageId); | 254 | retainedMessageIds.add(message.messageId); |
| 253 | continue; | 255 | continue; |
| 254 | } | 256 | } |
| ... | @@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert( | ... | @@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert( |
| 258 | } | 260 | } |
| 259 | 261 | ||
| 260 | const retainedUserCount = messages.filter( | 262 | const retainedUserCount = messages.filter( |
| 261 | (message) => message.role === "user" && retainedMessageIds.has(message.messageId), | 263 | (message) => |
| 264 | message.role === "user" && | ||
| 265 | !isImportedAgentSessionMessageId(message.messageId) && | ||
| 266 | retainedMessageIds.has(message.messageId), | ||
| 262 | ).length; | 267 | ).length; |
| 263 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); | 268 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); |
| 264 | if (missingUserCount > 0) { | 269 | if (missingUserCount > 0) { |
| ... | @@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert( | ... | @@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert( |
| 271 | ) | 276 | ) |
| 272 | .toSorted( | 277 | .toSorted( |
| 273 | (left, right) => | 278 | (left, right) => |
| 274 | left.createdAt.localeCompare(right.createdAt) || | 279 | compareDateTimeStrings(left.createdAt, right.createdAt) || |
| 275 | left.messageId.localeCompare(right.messageId), | 280 | left.messageId.localeCompare(right.messageId), |
| 276 | ) | 281 | ) |
| 277 | .slice(0, missingUserCount); | 282 | .slice(0, missingUserCount); |
| ... | @@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert( | ... | @@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert( |
| 281 | } | 286 | } |
| 282 | 287 | ||
| 283 | const retainedAssistantCount = messages.filter( | 288 | const retainedAssistantCount = messages.filter( |
| 284 | (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), | 289 | (message) => |
| 290 | message.role === "assistant" && | ||
| 291 | !isImportedAgentSessionMessageId(message.messageId) && | ||
| 292 | retainedMessageIds.has(message.messageId), | ||
| 285 | ).length; | 293 | ).length; |
| 286 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); | 294 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); |
| 287 | if (missingAssistantCount > 0) { | 295 | if (missingAssistantCount > 0) { |
| ... | @@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert( | ... | @@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert( |
| 294 | ) | 302 | ) |
| 295 | .toSorted( | 303 | .toSorted( |
| 296 | (left, right) => | 304 | (left, right) => |
| 297 | left.createdAt.localeCompare(right.createdAt) || | 305 | compareDateTimeStrings(left.createdAt, right.createdAt) || |
| 298 | left.messageId.localeCompare(right.messageId), | 306 | left.messageId.localeCompare(right.messageId), |
| 299 | ) | 307 | ) |
| 300 | .slice(0, missingAssistantCount); | 308 | .slice(0, missingAssistantCount); |
| ... | @@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 903 | updatedAt: event.occurredAt, | 911 | updatedAt: event.occurredAt, |
| 904 | latestUserMessageAt: | 912 | latestUserMessageAt: |
| 905 | event.payload.role === "user" && | 913 | event.payload.role === "user" && |
| 914 | !isImportedAgentSessionMessageId(event.payload.messageId) && | ||
| 906 | (previousLatest === null || event.payload.createdAt > previousLatest) | 915 | (previousLatest === null || event.payload.createdAt > previousLatest) |
| 907 | ? event.payload.createdAt | 916 | ? event.payload.createdAt |
| 908 | : previousLatest, | 917 | : previousLatest, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+298-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type AgentSessionImportSource, | ||
| 2 | CheckpointRef, | 3 | CheckpointRef, |
| 3 | EventId, | 4 | EventId, |
| 4 | MessageId, | 5 | MessageId, |
| ... | @@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest"; |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Schema from "effect/Schema"; | ||
| 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 16 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 15 | 17 | ||
| 16 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 18 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| ... | @@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = | ... | @@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = |
| 2134 | // | 2136 | // |
| 2135 | // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) | 2137 | // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) |
| 2136 | // and a turnless activity at T03.6 — both belong to the page containing T03+. | 2138 | // and a turnless activity at T03.6 — both belong to the page containing T03+. |
| 2137 | const seedFanOutThread = Effect.fnUntraced(function* () { | 2139 | const seedFanOutThread = Effect.fnUntraced(function* (options?: { |
| 2140 | readonly importedMessageCount?: number; | ||
| 2141 | }) { | ||
| 2138 | const sql = yield* SqlClient.SqlClient; | 2142 | const sql = yield* SqlClient.SqlClient; |
| 2139 | 2143 | ||
| 2140 | // Tests in this block share one in-memory database; reset before seeding. | 2144 | // Tests in this block share one in-memory database; reset before seeding. |
| ... | @@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = | ... | @@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = |
| 2163 | 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) | 2167 | 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) |
| 2164 | `; | 2168 | `; |
| 2165 | 2169 | ||
| 2170 | if (options?.importedMessageCount) { | ||
| 2171 | for (let index = 0; index < options.importedMessageCount; index += 1) { | ||
| 2172 | const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`; | ||
| 2173 | const role = index % 2 === 0 ? "user" : "assistant"; | ||
| 2174 | yield* sql` | ||
| 2175 | INSERT INTO projection_thread_messages ( | ||
| 2176 | message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at | ||
| 2177 | ) | ||
| 2178 | VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0, | ||
| 2179 | '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z') | ||
| 2180 | `; | ||
| 2181 | } | ||
| 2182 | } | ||
| 2183 | |||
| 2166 | const turns: ReadonlyArray<{ | 2184 | const turns: ReadonlyArray<{ |
| 2167 | turn: string; | 2185 | turn: string; |
| 2168 | pendingMessage: string | null; | 2186 | pendingMessage: string | null; |
| ... | @@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = | ... | @@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = |
| 2396 | }), | 2414 | }), |
| 2397 | ); | 2415 | ); |
| 2398 | 2416 | ||
| 2417 | it.effect("keeps imported history on the oldest page after resumed turns", () => | ||
| 2418 | Effect.gen(function* () { | ||
| 2419 | yield* seedFanOutThread({ importedMessageCount: 12 }); | ||
| 2420 | const snapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 2421 | |||
| 2422 | const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 }); | ||
| 2423 | assert.equal(completePage._tag, "Some"); | ||
| 2424 | if (completePage._tag !== "Some") return; | ||
| 2425 | assert.equal( | ||
| 2426 | completePage.value.thread.messages.filter((message) => message.id.startsWith("import:")) | ||
| 2427 | .length, | ||
| 2428 | 12, | ||
| 2429 | ); | ||
| 2430 | assert.equal(completePage.value.page?.hasMore, false); | ||
| 2431 | assert.equal(completePage.value.page?.beforeCursor, null); | ||
| 2432 | |||
| 2433 | const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); | ||
| 2434 | assert.equal(recentPage._tag, "Some"); | ||
| 2435 | if (recentPage._tag !== "Some") return; | ||
| 2436 | assert.equal( | ||
| 2437 | recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")), | ||
| 2438 | false, | ||
| 2439 | ); | ||
| 2440 | const cursor = recentPage.value.page?.beforeCursor; | ||
| 2441 | assert.notEqual(cursor, null); | ||
| 2442 | assert.notEqual(cursor, undefined); | ||
| 2443 | if (cursor === null || cursor === undefined) return; | ||
| 2444 | |||
| 2445 | const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { | ||
| 2446 | turnLimit: 1, | ||
| 2447 | beforeCursor: cursor, | ||
| 2448 | }); | ||
| 2449 | assert.equal(oldestPage._tag, "Some"); | ||
| 2450 | if (oldestPage._tag !== "Some") return; | ||
| 2451 | |||
| 2452 | const importedIds = oldestPage.value.thread.messages | ||
| 2453 | .map((message) => message.id) | ||
| 2454 | .filter((messageId) => messageId.startsWith("import:")); | ||
| 2455 | assert.equal(importedIds.length, 12); | ||
| 2456 | assert.equal(new Set(importedIds).size, 12); | ||
| 2457 | assert.equal(oldestPage.value.page?.hasMore, false); | ||
| 2458 | assert.equal(oldestPage.value.page?.beforeCursor, null); | ||
| 2459 | }), | ||
| 2460 | ); | ||
| 2461 | |||
| 2399 | it.effect("a cursor for a different thread degrades to the first page", () => | 2462 | it.effect("a cursor for a different thread degrades to the first page", () => |
| 2400 | Effect.gen(function* () { | 2463 | Effect.gen(function* () { |
| 2401 | yield* seedFanOutThread(); | 2464 | yield* seedFanOutThread(); |
| ... | @@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = | ... | @@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = |
| 2779 | }), | 2842 | }), |
| 2780 | ); | 2843 | ); |
| 2781 | }); | 2844 | }); |
| 2845 | |||
| 2846 | projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { | ||
| 2847 | const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 2848 | const source: AgentSessionImportSource = { | ||
| 2849 | provider: "codex", | ||
| 2850 | providerInstanceId: ProviderInstanceId.make("codex-home"), | ||
| 2851 | providerSessionId: "native-session", | ||
| 2852 | filePath: "/tmp/transcript.jsonl", | ||
| 2853 | size: 128, | ||
| 2854 | mtimeMs: 1_700_000_000_000, | ||
| 2855 | device: 1, | ||
| 2856 | inode: 2, | ||
| 2857 | birthtimeMs: 1_699_000_000_000, | ||
| 2858 | }; | ||
| 2859 | |||
| 2860 | const seedImportedSession = Effect.fn("seedImportedSession")(function* ( | ||
| 2861 | projectId: ProjectId, | ||
| 2862 | source: AgentSessionImportSource, | ||
| 2863 | ) { | ||
| 2864 | const sql = yield* SqlClient.SqlClient; | ||
| 2865 | const threadId = ThreadId.make( | ||
| 2866 | `import:${source.providerInstanceId}:${source.providerSessionId}`, | ||
| 2867 | ); | ||
| 2868 | const timestamp = "2026-03-02T00:00:00.000Z"; | ||
| 2869 | yield* sql` | ||
| 2870 | INSERT OR IGNORE INTO projection_projects ( | ||
| 2871 | project_id, title, workspace_root, scripts_json, created_at, updated_at | ||
| 2872 | ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]', | ||
| 2873 | ${timestamp}, ${timestamp}) | ||
| 2874 | `; | ||
| 2875 | yield* sql` | ||
| 2876 | INSERT INTO projection_threads ( | ||
| 2877 | thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, | ||
| 2878 | created_at, updated_at | ||
| 2879 | ) VALUES (${threadId}, ${projectId}, 'Imported thread', | ||
| 2880 | ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })}, | ||
| 2881 | 'full-access', 'default', | ||
| 2882 | ${timestamp}, ${timestamp}) | ||
| 2883 | `; | ||
| 2884 | yield* sql` | ||
| 2885 | INSERT INTO provider_session_runtime ( | ||
| 2886 | thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, | ||
| 2887 | last_seen_at, resume_cursor_json, runtime_payload_json | ||
| 2888 | ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId}, | ||
| 2889 | ${source.provider}, 'full-access', 'stopped', ${timestamp}, | ||
| 2890 | ${encodeJson({ threadId: source.providerSessionId })}, | ||
| 2891 | ${encodeJson({ importedTranscripts: [source] })}) | ||
| 2892 | `; | ||
| 2893 | yield* sql` | ||
| 2894 | INSERT INTO projection_thread_messages ( | ||
| 2895 | message_id, thread_id, role, text, is_streaming, created_at, updated_at | ||
| 2896 | ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0, | ||
| 2897 | ${timestamp}, ${timestamp}) | ||
| 2898 | `; | ||
| 2899 | return { threadId, source }; | ||
| 2900 | }); | ||
| 2901 | |||
| 2902 | it.effect("reads completed source copies without decoding message bodies", () => | ||
| 2903 | Effect.gen(function* () { | ||
| 2904 | const sql = yield* SqlClient.SqlClient; | ||
| 2905 | const query = yield* ProjectionSnapshotQuery; | ||
| 2906 | const projectId = ProjectId.make("project-import-metadata"); | ||
| 2907 | const imported = yield* seedImportedSession(projectId, source); | ||
| 2908 | const copiedSource = { | ||
| 2909 | ...source, | ||
| 2910 | filePath: "/tmp/transcript-copy.jsonl", | ||
| 2911 | mtimeMs: null, | ||
| 2912 | inode: null, | ||
| 2913 | birthtimeMs: null, | ||
| 2914 | }; | ||
| 2915 | yield* sql` | ||
| 2916 | UPDATE provider_session_runtime | ||
| 2917 | SET runtime_payload_json = ${encodeJson({ | ||
| 2918 | cwd: "/tmp/imported-project", | ||
| 2919 | importedTranscripts: [source, copiedSource], | ||
| 2920 | })} | ||
| 2921 | WHERE thread_id = ${imported.threadId} | ||
| 2922 | `; | ||
| 2923 | yield* sql` | ||
| 2924 | UPDATE projection_thread_messages SET attachments_json = 'not-json' | ||
| 2925 | WHERE thread_id = ${imported.threadId} | ||
| 2926 | `; | ||
| 2927 | |||
| 2928 | const counter = makeSqlStatementCounter(); | ||
| 2929 | const sources = yield* query | ||
| 2930 | .getImportedAgentSessionSources(projectId) | ||
| 2931 | .pipe(Effect.withTracer(counter.tracer)); | ||
| 2932 | assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]); | ||
| 2933 | assert.equal(counter.count(), 1); | ||
| 2934 | }), | ||
| 2935 | ); | ||
| 2936 | |||
| 2937 | it.effect("requires active project threads, a binding, and an imported message", () => | ||
| 2938 | Effect.gen(function* () { | ||
| 2939 | const sql = yield* SqlClient.SqlClient; | ||
| 2940 | const query = yield* ProjectionSnapshotQuery; | ||
| 2941 | const projectId = ProjectId.make("project-import-completion"); | ||
| 2942 | const completed = yield* seedImportedSession(projectId, { | ||
| 2943 | ...source, | ||
| 2944 | providerSessionId: "completed", | ||
| 2945 | }); | ||
| 2946 | yield* sql` | ||
| 2947 | UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`} | ||
| 2948 | WHERE thread_id = ${completed.threadId} | ||
| 2949 | `; | ||
| 2950 | const partials = yield* Effect.forEach( | ||
| 2951 | [ | ||
| 2952 | "no-binding", | ||
| 2953 | "no-history", | ||
| 2954 | "no-imported-message", | ||
| 2955 | "wrong-message-thread", | ||
| 2956 | "archived", | ||
| 2957 | "deleted", | ||
| 2958 | ], | ||
| 2959 | (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }), | ||
| 2960 | ); | ||
| 2961 | const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] = | ||
| 2962 | partials; | ||
| 2963 | assert.isDefined(noBinding); | ||
| 2964 | assert.isDefined(noHistory); | ||
| 2965 | assert.isDefined(noImportedMessage); | ||
| 2966 | assert.isDefined(wrongMessageThread); | ||
| 2967 | assert.isDefined(archived); | ||
| 2968 | assert.isDefined(deleted); | ||
| 2969 | yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`; | ||
| 2970 | yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`; | ||
| 2971 | yield* sql` | ||
| 2972 | UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`} | ||
| 2973 | WHERE thread_id = ${noImportedMessage.threadId} | ||
| 2974 | `; | ||
| 2975 | yield* sql` | ||
| 2976 | UPDATE projection_thread_messages SET thread_id = 'unrelated-thread' | ||
| 2977 | WHERE thread_id = ${wrongMessageThread.threadId} | ||
| 2978 | `; | ||
| 2979 | yield* sql` | ||
| 2980 | UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z' | ||
| 2981 | WHERE thread_id = ${archived.threadId} | ||
| 2982 | `; | ||
| 2983 | yield* sql` | ||
| 2984 | UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z' | ||
| 2985 | WHERE thread_id = ${deleted.threadId} | ||
| 2986 | `; | ||
| 2987 | const otherProjectId = ProjectId.make("project-import-other"); | ||
| 2988 | const otherProject = yield* seedImportedSession(otherProjectId, { | ||
| 2989 | ...source, | ||
| 2990 | providerSessionId: "other-project", | ||
| 2991 | }); | ||
| 2992 | const deletedProjectId = ProjectId.make("project-import-deleted"); | ||
| 2993 | yield* seedImportedSession(deletedProjectId, { | ||
| 2994 | ...source, | ||
| 2995 | providerSessionId: "deleted-project", | ||
| 2996 | }); | ||
| 2997 | yield* sql` | ||
| 2998 | UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z' | ||
| 2999 | WHERE project_id = ${deletedProjectId} | ||
| 3000 | `; | ||
| 3001 | |||
| 3002 | assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]); | ||
| 3003 | assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]); | ||
| 3004 | assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []); | ||
| 3005 | assert.deepEqual( | ||
| 3006 | yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")), | ||
| 3007 | [], | ||
| 3008 | ); | ||
| 3009 | }), | ||
| 3010 | ); | ||
| 3011 | |||
| 3012 | it.effect("keeps original sources when the current runtime provider and cursor change", () => | ||
| 3013 | Effect.gen(function* () { | ||
| 3014 | const sql = yield* SqlClient.SqlClient; | ||
| 3015 | const query = yield* ProjectionSnapshotQuery; | ||
| 3016 | const projectId = ProjectId.make("project-import-switched"); | ||
| 3017 | const imported = yield* seedImportedSession(projectId, { | ||
| 3018 | ...source, | ||
| 3019 | provider: "claudeAgent", | ||
| 3020 | providerInstanceId: ProviderInstanceId.make("claude-original"), | ||
| 3021 | providerSessionId: "original-session", | ||
| 3022 | }); | ||
| 3023 | yield* sql` | ||
| 3024 | UPDATE provider_session_runtime | ||
| 3025 | SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex', | ||
| 3026 | resume_cursor_json = '{"threadId":"new-session"}' | ||
| 3027 | WHERE thread_id = ${imported.threadId} | ||
| 3028 | `; | ||
| 3029 | |||
| 3030 | assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]); | ||
| 3031 | }), | ||
| 3032 | ); | ||
| 3033 | |||
| 3034 | it.effect("skips invalid source payloads and entries without dropping valid sources", () => | ||
| 3035 | Effect.gen(function* () { | ||
| 3036 | const sql = yield* SqlClient.SqlClient; | ||
| 3037 | const query = yield* ProjectionSnapshotQuery; | ||
| 3038 | const projectId = ProjectId.make("project-import-invalid"); | ||
| 3039 | const imported = yield* seedImportedSession(projectId, { | ||
| 3040 | ...source, | ||
| 3041 | providerSessionId: "a-invalid", | ||
| 3042 | }); | ||
| 3043 | const valid = yield* seedImportedSession(projectId, { | ||
| 3044 | ...source, | ||
| 3045 | providerSessionId: "z-valid", | ||
| 3046 | }); | ||
| 3047 | for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) { | ||
| 3048 | yield* sql` | ||
| 3049 | UPDATE provider_session_runtime SET runtime_payload_json = ${payload} | ||
| 3050 | WHERE thread_id = ${imported.threadId} | ||
| 3051 | `; | ||
| 3052 | assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); | ||
| 3053 | } | ||
| 3054 | yield* sql` | ||
| 3055 | UPDATE provider_session_runtime SET runtime_payload_json = X'FF' | ||
| 3056 | WHERE thread_id = ${imported.threadId} | ||
| 3057 | `; | ||
| 3058 | assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); | ||
| 3059 | |||
| 3060 | yield* sql` | ||
| 3061 | UPDATE provider_session_runtime | ||
| 3062 | SET runtime_payload_json = ${encodeJson({ | ||
| 3063 | importedTranscripts: [ | ||
| 3064 | null, | ||
| 3065 | {}, | ||
| 3066 | { ...imported.source, size: -1 }, | ||
| 3067 | { ...imported.source, provider: "cursor" }, | ||
| 3068 | { ...imported.source, providerInstanceId: "wrong-instance" }, | ||
| 3069 | { ...imported.source, providerSessionId: "wrong-session" }, | ||
| 3070 | imported.source, | ||
| 3071 | ], | ||
| 3072 | })} | ||
| 3073 | WHERE thread_id = ${imported.threadId} | ||
| 3074 | `; | ||
| 3075 | assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]); | ||
| 3076 | }), | ||
| 3077 | ); | ||
| 3078 | }); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+89-20| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | AgentSessionImportSource, | ||
| 2 | ApprovalRequestId, | 3 | ApprovalRequestId, |
| 3 | ChatAttachment, | 4 | ChatAttachment, |
| 4 | CheckpointRef, | 5 | CheckpointRef, |
| ... | @@ -75,6 +76,14 @@ import { | ... | @@ -75,6 +76,14 @@ import { |
| 75 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); | 76 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); |
| 76 | const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); | 77 | const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); |
| 77 | const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); | 78 | const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); |
| 79 | const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( | ||
| 80 | Schema.fromJsonString( | ||
| 81 | Schema.Struct({ | ||
| 82 | importedTranscripts: Schema.Array(Schema.Unknown), | ||
| 83 | }), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource); | ||
| 78 | // Keep detail reads consistent with the in-memory projector's retained | 87 | // Keep detail reads consistent with the in-memory projector's retained |
| 79 | // activity window. Applying the limit in SQL avoids decoding an unbounded | 88 | // activity window. Applying the limit in SQL avoids decoding an unbounded |
| 80 | // payload_json set before the projector can enforce that invariant. | 89 | // payload_json set before the projector can enforce that invariant. |
| ... | @@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({ | ... | @@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({ |
| 164 | const ProjectIdLookupInput = Schema.Struct({ | 173 | const ProjectIdLookupInput = Schema.Struct({ |
| 165 | projectId: ProjectId, | 174 | projectId: ProjectId, |
| 166 | }); | 175 | }); |
| 176 | const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ | ||
| 177 | threadId: ThreadId, | ||
| 178 | runtimePayload: Schema.Unknown, | ||
| 179 | }); | ||
| 167 | const ThreadIdLookupInput = Schema.Struct({ | 180 | const ThreadIdLookupInput = Schema.Struct({ |
| 168 | threadId: ThreadId, | 181 | threadId: ThreadId, |
| 169 | }); | 182 | }); |
| ... | @@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 983 | `, | 996 | `, |
| 984 | }); | 997 | }); |
| 985 | 998 | ||
| 999 | const listImportedAgentSessionSourceRows = SqlSchema.findAll({ | ||
| 1000 | Request: ProjectIdLookupInput, | ||
| 1001 | Result: ProjectionImportedAgentSessionSourcesRowSchema, | ||
| 1002 | execute: ({ projectId }) => | ||
| 1003 | sql` | ||
| 1004 | SELECT | ||
| 1005 | threads.thread_id AS "threadId", | ||
| 1006 | runtime.runtime_payload_json AS "runtimePayload" | ||
| 1007 | FROM projection_threads AS threads | ||
| 1008 | INNER JOIN projection_projects AS projects | ||
| 1009 | ON projects.project_id = threads.project_id | ||
| 1010 | INNER JOIN provider_session_runtime AS runtime | ||
| 1011 | ON runtime.thread_id = threads.thread_id | ||
| 1012 | WHERE threads.project_id = ${projectId} | ||
| 1013 | AND threads.deleted_at IS NULL | ||
| 1014 | AND threads.archived_at IS NULL | ||
| 1015 | AND projects.deleted_at IS NULL | ||
| 1016 | AND EXISTS ( | ||
| 1017 | SELECT 1 | ||
| 1018 | FROM projection_thread_messages AS messages | ||
| 1019 | WHERE messages.thread_id = threads.thread_id | ||
| 1020 | AND messages.message_id GLOB 'import:*' | ||
| 1021 | ) | ||
| 1022 | ORDER BY threads.thread_id ASC | ||
| 1023 | `, | ||
| 1024 | }); | ||
| 1025 | |||
| 986 | const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ | 1026 | const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ |
| 987 | Request: ThreadIdLookupInput, | 1027 | Request: ThreadIdLookupInput, |
| 988 | Result: ProjectionThreadCheckpointContextThreadRowSchema, | 1028 | Result: ProjectionThreadCheckpointContextThreadRowSchema, |
| ... | @@ -2663,6 +2703,33 @@ pending_approval_requests AS ( | ... | @@ -2663,6 +2703,33 @@ pending_approval_requests AS ( |
| 2663 | Effect.map(Option.map((row) => row.threadId)), | 2703 | Effect.map(Option.map((row) => row.threadId)), |
| 2664 | ); | 2704 | ); |
| 2665 | 2705 | ||
| 2706 | const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] = | ||
| 2707 | Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) { | ||
| 2708 | const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe( | ||
| 2709 | Effect.mapError( | ||
| 2710 | toPersistenceSqlOrDecodeError( | ||
| 2711 | "ProjectionSnapshotQuery.getImportedAgentSessionSources:query", | ||
| 2712 | "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows", | ||
| 2713 | ), | ||
| 2714 | ), | ||
| 2715 | ); | ||
| 2716 | return rows.flatMap((row) => { | ||
| 2717 | const payload = decodeImportedTranscriptsPayload(row.runtimePayload); | ||
| 2718 | if (Option.isNone(payload)) return []; | ||
| 2719 | return payload.value.importedTranscripts.flatMap((entry) => { | ||
| 2720 | const source = decodeAgentSessionImportSource(entry); | ||
| 2721 | if ( | ||
| 2722 | Option.isNone(source) || | ||
| 2723 | row.threadId !== | ||
| 2724 | `import:${source.value.providerInstanceId}:${source.value.providerSessionId}` | ||
| 2725 | ) { | ||
| 2726 | return []; | ||
| 2727 | } | ||
| 2728 | return [{ threadId: row.threadId, source: source.value }]; | ||
| 2729 | }); | ||
| 2730 | }); | ||
| 2731 | }); | ||
| 2732 | |||
| 2666 | const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( | 2733 | const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( |
| 2667 | threadId, | 2734 | threadId, |
| 2668 | ) => | 2735 | ) => |
| ... | @@ -3151,17 +3218,35 @@ pending_approval_requests AS ( | ... | @@ -3151,17 +3218,35 @@ pending_approval_requests AS ( |
| 3151 | ); | 3218 | ); |
| 3152 | 3219 | ||
| 3153 | const oldest = windowRows[0]; | 3220 | const oldest = windowRows[0]; |
| 3221 | const hasMore = | ||
| 3222 | oldest !== undefined && | ||
| 3223 | (yield* listTurnWindowRows({ | ||
| 3224 | threadId, | ||
| 3225 | beforeAnchorAt: oldest.anchorAt, | ||
| 3226 | beforeTurnKey: oldest.turnKey, | ||
| 3227 | userTurnLimit: 1, | ||
| 3228 | maxRawTurns: 1, | ||
| 3229 | }).pipe( | ||
| 3230 | Effect.mapError( | ||
| 3231 | toPersistenceSqlOrDecodeError( | ||
| 3232 | "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", | ||
| 3233 | "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", | ||
| 3234 | ), | ||
| 3235 | ), | ||
| 3236 | )).length > 0; | ||
| 3154 | // An empty window (no turns before the cursor, or a thread with no | 3237 | // An empty window (no turns before the cursor, or a thread with no |
| 3155 | // turns at all) still returns thread metadata with empty collections | 3238 | // turns at all) still returns thread metadata with empty collections |
| 3156 | // for turn-linked rows; turnless rows are bounded to the same empty | 3239 | // for turn-linked rows; turnless rows are bounded to the same empty |
| 3157 | // range. The first page of a turnless thread stays unwindowed so | 3240 | // range. The first page of a turnless thread stays unwindowed so |
| 3158 | // pre-turn content (e.g. a just-created thread) is not hidden. | 3241 | // pre-turn content (e.g. a just-created thread) is not hidden. Once |
| 3242 | // paging reaches the oldest turn, include turnless messages before | ||
| 3243 | // the first turn, such as history imported from a provider session. | ||
| 3159 | const bounds: ThreadDetailBounds | undefined = | 3244 | const bounds: ThreadDetailBounds | undefined = |
| 3160 | oldest === undefined && cursor === null | 3245 | oldest === undefined && cursor === null |
| 3161 | ? undefined | 3246 | ? undefined |
| 3162 | : { | 3247 | : { |
| 3163 | minAnchorAt: oldest?.anchorAt ?? "", | 3248 | minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "", |
| 3164 | minTurnKey: oldest?.turnKey ?? "", | 3249 | minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "", |
| 3165 | beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, | 3250 | beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, |
| 3166 | beforeTurnKey: cursor?.beforeTurnId ?? "", | 3251 | beforeTurnKey: cursor?.beforeTurnId ?? "", |
| 3167 | }; | 3252 | }; |
| ... | @@ -3178,23 +3263,6 @@ pending_approval_requests AS ( | ... | @@ -3178,23 +3263,6 @@ pending_approval_requests AS ( |
| 3178 | return Option.none<OrchestrationThreadDetailSnapshot>(); | 3263 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3179 | } | 3264 | } |
| 3180 | 3265 | ||
| 3181 | const hasMore = | ||
| 3182 | oldest !== undefined && | ||
| 3183 | (yield* listTurnWindowRows({ | ||
| 3184 | threadId, | ||
| 3185 | beforeAnchorAt: oldest.anchorAt, | ||
| 3186 | beforeTurnKey: oldest.turnKey, | ||
| 3187 | userTurnLimit: 1, | ||
| 3188 | maxRawTurns: 1, | ||
| 3189 | }).pipe( | ||
| 3190 | Effect.mapError( | ||
| 3191 | toPersistenceSqlOrDecodeError( | ||
| 3192 | "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", | ||
| 3193 | "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", | ||
| 3194 | ), | ||
| 3195 | ), | ||
| 3196 | )).length > 0; | ||
| 3197 | |||
| 3198 | const { snapshotSequence } = yield* getSnapshotSequence(); | 3266 | const { snapshotSequence } = yield* getSnapshotSequence(); |
| 3199 | const watermarkRow = yield* getThreadEventWatermarkRow({ | 3267 | const watermarkRow = yield* getThreadEventWatermarkRow({ |
| 3200 | threadId, | 3268 | threadId, |
| ... | @@ -3253,6 +3321,7 @@ pending_approval_requests AS ( | ... | @@ -3253,6 +3321,7 @@ pending_approval_requests AS ( |
| 3253 | getActiveProjectByWorkspaceRoot, | 3321 | getActiveProjectByWorkspaceRoot, |
| 3254 | getProjectShellById, | 3322 | getProjectShellById, |
| 3255 | getFirstActiveThreadIdByProjectId, | 3323 | getFirstActiveThreadIdByProjectId, |
| 3324 | getImportedAgentSessionSources, | ||
| 3256 | getThreadCheckpointContext, | 3325 | getThreadCheckpointContext, |
| 3257 | getFullThreadDiffContext, | 3326 | getFullThreadDiffContext, |
| 3258 | getThreadShellById, | 3327 | getThreadShellById, |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+10| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module ProjectionSnapshotQuery | 7 | * @module ProjectionSnapshotQuery |
| 8 | */ | 8 | */ |
| 9 | import type { | 9 | import type { |
| 10 | AgentSessionImportSource, | ||
| 10 | ApprovalRequestId, | 11 | ApprovalRequestId, |
| 11 | CheckpointRef, | 12 | CheckpointRef, |
| 12 | OrchestrationCheckpointSummary, | 13 | OrchestrationCheckpointSummary, |
| ... | @@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape { | ... | @@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape { |
| 170 | projectId: ProjectId, | 171 | projectId: ProjectId, |
| 171 | ) => Effect.Effect<Option.Option<ThreadId>, ProjectionRepositoryError>; | 172 | ) => Effect.Effect<Option.Option<ThreadId>, ProjectionRepositoryError>; |
| 172 | 173 | ||
| 174 | /** Read completed import sources without loading thread history. */ | ||
| 175 | readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect< | ||
| 176 | ReadonlyArray<{ | ||
| 177 | readonly threadId: ThreadId; | ||
| 178 | readonly source: AgentSessionImportSource; | ||
| 179 | }>, | ||
| 180 | ProjectionRepositoryError | ||
| 181 | >; | ||
| 182 | |||
| 173 | /** | 183 | /** |
| 174 | * Read the checkpoint context needed to resolve a single thread diff. | 184 | * Read the checkpoint context needed to resolve a single thread diff. |
| 175 | */ | 185 | */ |
apps/server/src/orchestration/decider.import.test.ts created+509| ... | @@ -0,0 +1,509 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | CommandId, | ||
| 5 | EventId, | ||
| 6 | MessageId, | ||
| 7 | ProjectId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | ThreadId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as TestClock from "effect/testing/TestClock"; | ||
| 13 | |||
| 14 | import { decideOrchestrationCommand } from "./decider.ts"; | ||
| 15 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | ||
| 16 | |||
| 17 | it.layer(NodeServices.layer)("thread history import", (it) => { | ||
| 18 | it.effect("marks imported thread creation without changing live creation", () => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 21 | const projectId = ProjectId.make("project-1"); | ||
| 22 | const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 23 | sequence: 1, | ||
| 24 | eventId: EventId.make("event-project-created"), | ||
| 25 | aggregateKind: "project", | ||
| 26 | aggregateId: projectId, | ||
| 27 | type: "project.created", | ||
| 28 | occurredAt: createdAt, | ||
| 29 | commandId: CommandId.make("command-project-created"), | ||
| 30 | causationEventId: null, | ||
| 31 | correlationId: CommandId.make("command-project-created"), | ||
| 32 | metadata: {}, | ||
| 33 | payload: { | ||
| 34 | projectId, | ||
| 35 | title: "Project", | ||
| 36 | workspaceRoot: "/tmp/project", | ||
| 37 | defaultModelSelection: null, | ||
| 38 | scripts: [], | ||
| 39 | createdAt, | ||
| 40 | updatedAt: createdAt, | ||
| 41 | }, | ||
| 42 | }); | ||
| 43 | const makeCreateCommand = (threadId: ThreadId) => ({ | ||
| 44 | type: "thread.create" as const, | ||
| 45 | commandId: CommandId.make(`command-create-${threadId}`), | ||
| 46 | threadId, | ||
| 47 | projectId, | ||
| 48 | title: "Imported thread", | ||
| 49 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 50 | runtimeMode: "full-access" as const, | ||
| 51 | interactionMode: "default" as const, | ||
| 52 | branch: null, | ||
| 53 | worktreePath: null, | ||
| 54 | createdAt, | ||
| 55 | }); | ||
| 56 | |||
| 57 | const imported = yield* decideOrchestrationCommand({ | ||
| 58 | command: { | ||
| 59 | ...makeCreateCommand(ThreadId.make("import:codex:session-1")), | ||
| 60 | historyImport: true, | ||
| 61 | }, | ||
| 62 | readModel, | ||
| 63 | }); | ||
| 64 | const live = yield* decideOrchestrationCommand({ | ||
| 65 | command: makeCreateCommand(ThreadId.make("live-thread")), | ||
| 66 | readModel, | ||
| 67 | }); | ||
| 68 | |||
| 69 | expect(imported).toMatchObject({ | ||
| 70 | type: "thread.created", | ||
| 71 | metadata: { historyImport: true }, | ||
| 72 | }); | ||
| 73 | expect(live).toMatchObject({ type: "thread.created" }); | ||
| 74 | expect(live).not.toMatchObject({ metadata: { historyImport: true } }); | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | |||
| 78 | it.effect("settles imported messages at the latest absolute timestamp", () => | ||
| 79 | Effect.gen(function* () { | ||
| 80 | const createdAt = "2026-08-24T10:30:00.000+02:00"; | ||
| 81 | const threadId = ThreadId.make("import:codex:session-1"); | ||
| 82 | const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 83 | sequence: 1, | ||
| 84 | eventId: EventId.make("event-thread-created"), | ||
| 85 | aggregateKind: "thread", | ||
| 86 | aggregateId: threadId, | ||
| 87 | type: "thread.created", | ||
| 88 | occurredAt: createdAt, | ||
| 89 | commandId: CommandId.make("command-thread-created"), | ||
| 90 | causationEventId: null, | ||
| 91 | correlationId: CommandId.make("command-thread-created"), | ||
| 92 | metadata: {}, | ||
| 93 | payload: { | ||
| 94 | threadId, | ||
| 95 | projectId: ProjectId.make("project-1"), | ||
| 96 | title: "Imported thread", | ||
| 97 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 98 | runtimeMode: "full-access", | ||
| 99 | interactionMode: "default", | ||
| 100 | branch: null, | ||
| 101 | worktreePath: null, | ||
| 102 | createdAt, | ||
| 103 | updatedAt: createdAt, | ||
| 104 | }, | ||
| 105 | }); | ||
| 106 | |||
| 107 | const events = yield* decideOrchestrationCommand({ | ||
| 108 | command: { | ||
| 109 | type: "thread.history.import", | ||
| 110 | commandId: CommandId.make("command-import-history"), | ||
| 111 | threadId, | ||
| 112 | messages: [ | ||
| 113 | { | ||
| 114 | messageId: MessageId.make(`${threadId}:000000`), | ||
| 115 | role: "user", | ||
| 116 | text: "Fix the bug", | ||
| 117 | createdAt, | ||
| 118 | }, | ||
| 119 | { | ||
| 120 | messageId: MessageId.make(`${threadId}:000001`), | ||
| 121 | role: "assistant", | ||
| 122 | text: "Fixed", | ||
| 123 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 124 | }, | ||
| 125 | ], | ||
| 126 | }, | ||
| 127 | readModel, | ||
| 128 | }); | ||
| 129 | |||
| 130 | expect(events).toMatchObject([ | ||
| 131 | { | ||
| 132 | type: "thread.message-sent", | ||
| 133 | metadata: { historyImport: true }, | ||
| 134 | payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false }, | ||
| 135 | }, | ||
| 136 | { | ||
| 137 | type: "thread.message-sent", | ||
| 138 | metadata: { historyImport: true }, | ||
| 139 | payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false }, | ||
| 140 | }, | ||
| 141 | { | ||
| 142 | type: "thread.settled", | ||
| 143 | metadata: { historyImport: true }, | ||
| 144 | occurredAt: "2026-08-24T09:00:00.000Z", | ||
| 145 | payload: { | ||
| 146 | settledAt: "2026-08-24T09:00:00.000Z", | ||
| 147 | updatedAt: "2026-08-24T09:00:00.000Z", | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | ]); | ||
| 151 | |||
| 152 | let projected = readModel; | ||
| 153 | const plannedEvents = Array.isArray(events) ? events : [events]; | ||
| 154 | for (const [index, event] of plannedEvents.entries()) { | ||
| 155 | projected = yield* projectEvent(projected, { ...event, sequence: index + 2 }); | ||
| 156 | } | ||
| 157 | projected = yield* projectEvent(projected, { | ||
| 158 | sequence: 5, | ||
| 159 | eventId: EventId.make("event-import-reverted"), | ||
| 160 | aggregateKind: "thread", | ||
| 161 | aggregateId: threadId, | ||
| 162 | type: "thread.reverted", | ||
| 163 | occurredAt: "2026-08-24T10:02:00.000Z", | ||
| 164 | commandId: CommandId.make("command-import-reverted"), | ||
| 165 | causationEventId: null, | ||
| 166 | correlationId: CommandId.make("command-import-reverted"), | ||
| 167 | metadata: {}, | ||
| 168 | payload: { threadId, turnCount: 0 }, | ||
| 169 | }); | ||
| 170 | expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([ | ||
| 171 | "Fix the bug", | ||
| 172 | "Fixed", | ||
| 173 | ]); | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | |||
| 177 | it.effect("allows a thread with a newly imported user message to be settled", () => | ||
| 178 | Effect.gen(function* () { | ||
| 179 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 180 | yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z")); | ||
| 181 | const threadId = ThreadId.make("import:codex:session-1"); | ||
| 182 | const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 183 | sequence: 1, | ||
| 184 | eventId: EventId.make("event-import-thread-created"), | ||
| 185 | aggregateKind: "thread", | ||
| 186 | aggregateId: threadId, | ||
| 187 | type: "thread.created", | ||
| 188 | occurredAt: createdAt, | ||
| 189 | commandId: CommandId.make("command-import-thread-created"), | ||
| 190 | causationEventId: null, | ||
| 191 | correlationId: CommandId.make("command-import-thread-created"), | ||
| 192 | metadata: {}, | ||
| 193 | payload: { | ||
| 194 | threadId, | ||
| 195 | projectId: ProjectId.make("project-1"), | ||
| 196 | title: "Imported thread", | ||
| 197 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 198 | runtimeMode: "full-access", | ||
| 199 | interactionMode: "default", | ||
| 200 | branch: null, | ||
| 201 | worktreePath: null, | ||
| 202 | createdAt, | ||
| 203 | updatedAt: createdAt, | ||
| 204 | }, | ||
| 205 | }); | ||
| 206 | const readModel = yield* projectEvent(withThread, { | ||
| 207 | sequence: 2, | ||
| 208 | eventId: EventId.make("event-import-user-message"), | ||
| 209 | aggregateKind: "thread", | ||
| 210 | aggregateId: threadId, | ||
| 211 | type: "thread.message-sent", | ||
| 212 | occurredAt: createdAt, | ||
| 213 | commandId: CommandId.make("command-import-user-message"), | ||
| 214 | causationEventId: null, | ||
| 215 | correlationId: CommandId.make("command-import-user-message"), | ||
| 216 | metadata: { historyImport: true }, | ||
| 217 | payload: { | ||
| 218 | threadId, | ||
| 219 | messageId: MessageId.make("import:codex:session-1:0"), | ||
| 220 | role: "user", | ||
| 221 | text: "Existing prompt", | ||
| 222 | turnId: null, | ||
| 223 | streaming: false, | ||
| 224 | createdAt, | ||
| 225 | updatedAt: createdAt, | ||
| 226 | }, | ||
| 227 | }); | ||
| 228 | |||
| 229 | const result = yield* decideOrchestrationCommand({ | ||
| 230 | command: { | ||
| 231 | type: "thread.settle", | ||
| 232 | commandId: CommandId.make("command-settle-imported-thread"), | ||
| 233 | threadId, | ||
| 234 | }, | ||
| 235 | readModel, | ||
| 236 | }); | ||
| 237 | |||
| 238 | expect(result).toMatchObject({ type: "thread.settled" }); | ||
| 239 | }), | ||
| 240 | ); | ||
| 241 | |||
| 242 | it.effect("rejects history import after a client message reaches the thread", () => | ||
| 243 | Effect.gen(function* () { | ||
| 244 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 245 | const liveMessageAt = "2026-08-24T10:02:00.000Z"; | ||
| 246 | const threadId = ThreadId.make("import:codex:client-race"); | ||
| 247 | const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 248 | sequence: 1, | ||
| 249 | eventId: EventId.make("event-client-race-thread-created"), | ||
| 250 | aggregateKind: "thread", | ||
| 251 | aggregateId: threadId, | ||
| 252 | type: "thread.created", | ||
| 253 | occurredAt: createdAt, | ||
| 254 | commandId: CommandId.make("command-client-race-thread-created"), | ||
| 255 | causationEventId: null, | ||
| 256 | correlationId: CommandId.make("command-client-race-thread-created"), | ||
| 257 | metadata: {}, | ||
| 258 | payload: { | ||
| 259 | threadId, | ||
| 260 | projectId: ProjectId.make("project-1"), | ||
| 261 | title: "Imported thread", | ||
| 262 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 263 | runtimeMode: "full-access", | ||
| 264 | interactionMode: "default", | ||
| 265 | branch: null, | ||
| 266 | worktreePath: null, | ||
| 267 | createdAt, | ||
| 268 | updatedAt: createdAt, | ||
| 269 | }, | ||
| 270 | }); | ||
| 271 | const readModel = yield* projectEvent(withThread, { | ||
| 272 | sequence: 2, | ||
| 273 | eventId: EventId.make("event-client-race-message"), | ||
| 274 | aggregateKind: "thread", | ||
| 275 | aggregateId: threadId, | ||
| 276 | type: "thread.message-sent", | ||
| 277 | occurredAt: liveMessageAt, | ||
| 278 | commandId: CommandId.make("command-client-race-message"), | ||
| 279 | causationEventId: null, | ||
| 280 | correlationId: CommandId.make("command-client-race-message"), | ||
| 281 | metadata: {}, | ||
| 282 | payload: { | ||
| 283 | threadId, | ||
| 284 | messageId: MessageId.make("client-race-message"), | ||
| 285 | role: "user", | ||
| 286 | text: "Start live work", | ||
| 287 | turnId: null, | ||
| 288 | streaming: false, | ||
| 289 | createdAt: liveMessageAt, | ||
| 290 | updatedAt: liveMessageAt, | ||
| 291 | }, | ||
| 292 | }); | ||
| 293 | |||
| 294 | const error = yield* Effect.flip( | ||
| 295 | decideOrchestrationCommand({ | ||
| 296 | command: { | ||
| 297 | type: "thread.history.import", | ||
| 298 | commandId: CommandId.make("command-client-race-import"), | ||
| 299 | threadId, | ||
| 300 | messages: [ | ||
| 301 | { | ||
| 302 | messageId: MessageId.make(`${threadId}:000000`), | ||
| 303 | role: "user", | ||
| 304 | text: "Old work", | ||
| 305 | createdAt, | ||
| 306 | }, | ||
| 307 | ], | ||
| 308 | }, | ||
| 309 | readModel, | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | |||
| 313 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | ||
| 314 | expect(error.message).toContain("must be active and empty"); | ||
| 315 | expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt); | ||
| 316 | }), | ||
| 317 | ); | ||
| 318 | |||
| 319 | for (const requestKind of ["approval.requested", "user-input.requested"] as const) { | ||
| 320 | it.effect(`rejects history import with an open ${requestKind} activity`, () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 323 | const threadId = ThreadId.make(`import:codex:${requestKind}`); | ||
| 324 | const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 325 | sequence: 1, | ||
| 326 | eventId: EventId.make(`event-${requestKind}-thread-created`), | ||
| 327 | aggregateKind: "thread", | ||
| 328 | aggregateId: threadId, | ||
| 329 | type: "thread.created", | ||
| 330 | occurredAt: createdAt, | ||
| 331 | commandId: CommandId.make(`command-${requestKind}-thread-created`), | ||
| 332 | causationEventId: null, | ||
| 333 | correlationId: CommandId.make(`command-${requestKind}-thread-created`), | ||
| 334 | metadata: {}, | ||
| 335 | payload: { | ||
| 336 | threadId, | ||
| 337 | projectId: ProjectId.make("project-1"), | ||
| 338 | title: "Imported thread", | ||
| 339 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 340 | runtimeMode: "full-access", | ||
| 341 | interactionMode: "default", | ||
| 342 | branch: null, | ||
| 343 | worktreePath: null, | ||
| 344 | createdAt, | ||
| 345 | updatedAt: createdAt, | ||
| 346 | }, | ||
| 347 | }); | ||
| 348 | const readModel = yield* projectEvent(withThread, { | ||
| 349 | sequence: 2, | ||
| 350 | eventId: EventId.make(`event-${requestKind}`), | ||
| 351 | aggregateKind: "thread", | ||
| 352 | aggregateId: threadId, | ||
| 353 | type: "thread.activity-appended", | ||
| 354 | occurredAt: createdAt, | ||
| 355 | commandId: CommandId.make(`command-${requestKind}`), | ||
| 356 | causationEventId: null, | ||
| 357 | correlationId: CommandId.make(`command-${requestKind}`), | ||
| 358 | metadata: {}, | ||
| 359 | payload: { | ||
| 360 | threadId, | ||
| 361 | activity: { | ||
| 362 | id: EventId.make(`activity-${requestKind}`), | ||
| 363 | tone: "approval", | ||
| 364 | kind: requestKind, | ||
| 365 | summary: "Pending request", | ||
| 366 | payload: { requestId: "request-1" }, | ||
| 367 | turnId: null, | ||
| 368 | createdAt, | ||
| 369 | }, | ||
| 370 | }, | ||
| 371 | }); | ||
| 372 | |||
| 373 | const error = yield* Effect.flip( | ||
| 374 | decideOrchestrationCommand({ | ||
| 375 | command: { | ||
| 376 | type: "thread.history.import", | ||
| 377 | commandId: CommandId.make(`command-import-${requestKind}`), | ||
| 378 | threadId, | ||
| 379 | messages: [ | ||
| 380 | { | ||
| 381 | messageId: MessageId.make(`${threadId}:000000`), | ||
| 382 | role: "user", | ||
| 383 | text: "Old work", | ||
| 384 | createdAt, | ||
| 385 | }, | ||
| 386 | ], | ||
| 387 | }, | ||
| 388 | readModel, | ||
| 389 | }), | ||
| 390 | ); | ||
| 391 | |||
| 392 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | ||
| 393 | expect(error.message).toContain("must be active and empty"); | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | } | ||
| 397 | |||
| 398 | it.effect("rejects a live user message in the imported-session namespace", () => | ||
| 399 | Effect.gen(function* () { | ||
| 400 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 401 | const threadId = ThreadId.make("thread-live-message"); | ||
| 402 | const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 403 | sequence: 1, | ||
| 404 | eventId: EventId.make("event-live-thread-created"), | ||
| 405 | aggregateKind: "thread", | ||
| 406 | aggregateId: threadId, | ||
| 407 | type: "thread.created", | ||
| 408 | occurredAt: createdAt, | ||
| 409 | commandId: CommandId.make("command-live-thread-created"), | ||
| 410 | causationEventId: null, | ||
| 411 | correlationId: CommandId.make("command-live-thread-created"), | ||
| 412 | metadata: {}, | ||
| 413 | payload: { | ||
| 414 | threadId, | ||
| 415 | projectId: ProjectId.make("project-1"), | ||
| 416 | title: "Live thread", | ||
| 417 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 418 | runtimeMode: "full-access", | ||
| 419 | interactionMode: "default", | ||
| 420 | branch: null, | ||
| 421 | worktreePath: null, | ||
| 422 | createdAt, | ||
| 423 | updatedAt: createdAt, | ||
| 424 | }, | ||
| 425 | }); | ||
| 426 | |||
| 427 | const error = yield* Effect.flip( | ||
| 428 | decideOrchestrationCommand({ | ||
| 429 | command: { | ||
| 430 | type: "thread.turn.start", | ||
| 431 | commandId: CommandId.make("command-live-import-id"), | ||
| 432 | threadId, | ||
| 433 | message: { | ||
| 434 | messageId: MessageId.make("import:forged-live-message"), | ||
| 435 | role: "user", | ||
| 436 | text: "Live work", | ||
| 437 | attachments: [], | ||
| 438 | }, | ||
| 439 | runtimeMode: "full-access", | ||
| 440 | interactionMode: "default", | ||
| 441 | createdAt, | ||
| 442 | }, | ||
| 443 | readModel, | ||
| 444 | }), | ||
| 445 | ); | ||
| 446 | |||
| 447 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | ||
| 448 | expect(error.message).toContain("reserved imported-session namespace"); | ||
| 449 | }), | ||
| 450 | ); | ||
| 451 | |||
| 452 | it.effect("rejects live assistant messages in the imported-session namespace", () => | ||
| 453 | Effect.gen(function* () { | ||
| 454 | const createdAt = "2026-08-24T10:00:00.000Z"; | ||
| 455 | const threadId = ThreadId.make("thread-live-assistant-message"); | ||
| 456 | const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { | ||
| 457 | sequence: 1, | ||
| 458 | eventId: EventId.make("event-live-assistant-thread-created"), | ||
| 459 | aggregateKind: "thread", | ||
| 460 | aggregateId: threadId, | ||
| 461 | type: "thread.created", | ||
| 462 | occurredAt: createdAt, | ||
| 463 | commandId: CommandId.make("command-live-assistant-thread-created"), | ||
| 464 | causationEventId: null, | ||
| 465 | correlationId: CommandId.make("command-live-assistant-thread-created"), | ||
| 466 | metadata: {}, | ||
| 467 | payload: { | ||
| 468 | threadId, | ||
| 469 | projectId: ProjectId.make("project-1"), | ||
| 470 | title: "Live thread", | ||
| 471 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | ||
| 472 | runtimeMode: "full-access", | ||
| 473 | interactionMode: "default", | ||
| 474 | branch: null, | ||
| 475 | worktreePath: null, | ||
| 476 | createdAt, | ||
| 477 | updatedAt: createdAt, | ||
| 478 | }, | ||
| 479 | }); | ||
| 480 | |||
| 481 | for (const commandType of [ | ||
| 482 | "thread.message.assistant.delta", | ||
| 483 | "thread.message.assistant.complete", | ||
| 484 | ] as const) { | ||
| 485 | const command = | ||
| 486 | commandType === "thread.message.assistant.delta" | ||
| 487 | ? { | ||
| 488 | type: commandType, | ||
| 489 | commandId: CommandId.make("command-live-assistant-delta-import-id"), | ||
| 490 | threadId, | ||
| 491 | messageId: MessageId.make("import:forged-live-assistant-message"), | ||
| 492 | delta: "Live work", | ||
| 493 | createdAt, | ||
| 494 | } | ||
| 495 | : { | ||
| 496 | type: commandType, | ||
| 497 | commandId: CommandId.make("command-live-assistant-complete-import-id"), | ||
| 498 | threadId, | ||
| 499 | messageId: MessageId.make("import:forged-live-assistant-message"), | ||
| 500 | createdAt, | ||
| 501 | }; | ||
| 502 | const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel })); | ||
| 503 | |||
| 504 | expect(error._tag).toBe("OrchestrationCommandInvariantError"); | ||
| 505 | expect(error.message).toContain("reserved imported-session namespace"); | ||
| 506 | } | ||
| 507 | }), | ||
| 508 | ); | ||
| 509 | }); | ||
apps/server/src/orchestration/decider.ts+95-1| ... | @@ -2,12 +2,14 @@ import { | ... | @@ -2,12 +2,14 @@ import { |
| 2 | EventId, | 2 | EventId, |
| 3 | MessageId, | 3 | MessageId, |
| 4 | UserInputRequestedPayload, | 4 | UserInputRequestedPayload, |
| 5 | isImportedAgentSessionMessageId, | ||
| 5 | type OrchestrationCommand, | 6 | type OrchestrationCommand, |
| 6 | type OrchestrationEvent, | 7 | type OrchestrationEvent, |
| 7 | type OrchestrationReadModel, | 8 | type OrchestrationReadModel, |
| 8 | type OrchestrationThread, | 9 | type OrchestrationThread, |
| 9 | type OrchestrationThreadActivity, | 10 | type OrchestrationThreadActivity, |
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | 13 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Crypto from "effect/Crypto"; | 14 | import * as Crypto from "effect/Crypto"; |
| 13 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| ... | @@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread( | ... | @@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread( |
| 98 | let latestUserMessageAt: string | null = null; | 100 | let latestUserMessageAt: string | null = null; |
| 99 | let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; | 101 | let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; |
| 100 | for (const message of thread.messages) { | 102 | for (const message of thread.messages) { |
| 101 | if (message.role !== "user") continue; | 103 | if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; |
| 102 | const messageAtMs = Date.parse(message.createdAt); | 104 | const messageAtMs = Date.parse(message.createdAt); |
| 103 | latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); | 105 | latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); |
| 104 | if (messageAtMs === latestUserMessageAtMs) { | 106 | if (messageAtMs === latestUserMessageAtMs) { |
| ... | @@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 347 | aggregateId: command.threadId, | 349 | aggregateId: command.threadId, |
| 348 | occurredAt: command.createdAt, | 350 | occurredAt: command.createdAt, |
| 349 | commandId: command.commandId, | 351 | commandId: command.commandId, |
| 352 | ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), | ||
| 350 | })), | 353 | })), |
| 351 | type: "thread.created", | 354 | type: "thread.created", |
| 352 | payload: { | 355 | payload: { |
| ... | @@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 905 | } | 908 | } |
| 906 | 909 | ||
| 907 | case "thread.turn.start": { | 910 | case "thread.turn.start": { |
| 911 | if (isImportedAgentSessionMessageId(command.message.messageId)) { | ||
| 912 | return yield* new OrchestrationCommandInvariantError({ | ||
| 913 | commandType: command.type, | ||
| 914 | detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, | ||
| 915 | }); | ||
| 916 | } | ||
| 908 | const targetThread = yield* requireThread({ | 917 | const targetThread = yield* requireThread({ |
| 909 | readModel, | 918 | readModel, |
| 910 | command, | 919 | command, |
| ... | @@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1272 | } | 1281 | } |
| 1273 | 1282 | ||
| 1274 | case "thread.message.assistant.delta": { | 1283 | case "thread.message.assistant.delta": { |
| 1284 | if (isImportedAgentSessionMessageId(command.messageId)) { | ||
| 1285 | return yield* new OrchestrationCommandInvariantError({ | ||
| 1286 | commandType: command.type, | ||
| 1287 | detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, | ||
| 1288 | }); | ||
| 1289 | } | ||
| 1275 | yield* requireThread({ | 1290 | yield* requireThread({ |
| 1276 | readModel, | 1291 | readModel, |
| 1277 | command, | 1292 | command, |
| ... | @@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1299 | } | 1314 | } |
| 1300 | 1315 | ||
| 1301 | case "thread.message.assistant.complete": { | 1316 | case "thread.message.assistant.complete": { |
| 1317 | if (isImportedAgentSessionMessageId(command.messageId)) { | ||
| 1318 | return yield* new OrchestrationCommandInvariantError({ | ||
| 1319 | commandType: command.type, | ||
| 1320 | detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, | ||
| 1321 | }); | ||
| 1322 | } | ||
| 1302 | yield* requireThread({ | 1323 | yield* requireThread({ |
| 1303 | readModel, | 1324 | readModel, |
| 1304 | command, | 1325 | command, |
| ... | @@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1325 | }; | 1346 | }; |
| 1326 | } | 1347 | } |
| 1327 | 1348 | ||
| 1349 | case "thread.history.import": { | ||
| 1350 | const thread = yield* requireThread({ | ||
| 1351 | readModel, | ||
| 1352 | command, | ||
| 1353 | threadId: command.threadId, | ||
| 1354 | }); | ||
| 1355 | if ( | ||
| 1356 | thread.deletedAt !== null || | ||
| 1357 | thread.archivedAt !== null || | ||
| 1358 | thread.messages.length > 0 || | ||
| 1359 | thread.latestTurn !== null || | ||
| 1360 | thread.session !== null || | ||
| 1361 | hasOpenBlockingRequest(thread) | ||
| 1362 | ) { | ||
| 1363 | return yield* new OrchestrationCommandInvariantError({ | ||
| 1364 | commandType: command.type, | ||
| 1365 | detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, | ||
| 1366 | }); | ||
| 1367 | } | ||
| 1368 | const firstMessage = command.messages[0]; | ||
| 1369 | if (firstMessage === undefined) { | ||
| 1370 | return yield* new OrchestrationCommandInvariantError({ | ||
| 1371 | commandType: command.type, | ||
| 1372 | detail: "Thread history imports require at least one message.", | ||
| 1373 | }); | ||
| 1374 | } | ||
| 1375 | |||
| 1376 | const events: Array<PlannedOrchestrationEvent> = []; | ||
| 1377 | for (const message of command.messages) { | ||
| 1378 | events.push({ | ||
| 1379 | ...(yield* withEventBase({ | ||
| 1380 | aggregateKind: "thread", | ||
| 1381 | aggregateId: command.threadId, | ||
| 1382 | occurredAt: message.createdAt, | ||
| 1383 | commandId: command.commandId, | ||
| 1384 | metadata: { historyImport: true }, | ||
| 1385 | })), | ||
| 1386 | type: "thread.message-sent", | ||
| 1387 | payload: { | ||
| 1388 | threadId: command.threadId, | ||
| 1389 | messageId: message.messageId, | ||
| 1390 | role: message.role, | ||
| 1391 | text: message.text, | ||
| 1392 | turnId: null, | ||
| 1393 | streaming: false, | ||
| 1394 | createdAt: message.createdAt, | ||
| 1395 | updatedAt: message.createdAt, | ||
| 1396 | }, | ||
| 1397 | }); | ||
| 1398 | } | ||
| 1399 | const settledAt = command.messages.reduce( | ||
| 1400 | (latest, message) => | ||
| 1401 | compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, | ||
| 1402 | firstMessage.createdAt, | ||
| 1403 | ); | ||
| 1404 | events.push({ | ||
| 1405 | ...(yield* withEventBase({ | ||
| 1406 | aggregateKind: "thread", | ||
| 1407 | aggregateId: command.threadId, | ||
| 1408 | occurredAt: settledAt, | ||
| 1409 | commandId: command.commandId, | ||
| 1410 | metadata: { historyImport: true }, | ||
| 1411 | })), | ||
| 1412 | type: "thread.settled", | ||
| 1413 | payload: { | ||
| 1414 | threadId: command.threadId, | ||
| 1415 | settledAt, | ||
| 1416 | updatedAt: settledAt, | ||
| 1417 | }, | ||
| 1418 | }); | ||
| 1419 | return events; | ||
| 1420 | } | ||
| 1421 | |||
| 1328 | case "thread.proposed-plan.upsert": { | 1422 | case "thread.proposed-plan.upsert": { |
| 1329 | yield* requireThread({ | 1423 | yield* requireThread({ |
| 1330 | readModel, | 1424 | readModel, |
apps/server/src/orchestration/projector.ts+15-5| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; | 1 | import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | isImportedAgentSessionMessageId, | ||
| 3 | OrchestrationCheckpointSummary, | 4 | OrchestrationCheckpointSummary, |
| 4 | OrchestrationMessage, | 5 | OrchestrationMessage, |
| 5 | OrchestrationSession, | 6 | OrchestrationSession, |
| 6 | OrchestrationThread, | 7 | OrchestrationThread, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 10 | import * as Predicate from "effect/Predicate"; | 12 | import * as Predicate from "effect/Predicate"; |
| ... | @@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert( | ... | @@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert( |
| 117 | ): ReadonlyArray<OrchestrationMessage> { | 119 | ): ReadonlyArray<OrchestrationMessage> { |
| 118 | const retainedMessageIds = new Set<string>(); | 120 | const retainedMessageIds = new Set<string>(); |
| 119 | for (const message of messages) { | 121 | for (const message of messages) { |
| 120 | if (message.role === "system") { | 122 | if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { |
| 121 | retainedMessageIds.add(message.id); | 123 | retainedMessageIds.add(message.id); |
| 122 | continue; | 124 | continue; |
| 123 | } | 125 | } |
| ... | @@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert( | ... | @@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert( |
| 127 | } | 129 | } |
| 128 | 130 | ||
| 129 | const retainedUserCount = messages.filter( | 131 | const retainedUserCount = messages.filter( |
| 130 | (message) => message.role === "user" && retainedMessageIds.has(message.id), | 132 | (message) => |
| 133 | message.role === "user" && | ||
| 134 | !isImportedAgentSessionMessageId(message.id) && | ||
| 135 | retainedMessageIds.has(message.id), | ||
| 131 | ).length; | 136 | ).length; |
| 132 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); | 137 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); |
| 133 | if (missingUserCount > 0) { | 138 | if (missingUserCount > 0) { |
| ... | @@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert( | ... | @@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert( |
| 140 | ) | 145 | ) |
| 141 | .toSorted( | 146 | .toSorted( |
| 142 | (left, right) => | 147 | (left, right) => |
| 143 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | 148 | compareDateTimeStrings(left.createdAt, right.createdAt) || |
| 149 | left.id.localeCompare(right.id), | ||
| 144 | ) | 150 | ) |
| 145 | .slice(0, missingUserCount); | 151 | .slice(0, missingUserCount); |
| 146 | for (const message of fallbackUserMessages) { | 152 | for (const message of fallbackUserMessages) { |
| ... | @@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert( | ... | @@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert( |
| 149 | } | 155 | } |
| 150 | 156 | ||
| 151 | const retainedAssistantCount = messages.filter( | 157 | const retainedAssistantCount = messages.filter( |
| 152 | (message) => message.role === "assistant" && retainedMessageIds.has(message.id), | 158 | (message) => |
| 159 | message.role === "assistant" && | ||
| 160 | !isImportedAgentSessionMessageId(message.id) && | ||
| 161 | retainedMessageIds.has(message.id), | ||
| 153 | ).length; | 162 | ).length; |
| 154 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); | 163 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); |
| 155 | if (missingAssistantCount > 0) { | 164 | if (missingAssistantCount > 0) { |
| ... | @@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert( | ... | @@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert( |
| 162 | ) | 171 | ) |
| 163 | .toSorted( | 172 | .toSorted( |
| 164 | (left, right) => | 173 | (left, right) => |
| 165 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | 174 | compareDateTimeStrings(left.createdAt, right.createdAt) || |
| 175 | left.id.localeCompare(right.id), | ||
| 166 | ) | 176 | ) |
| 167 | .slice(0, missingAssistantCount); | 177 | .slice(0, missingAssistantCount); |
| 168 | for (const message of fallbackAssistantMessages) { | 178 | for (const message of fallbackAssistantMessages) { |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+13-1| ... | @@ -12,12 +12,24 @@ const layer = it.layer( | ... | @@ -12,12 +12,24 @@ const layer = it.layer( |
| 12 | ); | 12 | ); |
| 13 | 13 | ||
| 14 | layer("ProjectionThreadMessageRepository", (it) => { | 14 | layer("ProjectionThreadMessageRepository", (it) => { |
| 15 | it.effect("finds the latest user-message time within one thread", () => | 15 | it.effect("finds the latest live user-message time within one thread", () => |
| 16 | Effect.gen(function* () { | 16 | Effect.gen(function* () { |
| 17 | const repository = yield* ProjectionThreadMessageRepository; | 17 | const repository = yield* ProjectionThreadMessageRepository; |
| 18 | const threadId = ThreadId.make("thread-latest-user-message"); | 18 | const threadId = ThreadId.make("thread-latest-user-message"); |
| 19 | assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); | 19 | assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); |
| 20 | 20 | ||
| 21 | yield* repository.upsert({ | ||
| 22 | messageId: MessageId.make("import:codex:latest-user-message:000000"), | ||
| 23 | threadId, | ||
| 24 | turnId: null, | ||
| 25 | role: "user", | ||
| 26 | text: "Imported prompt", | ||
| 27 | isStreaming: false, | ||
| 28 | createdAt: "2026-02-28T19:05:06.000Z", | ||
| 29 | updatedAt: "2026-02-28T19:05:06.000Z", | ||
| 30 | }); | ||
| 31 | assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); | ||
| 32 | |||
| 21 | const messages = [ | 33 | const messages = [ |
| 22 | { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, | 34 | { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, |
| 23 | { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, | 35 | { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+1| ... | @@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 191 | SELECT MAX(created_at) AS "latestUserMessageAt" | 191 | SELECT MAX(created_at) AS "latestUserMessageAt" |
| 192 | FROM projection_thread_messages | 192 | FROM projection_thread_messages |
| 193 | WHERE thread_id = ${threadId} AND role = 'user' | 193 | WHERE thread_id = ${threadId} AND role = 'user' |
| 194 | AND message_id NOT GLOB 'import:*' | ||
| 194 | `, | 195 | `, |
| 195 | }); | 196 | }); |
| 196 | 197 |
apps/server/src/persistence/ProviderSessionRuntime.ts+146-5| ... | @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; | ... | @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 10 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 10 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 11 | 11 | ||
| 12 | import { | 12 | import { |
| 13 | AgentSessionImportSource, | ||
| 13 | IsoDateTime, | 14 | IsoDateTime, |
| 14 | ProviderInstanceId, | 15 | ProviderInstanceId, |
| 15 | ProviderSessionRuntimeStatus, | 16 | ProviderSessionRuntimeStatus, |
| ... | @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp | ... | @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp |
| 58 | export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); | 59 | export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); |
| 59 | export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; | 60 | export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; |
| 60 | 61 | ||
| 62 | export const RecordImportedTranscriptInput = Schema.Struct({ | ||
| 63 | threadId: ThreadId, | ||
| 64 | source: AgentSessionImportSource, | ||
| 65 | }); | ||
| 66 | export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type; | ||
| 67 | |||
| 68 | export interface ProviderSessionRuntimeUpsertOptions { | ||
| 69 | readonly onConflict?: "update" | "ignore"; | ||
| 70 | } | ||
| 71 | |||
| 61 | /** | 72 | /** |
| 62 | * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. | 73 | * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. |
| 63 | */ | 74 | */ |
| ... | @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< | ... | @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< |
| 67 | /** | 78 | /** |
| 68 | * Insert or replace a provider runtime row. | 79 | * Insert or replace a provider runtime row. |
| 69 | * | 80 | * |
| 70 | * Upserts by canonical `threadId`, including JSON payload/cursor fields. | 81 | * Upserts by canonical `threadId`, retaining imported transcript records |
| 82 | * from the current database row. | ||
| 71 | */ | 83 | */ |
| 72 | readonly upsert: ( | 84 | readonly upsert: ( |
| 73 | runtime: ProviderSessionRuntime, | 85 | runtime: ProviderSessionRuntime, |
| 86 | options?: ProviderSessionRuntimeUpsertOptions, | ||
| 87 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; | ||
| 88 | |||
| 89 | /** Record one source file without replacing the current session state. */ | ||
| 90 | readonly recordImportedTranscript: ( | ||
| 91 | input: RecordImportedTranscriptInput, | ||
| 74 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; | 92 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; |
| 75 | 93 | ||
| 76 | /** | 94 | /** |
| ... | @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ | ... | @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ |
| 129 | 147 | ||
| 130 | const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; | 148 | const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; |
| 131 | 149 | ||
| 150 | const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields( | ||
| 151 | Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }), | ||
| 152 | ); | ||
| 153 | |||
| 132 | function toPersistenceSqlOrDecodeError( | 154 | function toPersistenceSqlOrDecodeError( |
| 133 | sqlOperation: string, | 155 | sqlOperation: string, |
| 134 | decodeOperation: string, | 156 | decodeOperation: string, |
| ... | @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( | ... | @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( |
| 147 | export const make = Effect.gen(function* () { | 169 | export const make = Effect.gen(function* () { |
| 148 | const sql = yield* SqlClient.SqlClient; | 170 | const sql = yield* SqlClient.SqlClient; |
| 149 | 171 | ||
| 172 | // Runtime writes can carry stale payloads. Only recordImportedTranscript may | ||
| 173 | // change source records, so restore that field from the row being updated. | ||
| 150 | const upsertRuntimeRow = SqlSchema.void({ | 174 | const upsertRuntimeRow = SqlSchema.void({ |
| 151 | Request: ProviderSessionRuntimeDbRowSchema, | 175 | Request: ProviderSessionRuntimeDbRowSchema, |
| 152 | execute: (runtime) => | 176 | execute: (runtime) => |
| ... | @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { | ... | @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { |
| 171 | ${runtime.status}, | 195 | ${runtime.status}, |
| 172 | ${runtime.lastSeenAt}, | 196 | ${runtime.lastSeenAt}, |
| 173 | ${runtime.resumeCursor}, | 197 | ${runtime.resumeCursor}, |
| 174 | ${runtime.runtimePayload} | 198 | CASE |
| 199 | WHEN json_type(${runtime.runtimePayload}) = 'object' | ||
| 200 | THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') | ||
| 201 | ELSE ${runtime.runtimePayload} | ||
| 202 | END | ||
| 175 | ) | 203 | ) |
| 176 | ON CONFLICT (thread_id) | 204 | ON CONFLICT (thread_id) |
| 177 | DO UPDATE SET | 205 | DO UPDATE SET |
| ... | @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { | ... | @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { |
| 182 | status = excluded.status, | 210 | status = excluded.status, |
| 183 | last_seen_at = excluded.last_seen_at, | 211 | last_seen_at = excluded.last_seen_at, |
| 184 | resume_cursor_json = excluded.resume_cursor_json, | 212 | resume_cursor_json = excluded.resume_cursor_json, |
| 185 | runtime_payload_json = excluded.runtime_payload_json | 213 | runtime_payload_json = CASE |
| 214 | WHEN json_type( | ||
| 215 | CASE | ||
| 216 | WHEN json_valid(provider_session_runtime.runtime_payload_json) | ||
| 217 | THEN provider_session_runtime.runtime_payload_json | ||
| 218 | ELSE '{}' | ||
| 219 | END, | ||
| 220 | '$.importedTranscripts' | ||
| 221 | ) IS NOT NULL | ||
| 222 | THEN json_set( | ||
| 223 | CASE | ||
| 224 | WHEN json_type(excluded.runtime_payload_json) = 'object' | ||
| 225 | THEN excluded.runtime_payload_json | ||
| 226 | ELSE '{}' | ||
| 227 | END, | ||
| 228 | '$.importedTranscripts', | ||
| 229 | json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts') | ||
| 230 | ) | ||
| 231 | ELSE excluded.runtime_payload_json | ||
| 232 | END | ||
| 233 | `, | ||
| 234 | }); | ||
| 235 | |||
| 236 | const insertRuntimeRow = SqlSchema.void({ | ||
| 237 | Request: ProviderSessionRuntimeDbRowSchema, | ||
| 238 | execute: (runtime) => | ||
| 239 | sql` | ||
| 240 | INSERT INTO provider_session_runtime ( | ||
| 241 | thread_id, | ||
| 242 | provider_name, | ||
| 243 | provider_instance_id, | ||
| 244 | adapter_key, | ||
| 245 | runtime_mode, | ||
| 246 | status, | ||
| 247 | last_seen_at, | ||
| 248 | resume_cursor_json, | ||
| 249 | runtime_payload_json | ||
| 250 | ) | ||
| 251 | VALUES ( | ||
| 252 | ${runtime.threadId}, | ||
| 253 | ${runtime.providerName}, | ||
| 254 | ${runtime.providerInstanceId}, | ||
| 255 | ${runtime.adapterKey}, | ||
| 256 | ${runtime.runtimeMode}, | ||
| 257 | ${runtime.status}, | ||
| 258 | ${runtime.lastSeenAt}, | ||
| 259 | ${runtime.resumeCursor}, | ||
| 260 | CASE | ||
| 261 | WHEN json_type(${runtime.runtimePayload}) = 'object' | ||
| 262 | THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') | ||
| 263 | ELSE ${runtime.runtimePayload} | ||
| 264 | END | ||
| 265 | ) | ||
| 266 | ON CONFLICT (thread_id) DO NOTHING | ||
| 267 | `, | ||
| 268 | }); | ||
| 269 | |||
| 270 | const recordImportedTranscriptRow = SqlSchema.void({ | ||
| 271 | Request: RecordImportedTranscriptRequestSchema, | ||
| 272 | execute: ({ threadId, source }) => | ||
| 273 | sql` | ||
| 274 | WITH current_runtime AS ( | ||
| 275 | SELECT CASE | ||
| 276 | WHEN json_valid(runtime_payload_json) THEN CASE | ||
| 277 | WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json | ||
| 278 | ELSE '{}' | ||
| 279 | END | ||
| 280 | ELSE '{}' | ||
| 281 | END AS payload | ||
| 282 | FROM provider_session_runtime | ||
| 283 | WHERE thread_id = ${threadId} | ||
| 284 | ) | ||
| 285 | UPDATE provider_session_runtime | ||
| 286 | SET runtime_payload_json = ( | ||
| 287 | SELECT json_set( | ||
| 288 | payload, | ||
| 289 | '$.importedTranscripts', | ||
| 290 | json(( | ||
| 291 | SELECT json_group_array(json(value)) | ||
| 292 | FROM ( | ||
| 293 | SELECT value | ||
| 294 | FROM json_each(CASE | ||
| 295 | WHEN json_type(payload, '$.importedTranscripts') = 'array' | ||
| 296 | THEN json_extract(payload, '$.importedTranscripts') | ||
| 297 | ELSE '[]' | ||
| 298 | END) | ||
| 299 | WHERE CASE | ||
| 300 | WHEN type = 'object' THEN | ||
| 301 | json_extract(value, '$.providerInstanceId') | ||
| 302 | IS NOT json_extract(${source}, '$.providerInstanceId') | ||
| 303 | OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath') | ||
| 304 | ELSE 0 | ||
| 305 | END | ||
| 306 | UNION ALL | ||
| 307 | SELECT ${source} AS value | ||
| 308 | ) | ||
| 309 | )) | ||
| 310 | ) | ||
| 311 | FROM current_runtime | ||
| 312 | ) | ||
| 313 | WHERE thread_id = ${threadId} | ||
| 186 | `, | 314 | `, |
| 187 | }); | 315 | }); |
| 188 | 316 | ||
| ... | @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { | ... | @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { |
| 235 | `, | 363 | `, |
| 236 | }); | 364 | }); |
| 237 | 365 | ||
| 238 | const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => | 366 | const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) => |
| 239 | upsertRuntimeRow(runtime).pipe( | 367 | (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe( |
| 240 | Effect.mapError( | 368 | Effect.mapError( |
| 241 | toPersistenceSqlOrDecodeError( | 369 | toPersistenceSqlOrDecodeError( |
| 242 | "ProviderSessionRuntimeRepository.upsert:query", | 370 | "ProviderSessionRuntimeRepository.upsert:query", |
| ... | @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { | ... | @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { |
| 246 | ), | 374 | ), |
| 247 | ); | 375 | ); |
| 248 | 376 | ||
| 377 | const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] = | ||
| 378 | (input) => | ||
| 379 | recordImportedTranscriptRow(input).pipe( | ||
| 380 | Effect.mapError( | ||
| 381 | toPersistenceSqlOrDecodeError( | ||
| 382 | "ProviderSessionRuntimeRepository.recordImportedTranscript:query", | ||
| 383 | "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest", | ||
| 384 | { threadId: input.threadId }, | ||
| 385 | ), | ||
| 386 | ), | ||
| 387 | ); | ||
| 388 | |||
| 249 | const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => | 389 | const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => |
| 250 | getRuntimeRowByThreadId(input).pipe( | 390 | getRuntimeRowByThreadId(input).pipe( |
| 251 | Effect.mapError( | 391 | Effect.mapError( |
| ... | @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { | ... | @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { |
| 324 | 464 | ||
| 325 | return { | 465 | return { |
| 326 | upsert, | 466 | upsert, |
| 467 | recordImportedTranscript, | ||
| 327 | getByThreadId, | 468 | getByThreadId, |
| 328 | list, | 469 | list, |
| 329 | deleteByThreadId, | 470 | deleteByThreadId, |
apps/server/src/project/AgentSessionImporter.test.ts created+1213| ... | @@ -0,0 +1,1213 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | AgentSessionImportProjectChangedError, | ||
| 5 | CommandId, | ||
| 6 | MessageId, | ||
| 7 | ProjectId, | ||
| 8 | ProviderDriverKind, | ||
| 9 | ProviderInstanceId, | ||
| 10 | ThreadId, | ||
| 11 | type OrchestrationCommand, | ||
| 12 | type OrchestrationProjectShell, | ||
| 13 | type OrchestrationThread, | ||
| 14 | type ProviderSendTurnInput, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Deferred from "effect/Deferred"; | ||
| 18 | import * as Fiber from "effect/Fiber"; | ||
| 19 | import * as FileSystem from "effect/FileSystem"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import * as Path from "effect/Path"; | ||
| 23 | import * as Schema from "effect/Schema"; | ||
| 24 | import * as Stream from "effect/Stream"; | ||
| 25 | import * as TestClock from "effect/testing/TestClock"; | ||
| 26 | |||
| 27 | import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; | ||
| 28 | import { ServerConfig } from "../config.ts"; | ||
| 29 | import { GitWorkflowService } from "../git/GitWorkflowService.ts"; | ||
| 30 | import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; | ||
| 31 | import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; | ||
| 32 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | ||
| 33 | import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; | ||
| 34 | import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts"; | ||
| 35 | import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts"; | ||
| 36 | import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; | ||
| 37 | import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts"; | ||
| 38 | import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; | ||
| 39 | import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; | ||
| 40 | import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; | ||
| 41 | import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; | ||
| 42 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 43 | import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; | ||
| 44 | import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; | ||
| 45 | import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; | ||
| 46 | import { | ||
| 47 | NoOpProviderEventLoggers, | ||
| 48 | ProviderEventLoggers, | ||
| 49 | } from "../provider/Layers/ProviderEventLoggers.ts"; | ||
| 50 | import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; | ||
| 51 | import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts"; | ||
| 52 | import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; | ||
| 53 | import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; | ||
| 54 | import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; | ||
| 55 | import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; | ||
| 56 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 57 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | ||
| 58 | import { TextGeneration } from "../textGeneration/TextGeneration.ts"; | ||
| 59 | import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; | ||
| 60 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; | ||
| 61 | import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; | ||
| 62 | import * as AgentSessionScanner from "./AgentSessionScanner.ts"; | ||
| 63 | |||
| 64 | const PROJECT_ID = ProjectId.make("project-1"); | ||
| 65 | const WORKSPACE_ROOT = "/tmp/project-from-server"; | ||
| 66 | const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; | ||
| 67 | const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 68 | |||
| 69 | const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ | ||
| 70 | source, | ||
| 71 | providerInstanceId: ProviderInstanceId.make(source), | ||
| 72 | providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, | ||
| 73 | title: `Imported ${source} thread`, | ||
| 74 | model: null, | ||
| 75 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 76 | updatedAt: "2026-08-24T10:01:00.000Z", | ||
| 77 | messages: [ | ||
| 78 | { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" }, | ||
| 79 | { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" }, | ||
| 80 | ], | ||
| 81 | }); | ||
| 82 | |||
| 83 | const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) => | ||
| 84 | ({ | ||
| 85 | _tag: "Importable", | ||
| 86 | thread, | ||
| 87 | source: { | ||
| 88 | provider: thread.source, | ||
| 89 | providerInstanceId: thread.providerInstanceId, | ||
| 90 | providerSessionId: thread.providerSessionId, | ||
| 91 | filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`, | ||
| 92 | size: 0, | ||
| 93 | mtimeMs: 0, | ||
| 94 | device: 0, | ||
| 95 | inode: 0, | ||
| 96 | birthtimeMs: 0, | ||
| 97 | }, | ||
| 98 | }) satisfies AgentSessionScanner.AgentSessionRecentThread; | ||
| 99 | |||
| 100 | const makeProject = (): OrchestrationProjectShell => ({ | ||
| 101 | id: PROJECT_ID, | ||
| 102 | title: "Project", | ||
| 103 | workspaceRoot: WORKSPACE_ROOT, | ||
| 104 | defaultModelSelection: null, | ||
| 105 | scripts: [], | ||
| 106 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 107 | updatedAt: "2026-08-24T09:00:00.000Z", | ||
| 108 | }); | ||
| 109 | |||
| 110 | const makeProjectedThread = (input: { | ||
| 111 | readonly source: "codex" | "claudeAgent"; | ||
| 112 | readonly projectId?: ProjectId; | ||
| 113 | readonly imported?: boolean; | ||
| 114 | readonly includeFollowup?: boolean; | ||
| 115 | }): OrchestrationThread => { | ||
| 116 | const sourceThread = makeThread(input.source); | ||
| 117 | const threadId = ThreadId.make( | ||
| 118 | `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, | ||
| 119 | ); | ||
| 120 | return { | ||
| 121 | id: threadId, | ||
| 122 | projectId: input.projectId ?? PROJECT_ID, | ||
| 123 | title: sourceThread.title, | ||
| 124 | modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, | ||
| 125 | runtimeMode: "full-access", | ||
| 126 | interactionMode: "default", | ||
| 127 | branch: null, | ||
| 128 | worktreePath: null, | ||
| 129 | latestTurn: null, | ||
| 130 | createdAt: sourceThread.createdAt, | ||
| 131 | updatedAt: sourceThread.updatedAt, | ||
| 132 | archivedAt: null, | ||
| 133 | settledOverride: null, | ||
| 134 | settledAt: null, | ||
| 135 | deletedAt: null, | ||
| 136 | messages: input.imported | ||
| 137 | ? [ | ||
| 138 | { | ||
| 139 | id: MessageId.make(`${threadId}:000000`), | ||
| 140 | role: "user", | ||
| 141 | text: "Fix the bug", | ||
| 142 | turnId: null, | ||
| 143 | streaming: false, | ||
| 144 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 145 | updatedAt: "2026-08-24T10:00:00.000Z", | ||
| 146 | }, | ||
| 147 | ...(input.includeFollowup | ||
| 148 | ? [ | ||
| 149 | { | ||
| 150 | id: MessageId.make("user-followup"), | ||
| 151 | role: "user" as const, | ||
| 152 | text: "Keep going", | ||
| 153 | turnId: null, | ||
| 154 | streaming: false, | ||
| 155 | createdAt: "2026-08-24T10:02:00.000Z", | ||
| 156 | updatedAt: "2026-08-24T10:02:00.000Z", | ||
| 157 | }, | ||
| 158 | ] | ||
| 159 | : []), | ||
| 160 | ] | ||
| 161 | : [], | ||
| 162 | proposedPlans: [], | ||
| 163 | activities: [], | ||
| 164 | checkpoints: [], | ||
| 165 | session: null, | ||
| 166 | }; | ||
| 167 | }; | ||
| 168 | |||
| 169 | const makeSnapshotsLayer = (input: { | ||
| 170 | readonly project?: OrchestrationProjectShell; | ||
| 171 | readonly getThread?: (threadId: ThreadId) => Option.Option<OrchestrationThread>; | ||
| 172 | }) => | ||
| 173 | Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ | ||
| 174 | getProjectShellById: () => | ||
| 175 | Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)), | ||
| 176 | getImportedAgentSessionSources: () => Effect.succeed([]), | ||
| 177 | getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()), | ||
| 178 | }); | ||
| 179 | |||
| 180 | const runImport = (input: { | ||
| 181 | readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; | ||
| 182 | readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"]; | ||
| 183 | readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; | ||
| 184 | readonly snapshots: ReturnType<typeof makeSnapshotsLayer>; | ||
| 185 | readonly expectedWorkspaceRoot?: string; | ||
| 186 | }) => | ||
| 187 | importRecentAgentThreads({ | ||
| 188 | projectId: PROJECT_ID, | ||
| 189 | ...(input.expectedWorkspaceRoot === undefined | ||
| 190 | ? {} | ||
| 191 | : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }), | ||
| 192 | }).pipe( | ||
| 193 | Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner), | ||
| 194 | Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine), | ||
| 195 | Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), | ||
| 196 | Effect.provide(input.snapshots), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { | ||
| 200 | describe("importRecentAgentThreads", () => { | ||
| 201 | it.effect("uses the project root and stores provider-specific resume cursors", () => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const commands: Array<OrchestrationCommand> = []; | ||
| 204 | const bindings: Array<ProviderSessionDirectory.ProviderRuntimeBinding> = []; | ||
| 205 | let scannedRoot: string | undefined; | ||
| 206 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 207 | scan: Effect.die("unused"), | ||
| 208 | recentThreads: (workspaceRoot) => { | ||
| 209 | scannedRoot = workspaceRoot; | ||
| 210 | return Stream.concat( | ||
| 211 | Stream.succeed(makeThreadOutcome(makeThread("codex"))), | ||
| 212 | Stream.fromEffect( | ||
| 213 | Effect.sync(() => { | ||
| 214 | expect(bindings).toHaveLength(1); | ||
| 215 | return makeThreadOutcome(makeThread("claudeAgent")); | ||
| 216 | }), | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | }, | ||
| 220 | }); | ||
| 221 | const engine = OrchestrationEngine.OrchestrationEngineService.of({ | ||
| 222 | dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), | ||
| 223 | readEvents: () => Stream.empty, | ||
| 224 | readThreadEvents: () => Stream.empty, | ||
| 225 | getThreadReplayStats: () => Effect.die("unused"), | ||
| 226 | streamDomainEvents: Stream.empty, | ||
| 227 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 228 | latestSequence: Effect.succeed(0), | ||
| 229 | }); | ||
| 230 | const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ | ||
| 231 | upsert: (binding) => Effect.sync(() => void bindings.push(binding)), | ||
| 232 | getProvider: () => Effect.die("unused"), | ||
| 233 | recordImportedTranscript: () => Effect.void, | ||
| 234 | getBinding: () => Effect.succeed(Option.none()), | ||
| 235 | listThreadIds: () => Effect.die("unused"), | ||
| 236 | listBindings: () => Effect.die("unused"), | ||
| 237 | }); | ||
| 238 | |||
| 239 | const result = yield* runImport({ | ||
| 240 | scanner, | ||
| 241 | engine, | ||
| 242 | directory, | ||
| 243 | snapshots: makeSnapshotsLayer({ project: makeProject() }), | ||
| 244 | expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`, | ||
| 245 | }); | ||
| 246 | |||
| 247 | expect(result).toEqual({ importedCount: 2, skippedCount: 0 }); | ||
| 248 | expect(scannedRoot).toBe(WORKSPACE_ROOT); | ||
| 249 | expect(commands.map((command) => command.type)).toEqual([ | ||
| 250 | "thread.create", | ||
| 251 | "thread.history.import", | ||
| 252 | "thread.create", | ||
| 253 | "thread.history.import", | ||
| 254 | ]); | ||
| 255 | expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([ | ||
| 256 | { historyImport: true }, | ||
| 257 | { historyImport: true }, | ||
| 258 | ]); | ||
| 259 | expect( | ||
| 260 | commands | ||
| 261 | .filter((command) => command.type === "thread.history.import") | ||
| 262 | .flatMap((command) => command.messages.map((message) => message.messageId)), | ||
| 263 | ).toEqual([ | ||
| 264 | "import:codex:codex-session:000000", | ||
| 265 | "import:codex:codex-session:000001", | ||
| 266 | `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`, | ||
| 267 | `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`, | ||
| 268 | ]); | ||
| 269 | expect(bindings).toMatchObject([ | ||
| 270 | { | ||
| 271 | provider: "codex", | ||
| 272 | providerInstanceId: "codex", | ||
| 273 | resumeCursor: { threadId: "codex-session" }, | ||
| 274 | runtimePayload: { cwd: WORKSPACE_ROOT }, | ||
| 275 | }, | ||
| 276 | { | ||
| 277 | provider: "claudeAgent", | ||
| 278 | providerInstanceId: "claudeAgent", | ||
| 279 | resumeCursor: { | ||
| 280 | threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`, | ||
| 281 | resume: CLAUDE_SESSION_ID, | ||
| 282 | }, | ||
| 283 | runtimePayload: { cwd: WORKSPACE_ROOT }, | ||
| 284 | }, | ||
| 285 | ]); | ||
| 286 | }), | ||
| 287 | ); | ||
| 288 | |||
| 289 | it.effect("rejects a changed project root before scanning or writing", () => | ||
| 290 | Effect.gen(function* () { | ||
| 291 | const recentThreads = vi.fn(() => Stream.empty); | ||
| 292 | const error = yield* importRecentAgentThreads({ | ||
| 293 | projectId: PROJECT_ID, | ||
| 294 | expectedWorkspaceRoot: WORKSPACE_ROOT, | ||
| 295 | }).pipe( | ||
| 296 | Effect.provideService( | ||
| 297 | AgentSessionScanner.AgentSessionScanner, | ||
| 298 | AgentSessionScanner.AgentSessionScanner.of({ | ||
| 299 | scan: Effect.die("must not scan a changed project"), | ||
| 300 | recentThreads, | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | Effect.provide( | ||
| 304 | Layer.mergeAll( | ||
| 305 | Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), | ||
| 306 | Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}), | ||
| 307 | makeSnapshotsLayer({ | ||
| 308 | project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" }, | ||
| 309 | }), | ||
| 310 | ), | ||
| 311 | ), | ||
| 312 | Effect.flip, | ||
| 313 | ); | ||
| 314 | |||
| 315 | expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })); | ||
| 316 | expect(recentThreads).not.toHaveBeenCalled(); | ||
| 317 | }), | ||
| 318 | ); | ||
| 319 | |||
| 320 | it.effect("counts scanner skips without writing a thread or binding", () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 323 | scan: Effect.die("unused"), | ||
| 324 | recentThreads: () => Stream.succeed({ _tag: "Skipped" }), | ||
| 325 | }); | ||
| 326 | const engine = OrchestrationEngine.OrchestrationEngineService.of({ | ||
| 327 | dispatch: () => Effect.die("must not dispatch for a scanner skip"), | ||
| 328 | readEvents: () => Stream.empty, | ||
| 329 | readThreadEvents: () => Stream.empty, | ||
| 330 | getThreadReplayStats: () => Effect.die("unused"), | ||
| 331 | streamDomainEvents: Stream.empty, | ||
| 332 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 333 | latestSequence: Effect.succeed(0), | ||
| 334 | }); | ||
| 335 | const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ | ||
| 336 | upsert: () => Effect.die("must not bind a scanner skip"), | ||
| 337 | getProvider: () => Effect.die("unused"), | ||
| 338 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 339 | getBinding: () => Effect.die("must not read a scanner skip binding"), | ||
| 340 | listThreadIds: () => Effect.die("unused"), | ||
| 341 | listBindings: () => Effect.die("unused"), | ||
| 342 | }); | ||
| 343 | |||
| 344 | const result = yield* runImport({ | ||
| 345 | scanner, | ||
| 346 | engine, | ||
| 347 | directory, | ||
| 348 | snapshots: makeSnapshotsLayer({ project: makeProject() }), | ||
| 349 | }); | ||
| 350 | |||
| 351 | expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); | ||
| 352 | }), | ||
| 353 | ); | ||
| 354 | |||
| 355 | it.effect("recovers after a rejected history receipt and a failed binding write", () => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | let threadCreated = false; | ||
| 358 | let historyImported = false; | ||
| 359 | let historyAttemptCount = 0; | ||
| 360 | let bindingAttemptCount = 0; | ||
| 361 | const rejectedCommandIds = new Set<string>(); | ||
| 362 | const bindings: Array<ProviderSessionDirectory.ProviderRuntimeBinding> = []; | ||
| 363 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 364 | scan: Effect.die("unused"), | ||
| 365 | recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), | ||
| 366 | }); | ||
| 367 | const engine = OrchestrationEngine.OrchestrationEngineService.of({ | ||
| 368 | dispatch: (command) => { | ||
| 369 | if (rejectedCommandIds.has(command.commandId)) { | ||
| 370 | return Effect.fail( | ||
| 371 | new OrchestrationCommandInvariantError({ | ||
| 372 | commandType: command.type, | ||
| 373 | detail: "Previously rejected.", | ||
| 374 | }), | ||
| 375 | ); | ||
| 376 | } | ||
| 377 | if (command.type === "thread.create") threadCreated = true; | ||
| 378 | if (command.type === "thread.history.import") { | ||
| 379 | historyAttemptCount += 1; | ||
| 380 | if (historyAttemptCount === 1) { | ||
| 381 | rejectedCommandIds.add(command.commandId); | ||
| 382 | return Effect.fail( | ||
| 383 | new OrchestrationCommandInvariantError({ | ||
| 384 | commandType: command.type, | ||
| 385 | detail: "Temporary history import failure.", | ||
| 386 | }), | ||
| 387 | ); | ||
| 388 | } | ||
| 389 | historyImported = true; | ||
| 390 | } | ||
| 391 | return Effect.succeed({ sequence: 1 }); | ||
| 392 | }, | ||
| 393 | readEvents: () => Stream.empty, | ||
| 394 | readThreadEvents: () => Stream.empty, | ||
| 395 | getThreadReplayStats: () => Effect.die("unused"), | ||
| 396 | streamDomainEvents: Stream.empty, | ||
| 397 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 398 | latestSequence: Effect.succeed(0), | ||
| 399 | }); | ||
| 400 | const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ | ||
| 401 | upsert: (binding) => { | ||
| 402 | bindingAttemptCount += 1; | ||
| 403 | if (bindingAttemptCount === 1) { | ||
| 404 | return Effect.fail( | ||
| 405 | new ProviderSessionDirectoryPersistenceError({ | ||
| 406 | operation: "upsert", | ||
| 407 | detail: "Temporary session storage failure.", | ||
| 408 | }), | ||
| 409 | ); | ||
| 410 | } | ||
| 411 | bindings.push(binding); | ||
| 412 | return Effect.void; | ||
| 413 | }, | ||
| 414 | getProvider: () => Effect.die("unused"), | ||
| 415 | recordImportedTranscript: () => Effect.void, | ||
| 416 | getBinding: () => | ||
| 417 | Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), | ||
| 418 | listThreadIds: () => Effect.die("unused"), | ||
| 419 | listBindings: () => Effect.die("unused"), | ||
| 420 | }); | ||
| 421 | const snapshots = makeSnapshotsLayer({ | ||
| 422 | project: makeProject(), | ||
| 423 | getThread: () => | ||
| 424 | threadCreated | ||
| 425 | ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported })) | ||
| 426 | : Option.none(), | ||
| 427 | }); | ||
| 428 | const importOnce = () => runImport({ scanner, engine, directory, snapshots }); | ||
| 429 | |||
| 430 | expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); | ||
| 431 | expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); | ||
| 432 | expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); | ||
| 433 | const historyAttemptsAfterCompletion = historyAttemptCount; | ||
| 434 | expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); | ||
| 435 | expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion); | ||
| 436 | expect(historyAttemptCount).toBe(2); | ||
| 437 | expect(bindings).toHaveLength(1); | ||
| 438 | }), | ||
| 439 | ); | ||
| 440 | |||
| 441 | it.effect("does not replace completed history or an active binding on retry", () => | ||
| 442 | Effect.gen(function* () { | ||
| 443 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 444 | scan: Effect.die("unused"), | ||
| 445 | recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), | ||
| 446 | }); | ||
| 447 | const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = { | ||
| 448 | threadId: ThreadId.make("import:codex:codex-session"), | ||
| 449 | provider: ProviderDriverKind.make("codex"), | ||
| 450 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 451 | status: "running", | ||
| 452 | resumeCursor: { threadId: "newer-codex-session" }, | ||
| 453 | }; | ||
| 454 | const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ | ||
| 455 | upsert: () => Effect.die("must not replace an active binding"), | ||
| 456 | getProvider: () => Effect.die("unused"), | ||
| 457 | recordImportedTranscript: () => Effect.void, | ||
| 458 | getBinding: () => Effect.succeed(Option.some(runningBinding)), | ||
| 459 | listThreadIds: () => Effect.die("unused"), | ||
| 460 | listBindings: () => Effect.die("unused"), | ||
| 461 | }); | ||
| 462 | const engine = OrchestrationEngine.OrchestrationEngineService.of({ | ||
| 463 | dispatch: () => Effect.die("must not replay history or settle active work"), | ||
| 464 | readEvents: () => Stream.empty, | ||
| 465 | readThreadEvents: () => Stream.empty, | ||
| 466 | getThreadReplayStats: () => Effect.die("unused"), | ||
| 467 | streamDomainEvents: Stream.empty, | ||
| 468 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 469 | latestSequence: Effect.succeed(0), | ||
| 470 | }); | ||
| 471 | |||
| 472 | const result = yield* runImport({ | ||
| 473 | scanner, | ||
| 474 | engine, | ||
| 475 | directory, | ||
| 476 | snapshots: makeSnapshotsLayer({ | ||
| 477 | project: makeProject(), | ||
| 478 | getThread: () => | ||
| 479 | Option.some( | ||
| 480 | makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }), | ||
| 481 | ), | ||
| 482 | }), | ||
| 483 | }); | ||
| 484 | |||
| 485 | expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); | ||
| 486 | }), | ||
| 487 | ); | ||
| 488 | |||
| 489 | it.effect("skips malformed Claude ids and wrong-project thread collisions", () => | ||
| 490 | Effect.gen(function* () { | ||
| 491 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 492 | scan: Effect.die("unused"), | ||
| 493 | recentThreads: () => | ||
| 494 | Stream.fromIterable([ | ||
| 495 | makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }), | ||
| 496 | makeThreadOutcome(makeThread("codex")), | ||
| 497 | ]), | ||
| 498 | }); | ||
| 499 | const commands: Array<OrchestrationCommand> = []; | ||
| 500 | const engine = OrchestrationEngine.OrchestrationEngineService.of({ | ||
| 501 | dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), | ||
| 502 | readEvents: () => Stream.empty, | ||
| 503 | readThreadEvents: () => Stream.empty, | ||
| 504 | getThreadReplayStats: () => Effect.die("unused"), | ||
| 505 | streamDomainEvents: Stream.empty, | ||
| 506 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 507 | latestSequence: Effect.succeed(0), | ||
| 508 | }); | ||
| 509 | const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ | ||
| 510 | upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), | ||
| 511 | getProvider: () => Effect.die("unused"), | ||
| 512 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 513 | getBinding: () => Effect.succeed(Option.none()), | ||
| 514 | listThreadIds: () => Effect.die("unused"), | ||
| 515 | listBindings: () => Effect.die("unused"), | ||
| 516 | }); | ||
| 517 | |||
| 518 | const result = yield* runImport({ | ||
| 519 | scanner, | ||
| 520 | engine, | ||
| 521 | directory, | ||
| 522 | snapshots: makeSnapshotsLayer({ | ||
| 523 | project: makeProject(), | ||
| 524 | getThread: (threadId) => | ||
| 525 | threadId === "import:codex:codex-session" | ||
| 526 | ? Option.some( | ||
| 527 | makeProjectedThread({ | ||
| 528 | source: "codex", | ||
| 529 | projectId: ProjectId.make("project-other"), | ||
| 530 | }), | ||
| 531 | ) | ||
| 532 | : Option.none(), | ||
| 533 | }), | ||
| 534 | }); | ||
| 535 | |||
| 536 | expect(result).toEqual({ importedCount: 0, skippedCount: 2 }); | ||
| 537 | expect(commands).toHaveLength(0); | ||
| 538 | }), | ||
| 539 | ); | ||
| 540 | }); | ||
| 541 | }); | ||
| 542 | |||
| 543 | const integrationThread = { | ||
| 544 | ...makeThread("codex"), | ||
| 545 | updatedAt: "2026-08-24T10:00:00.000Z", | ||
| 546 | messages: Array.from({ length: 12 }, (_, index) => ({ | ||
| 547 | role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), | ||
| 548 | text: `Message ${index}`, | ||
| 549 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 550 | })), | ||
| 551 | }; | ||
| 552 | const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 553 | scan: Effect.die("unused"), | ||
| 554 | recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), | ||
| 555 | }); | ||
| 556 | const integrationServerConfig = ServerConfig.layerTest(process.cwd(), { | ||
| 557 | prefix: "t3-agent-session-importer-test-", | ||
| 558 | }); | ||
| 559 | const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe( | ||
| 560 | Layer.provide(SqlitePersistenceMemory), | ||
| 561 | ); | ||
| 562 | const integrationLayer = Layer.mergeAll( | ||
| 563 | OrchestrationEngineLive.pipe( | ||
| 564 | Layer.provide(OrchestrationProjectionSnapshotQueryLive), | ||
| 565 | Layer.provide(OrchestrationProjectionPipelineLive), | ||
| 566 | ), | ||
| 567 | OrchestrationProjectionSnapshotQueryLive, | ||
| 568 | integrationRuntimeRepository, | ||
| 569 | ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)), | ||
| 570 | Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner), | ||
| 571 | ).pipe( | ||
| 572 | Layer.provide(ThreadBackgroundLiveness.layer), | ||
| 573 | Layer.provide(ThreadPlanProgress.layer), | ||
| 574 | Layer.provide(OrchestrationEventStoreLive), | ||
| 575 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | ||
| 576 | Layer.provide(RepositoryIdentityResolver.layer), | ||
| 577 | Layer.provide(SqlitePersistenceMemory), | ||
| 578 | Layer.provideMerge(integrationServerConfig), | ||
| 579 | Layer.provideMerge(NodeServices.layer), | ||
| 580 | ); | ||
| 581 | |||
| 582 | it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { | ||
| 583 | it.effect("imports once after the real engine persists an old rejected receipt", () => | ||
| 584 | Effect.gen(function* () { | ||
| 585 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | ||
| 586 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 587 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 588 | const threadId = ThreadId.make("import:codex:codex-session"); | ||
| 589 | |||
| 590 | yield* engine.dispatch({ | ||
| 591 | type: "project.create", | ||
| 592 | commandId: CommandId.make("create-import-integration-project"), | ||
| 593 | projectId: PROJECT_ID, | ||
| 594 | title: "Project", | ||
| 595 | workspaceRoot: WORKSPACE_ROOT, | ||
| 596 | defaultModelSelection: null, | ||
| 597 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 598 | }); | ||
| 599 | const rejected = yield* Effect.result( | ||
| 600 | engine.dispatch({ | ||
| 601 | type: "thread.history.import", | ||
| 602 | commandId: CommandId.make(`agent-session:history:${threadId}`), | ||
| 603 | threadId, | ||
| 604 | messages: [ | ||
| 605 | { | ||
| 606 | messageId: MessageId.make(`${threadId}:000000`), | ||
| 607 | role: "user", | ||
| 608 | text: "Fix the bug", | ||
| 609 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 610 | }, | ||
| 611 | ], | ||
| 612 | }), | ||
| 613 | ); | ||
| 614 | expect(rejected._tag).toBe("Failure"); | ||
| 615 | |||
| 616 | const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID }); | ||
| 617 | const importedThread = yield* snapshots.getThreadDetailById(threadId); | ||
| 618 | const binding = yield* directory.getBinding(threadId); | ||
| 619 | |||
| 620 | expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); | ||
| 621 | expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual( | ||
| 622 | integrationThread.messages.map((message) => message.text), | ||
| 623 | ); | ||
| 624 | expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled"); | ||
| 625 | expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z"); | ||
| 626 | expect(Option.getOrThrow(binding)).toMatchObject({ | ||
| 627 | provider: "codex", | ||
| 628 | providerInstanceId: "codex", | ||
| 629 | resumeCursor: { threadId: "codex-session" }, | ||
| 630 | runtimePayload: { cwd: WORKSPACE_ROOT }, | ||
| 631 | }); | ||
| 632 | |||
| 633 | yield* engine.dispatch({ | ||
| 634 | type: "thread.revert.complete", | ||
| 635 | commandId: CommandId.make("revert-imported-thread-to-baseline"), | ||
| 636 | threadId, | ||
| 637 | turnCount: 0, | ||
| 638 | createdAt: "2026-08-24T10:05:00.000Z", | ||
| 639 | }); | ||
| 640 | const afterRevert = yield* snapshots.getThreadDetailById(threadId); | ||
| 641 | expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual( | ||
| 642 | integrationThread.messages.map((message) => message.text), | ||
| 643 | ); | ||
| 644 | }), | ||
| 645 | ); | ||
| 646 | |||
| 647 | it.effect( | ||
| 648 | "retries a bounded import after scanner restart without rereading completed transcripts", | ||
| 649 | () => | ||
| 650 | Effect.gen(function* () { | ||
| 651 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | ||
| 652 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 653 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 654 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 655 | const path = yield* Path.Path; | ||
| 656 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 657 | yield* TestClock.setTime(nowMs); | ||
| 658 | const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 659 | prefix: "t3-import-retry-", | ||
| 660 | }); | ||
| 661 | const workspaceRoot = path.join(fixtureDir, "workspace"); | ||
| 662 | const claudeHomePath = path.join(fixtureDir, "claude"); | ||
| 663 | const codexHomePath = path.join(fixtureDir, "codex"); | ||
| 664 | const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24"); | ||
| 665 | yield* fileSystem.makeDirectory(workspaceRoot); | ||
| 666 | yield* fileSystem.makeDirectory(claudeHomePath); | ||
| 667 | yield* fileSystem.makeDirectory(sessionsDir, { recursive: true }); | ||
| 668 | |||
| 669 | const projectId = ProjectId.make("project-bounded-import-retry"); | ||
| 670 | const transcripts = Array.from({ length: 101 }, (_, index) => { | ||
| 671 | const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`; | ||
| 672 | return { | ||
| 673 | providerSessionId, | ||
| 674 | threadId: ThreadId.make(`import:codex:${providerSessionId}`), | ||
| 675 | filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`), | ||
| 676 | }; | ||
| 677 | }); | ||
| 678 | for (const [index, transcript] of transcripts.entries()) { | ||
| 679 | yield* fileSystem.writeFileString( | ||
| 680 | transcript.filePath, | ||
| 681 | [ | ||
| 682 | encodeTranscriptRecord({ | ||
| 683 | type: "session_meta", | ||
| 684 | payload: { id: transcript.providerSessionId, cwd: workspaceRoot }, | ||
| 685 | }), | ||
| 686 | encodeTranscriptRecord({ | ||
| 687 | type: "event_msg", | ||
| 688 | payload: { | ||
| 689 | type: "user_message", | ||
| 690 | message: `Prompt ${transcript.providerSessionId}`, | ||
| 691 | }, | ||
| 692 | }), | ||
| 693 | ].join("\n"), | ||
| 694 | ); | ||
| 695 | const seconds = nowMs / 1_000 - index; | ||
| 696 | yield* fileSystem.utimes(transcript.filePath, seconds, seconds); | ||
| 697 | } | ||
| 698 | const legacy = transcripts[0]!; | ||
| 699 | const failed = transcripts[1]!; | ||
| 700 | const remaining = transcripts[100]!; | ||
| 701 | yield* engine.dispatch({ | ||
| 702 | type: "project.create", | ||
| 703 | commandId: CommandId.make("create-bounded-import-project"), | ||
| 704 | projectId, | ||
| 705 | title: "Bounded import", | ||
| 706 | workspaceRoot, | ||
| 707 | defaultModelSelection: null, | ||
| 708 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 709 | }); | ||
| 710 | |||
| 711 | // This completed import predates persisted transcript source metadata. | ||
| 712 | yield* directory.upsert({ | ||
| 713 | threadId: legacy.threadId, | ||
| 714 | provider: ProviderDriverKind.make("codex"), | ||
| 715 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 716 | status: "stopped", | ||
| 717 | resumeCursor: { threadId: "legacy-current-session" }, | ||
| 718 | runtimePayload: { cwd: workspaceRoot }, | ||
| 719 | }); | ||
| 720 | yield* engine.dispatch({ | ||
| 721 | type: "thread.create", | ||
| 722 | commandId: CommandId.make("create-legacy-bounded-import"), | ||
| 723 | threadId: legacy.threadId, | ||
| 724 | projectId, | ||
| 725 | title: "Legacy import", | ||
| 726 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, | ||
| 727 | runtimeMode: "full-access", | ||
| 728 | interactionMode: "default", | ||
| 729 | branch: null, | ||
| 730 | worktreePath: null, | ||
| 731 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 732 | historyImport: true, | ||
| 733 | }); | ||
| 734 | yield* engine.dispatch({ | ||
| 735 | type: "thread.history.import", | ||
| 736 | commandId: CommandId.make("import-legacy-bounded-history"), | ||
| 737 | threadId: legacy.threadId, | ||
| 738 | messages: [ | ||
| 739 | { | ||
| 740 | messageId: MessageId.make(`${legacy.threadId}:000000`), | ||
| 741 | role: "user", | ||
| 742 | text: "Legacy imported history", | ||
| 743 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 744 | }, | ||
| 745 | ], | ||
| 746 | }); | ||
| 747 | expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]); | ||
| 748 | |||
| 749 | let failHistory = true; | ||
| 750 | const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({ | ||
| 751 | ...engine, | ||
| 752 | dispatch: (command) => { | ||
| 753 | if ( | ||
| 754 | failHistory && | ||
| 755 | command.type === "thread.history.import" && | ||
| 756 | command.threadId === failed.threadId | ||
| 757 | ) { | ||
| 758 | failHistory = false; | ||
| 759 | return Effect.fail( | ||
| 760 | new OrchestrationCommandInvariantError({ | ||
| 761 | commandType: command.type, | ||
| 762 | detail: "Injected history import failure.", | ||
| 763 | }), | ||
| 764 | ); | ||
| 765 | } | ||
| 766 | return engine.dispatch(command); | ||
| 767 | }, | ||
| 768 | }); | ||
| 769 | const settingsLayer = ServerSettingsService.layerTest({ | ||
| 770 | providers: { | ||
| 771 | claudeAgent: { homePath: claudeHomePath }, | ||
| 772 | codex: { homePath: codexHomePath }, | ||
| 773 | }, | ||
| 774 | }); | ||
| 775 | const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath)); | ||
| 776 | const runAttempt = Effect.fn("runBoundedImportAttempt")(function* ( | ||
| 777 | completedPaths: ReadonlySet<string>, | ||
| 778 | ) { | ||
| 779 | const openCounts = new Map<string, number>(); | ||
| 780 | const fullReads: string[] = []; | ||
| 781 | const observedFileSystem = FileSystem.FileSystem.of({ | ||
| 782 | ...fileSystem, | ||
| 783 | open: (filePath, options) => | ||
| 784 | Effect.suspend(() => { | ||
| 785 | if (transcriptPaths.has(filePath)) { | ||
| 786 | const count = (openCounts.get(filePath) ?? 0) + 1; | ||
| 787 | openCounts.set(filePath, count); | ||
| 788 | // A fresh scanner first opens each file for project discovery. | ||
| 789 | if (count > 1) { | ||
| 790 | fullReads.push(filePath); | ||
| 791 | if (completedPaths.has(filePath)) { | ||
| 792 | return Effect.die(new Error(`Completed transcript reopened: ${filePath}`)); | ||
| 793 | } | ||
| 794 | } | ||
| 795 | } | ||
| 796 | return fileSystem.open(filePath, options); | ||
| 797 | }), | ||
| 798 | }); | ||
| 799 | const result = yield* importRecentAgentThreads({ projectId }).pipe( | ||
| 800 | Effect.provide( | ||
| 801 | Layer.fresh(AgentSessionScanner.layer).pipe( | ||
| 802 | Layer.provide(settingsLayer), | ||
| 803 | Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)), | ||
| 804 | ), | ||
| 805 | ), | ||
| 806 | Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine), | ||
| 807 | ); | ||
| 808 | return { result, fullReads, openCounts }; | ||
| 809 | }); | ||
| 810 | |||
| 811 | const first = yield* runAttempt(new Set()); | ||
| 812 | expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 }); | ||
| 813 | expect(failHistory).toBe(false); | ||
| 814 | expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath)); | ||
| 815 | expect(first.openCounts.get(remaining.filePath)).toBe(1); | ||
| 816 | const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId); | ||
| 817 | expect(completedSources).toHaveLength(99); | ||
| 818 | expect(completedSources).toContainEqual({ | ||
| 819 | threadId: legacy.threadId, | ||
| 820 | source: expect.objectContaining({ filePath: legacy.filePath }), | ||
| 821 | }); | ||
| 822 | expect( | ||
| 823 | Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages, | ||
| 824 | ).toEqual([]); | ||
| 825 | expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({ | ||
| 826 | status: "stopped", | ||
| 827 | resumeCursor: { threadId: failed.providerSessionId }, | ||
| 828 | }); | ||
| 829 | expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true); | ||
| 830 | |||
| 831 | const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath)); | ||
| 832 | const second = yield* runAttempt(completedPaths); | ||
| 833 | expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 }); | ||
| 834 | expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]); | ||
| 835 | for (const transcript of transcripts) { | ||
| 836 | expect(second.openCounts.get(transcript.filePath)).toBe( | ||
| 837 | completedPaths.has(transcript.filePath) ? 1 : 2, | ||
| 838 | ); | ||
| 839 | } | ||
| 840 | expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101); | ||
| 841 | expect( | ||
| 842 | Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map( | ||
| 843 | (message) => message.text, | ||
| 844 | ), | ||
| 845 | ).toEqual(["Legacy imported history"]); | ||
| 846 | expect( | ||
| 847 | Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor, | ||
| 848 | ).toEqual({ | ||
| 849 | threadId: "legacy-current-session", | ||
| 850 | }); | ||
| 851 | for (const transcript of [failed, remaining]) { | ||
| 852 | expect( | ||
| 853 | Option.getOrThrow( | ||
| 854 | yield* snapshots.getThreadDetailById(transcript.threadId), | ||
| 855 | ).messages.map((message) => message.text), | ||
| 856 | ).toEqual([`Prompt ${transcript.providerSessionId}`]); | ||
| 857 | } | ||
| 858 | }), | ||
| 859 | ); | ||
| 860 | |||
| 861 | for (const source of ["codex", "claudeAgent"] as const) { | ||
| 862 | it.effect(`resumes imported ${source} history only after the first prompt`, () => | ||
| 863 | Effect.gen(function* () { | ||
| 864 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | ||
| 865 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 866 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 867 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 868 | const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped(); | ||
| 869 | const projectId = ProjectId.make(`project-import-resume-${source}`); | ||
| 870 | const sourceThread = { | ||
| 871 | ...makeThread(source), | ||
| 872 | providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID, | ||
| 873 | }; | ||
| 874 | const threadId = ThreadId.make( | ||
| 875 | `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, | ||
| 876 | ); | ||
| 877 | const resumeCursor = | ||
| 878 | source === "codex" | ||
| 879 | ? { threadId: sourceThread.providerSessionId } | ||
| 880 | : { threadId, resume: sourceThread.providerSessionId }; | ||
| 881 | const provider = ProviderDriverKind.make(source); | ||
| 882 | const harness = yield* makeTestProviderAdapterHarness({ provider }); | ||
| 883 | const importSettled = yield* Deferred.make<void>(); | ||
| 884 | const turnSent = yield* Deferred.make<void>(); | ||
| 885 | const startSession = vi.fn(harness.adapter.startSession); | ||
| 886 | const sendTurn = vi.fn((input: ProviderSendTurnInput) => | ||
| 887 | harness.adapter | ||
| 888 | .sendTurn(input) | ||
| 889 | .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), | ||
| 890 | ); | ||
| 891 | const providerLayer = makeProviderServiceLive().pipe( | ||
| 892 | Layer.provide( | ||
| 893 | Layer.succeed( | ||
| 894 | ProviderAdapterRegistry, | ||
| 895 | makeAdapterRegistryMock({ | ||
| 896 | [provider]: { ...harness.adapter, startSession, sendTurn }, | ||
| 897 | }), | ||
| 898 | ), | ||
| 899 | ), | ||
| 900 | Layer.provide( | ||
| 901 | Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), | ||
| 902 | ), | ||
| 903 | Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | ||
| 904 | Layer.provide(AnalyticsService.layerTest), | ||
| 905 | ); | ||
| 906 | const reactorLayer = ProviderCommandReactorLive.pipe( | ||
| 907 | Layer.provideMerge(providerLayer), | ||
| 908 | Layer.provide( | ||
| 909 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | ||
| 910 | ...snapshots, | ||
| 911 | // Acknowledge the imported settlement before draining the reactor. | ||
| 912 | getThreadShellById: (requestedThreadId) => | ||
| 913 | snapshots | ||
| 914 | .getThreadShellById(requestedThreadId) | ||
| 915 | .pipe( | ||
| 916 | Effect.tap(() => | ||
| 917 | requestedThreadId === threadId | ||
| 918 | ? Deferred.succeed(importSettled, undefined) | ||
| 919 | : Effect.void, | ||
| 920 | ), | ||
| 921 | ), | ||
| 922 | }), | ||
| 923 | ), | ||
| 924 | Layer.provide( | ||
| 925 | Layer.mock(ProviderAuthService)({ | ||
| 926 | tryHandlePromptCommand: () => Effect.succeed(false), | ||
| 927 | }), | ||
| 928 | ), | ||
| 929 | Layer.provide(makeProviderRegistryLayer()), | ||
| 930 | Layer.provide(Layer.mock(GitWorkflowService)({})), | ||
| 931 | Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), | ||
| 932 | Layer.provide(Layer.mock(TextGeneration)({})), | ||
| 933 | Layer.provide(ServerSettingsService.layerTest()), | ||
| 934 | ); | ||
| 935 | |||
| 936 | yield* engine.dispatch({ | ||
| 937 | type: "project.create", | ||
| 938 | commandId: CommandId.make(`create-import-resume-project-${source}`), | ||
| 939 | projectId, | ||
| 940 | title: "Import resume", | ||
| 941 | workspaceRoot, | ||
| 942 | defaultModelSelection: null, | ||
| 943 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 944 | }); | ||
| 945 | yield* harness.queueTurnResponseForNextSession({ events: [] }); | ||
| 946 | |||
| 947 | yield* Effect.gen(function* () { | ||
| 948 | const reactor = yield* ProviderCommandReactor; | ||
| 949 | yield* reactor.start(); | ||
| 950 | expect(yield* importRecentAgentThreads({ projectId })).toEqual({ | ||
| 951 | importedCount: 1, | ||
| 952 | skippedCount: 0, | ||
| 953 | }); | ||
| 954 | yield* Deferred.await(importSettled); | ||
| 955 | yield* reactor.drain; | ||
| 956 | expect(startSession).not.toHaveBeenCalled(); | ||
| 957 | expect(sendTurn).not.toHaveBeenCalled(); | ||
| 958 | const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); | ||
| 959 | expect(importedThread.session).toBeNull(); | ||
| 960 | expect(importedThread.latestTurn).toBeNull(); | ||
| 961 | |||
| 962 | yield* engine.dispatch({ | ||
| 963 | type: "thread.turn.start", | ||
| 964 | commandId: CommandId.make(`resume-imported-${source}`), | ||
| 965 | threadId, | ||
| 966 | message: { | ||
| 967 | messageId: MessageId.make(`resume-imported-message-${source}`), | ||
| 968 | role: "user", | ||
| 969 | text: "Continue this session", | ||
| 970 | attachments: [], | ||
| 971 | }, | ||
| 972 | modelSelection: importedThread.modelSelection, | ||
| 973 | runtimeMode: importedThread.runtimeMode, | ||
| 974 | interactionMode: importedThread.interactionMode, | ||
| 975 | createdAt: "2026-08-24T10:02:00.000Z", | ||
| 976 | }); | ||
| 977 | yield* Deferred.await(turnSent); | ||
| 978 | yield* reactor.drain; | ||
| 979 | expect(startSession).toHaveBeenCalledExactlyOnceWith( | ||
| 980 | expect.objectContaining({ | ||
| 981 | threadId, | ||
| 982 | provider, | ||
| 983 | providerInstanceId: sourceThread.providerInstanceId, | ||
| 984 | resumeCursor, | ||
| 985 | cwd: workspaceRoot, | ||
| 986 | }), | ||
| 987 | ); | ||
| 988 | expect(sendTurn).toHaveBeenCalledExactlyOnceWith( | ||
| 989 | expect.objectContaining({ threadId, input: "Continue this session" }), | ||
| 990 | ); | ||
| 991 | expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ | ||
| 992 | provider, | ||
| 993 | providerInstanceId: sourceThread.providerInstanceId, | ||
| 994 | resumeCursor, | ||
| 995 | }); | ||
| 996 | }).pipe( | ||
| 997 | Effect.provide(reactorLayer), | ||
| 998 | Effect.provideService( | ||
| 999 | AgentSessionScanner.AgentSessionScanner, | ||
| 1000 | AgentSessionScanner.AgentSessionScanner.of({ | ||
| 1001 | scan: Effect.die("unused"), | ||
| 1002 | recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), | ||
| 1003 | }), | ||
| 1004 | ), | ||
| 1005 | ); | ||
| 1006 | }), | ||
| 1007 | ); | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | it.effect("persists the resume cursor before publishing a new imported thread", () => | ||
| 1011 | Effect.gen(function* () { | ||
| 1012 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | ||
| 1013 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 1014 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 1015 | const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | ||
| 1016 | const projectId = ProjectId.make("project-import-binding-race"); | ||
| 1017 | const workspaceRoot = "/tmp/project-import-binding-race"; | ||
| 1018 | const providerSessionId = "codex-binding-race"; | ||
| 1019 | const threadId = ThreadId.make(`import:codex:${providerSessionId}`); | ||
| 1020 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 1021 | scan: Effect.die("unused"), | ||
| 1022 | recentThreads: () => | ||
| 1023 | Stream.succeed( | ||
| 1024 | makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }), | ||
| 1025 | ), | ||
| 1026 | }); | ||
| 1027 | const importerAtBindingWrite = yield* Deferred.make<void>(); | ||
| 1028 | const releaseImporter = yield* Deferred.make<void>(); | ||
| 1029 | const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ | ||
| 1030 | ...repository, | ||
| 1031 | upsert: (runtime, options) => | ||
| 1032 | options?.onConflict === "ignore" | ||
| 1033 | ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( | ||
| 1034 | Effect.andThen(Deferred.await(releaseImporter)), | ||
| 1035 | Effect.andThen(repository.upsert(runtime, options)), | ||
| 1036 | ) | ||
| 1037 | : repository.upsert(runtime, options), | ||
| 1038 | }); | ||
| 1039 | const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( | ||
| 1040 | Effect.provide( | ||
| 1041 | Layer.fresh(ProviderSessionDirectoryLive).pipe( | ||
| 1042 | Layer.provide( | ||
| 1043 | Layer.succeed( | ||
| 1044 | ProviderSessionRuntime.ProviderSessionRuntimeRepository, | ||
| 1045 | importerRepository, | ||
| 1046 | ), | ||
| 1047 | ), | ||
| 1048 | ), | ||
| 1049 | ), | ||
| 1050 | ); | ||
| 1051 | |||
| 1052 | yield* engine.dispatch({ | ||
| 1053 | type: "project.create", | ||
| 1054 | commandId: CommandId.make("create-import-binding-race-project"), | ||
| 1055 | projectId, | ||
| 1056 | title: "Binding race", | ||
| 1057 | workspaceRoot, | ||
| 1058 | defaultModelSelection: null, | ||
| 1059 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 1060 | }); | ||
| 1061 | |||
| 1062 | const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( | ||
| 1063 | Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), | ||
| 1064 | Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), | ||
| 1065 | Effect.forkChild, | ||
| 1066 | ); | ||
| 1067 | |||
| 1068 | yield* Effect.raceFirst( | ||
| 1069 | Deferred.await(importerAtBindingWrite), | ||
| 1070 | Fiber.join(importFiber).pipe( | ||
| 1071 | Effect.flatMap((result) => | ||
| 1072 | Effect.die( | ||
| 1073 | new Error(`Import completed before the binding write: ${JSON.stringify(result)}`), | ||
| 1074 | ), | ||
| 1075 | ), | ||
| 1076 | ), | ||
| 1077 | ); | ||
| 1078 | expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true); | ||
| 1079 | |||
| 1080 | yield* directory.upsert({ | ||
| 1081 | threadId, | ||
| 1082 | provider: ProviderDriverKind.make("codex"), | ||
| 1083 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 1084 | status: "running", | ||
| 1085 | resumeCursor: { threadId: "active-client-session" }, | ||
| 1086 | runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, | ||
| 1087 | }); | ||
| 1088 | yield* Deferred.succeed(releaseImporter, undefined); | ||
| 1089 | |||
| 1090 | expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 }); | ||
| 1091 | expect( | ||
| 1092 | Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( | ||
| 1093 | (message) => message.text, | ||
| 1094 | ), | ||
| 1095 | ).toEqual(integrationThread.messages.map((message) => message.text)); | ||
| 1096 | expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ | ||
| 1097 | status: "running", | ||
| 1098 | resumeCursor: { threadId: "active-client-session" }, | ||
| 1099 | runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, | ||
| 1100 | }); | ||
| 1101 | }), | ||
| 1102 | ); | ||
| 1103 | |||
| 1104 | it.effect("does not import history over a turn started on a partial thread", () => | ||
| 1105 | Effect.gen(function* () { | ||
| 1106 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | ||
| 1107 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 1108 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 1109 | const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | ||
| 1110 | const projectId = ProjectId.make("project-import-turn-race"); | ||
| 1111 | const workspaceRoot = "/tmp/project-import-turn-race"; | ||
| 1112 | const providerSessionId = "codex-turn-race"; | ||
| 1113 | const threadId = ThreadId.make(`import:codex:${providerSessionId}`); | ||
| 1114 | const scanner = AgentSessionScanner.AgentSessionScanner.of({ | ||
| 1115 | scan: Effect.die("unused"), | ||
| 1116 | recentThreads: () => | ||
| 1117 | Stream.succeed( | ||
| 1118 | makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }), | ||
| 1119 | ), | ||
| 1120 | }); | ||
| 1121 | const importerAtBindingWrite = yield* Deferred.make<void>(); | ||
| 1122 | const releaseImporter = yield* Deferred.make<void>(); | ||
| 1123 | const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ | ||
| 1124 | ...repository, | ||
| 1125 | upsert: (runtime, options) => | ||
| 1126 | options?.onConflict === "ignore" | ||
| 1127 | ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( | ||
| 1128 | Effect.andThen(Deferred.await(releaseImporter)), | ||
| 1129 | Effect.andThen(repository.upsert(runtime, options)), | ||
| 1130 | ) | ||
| 1131 | : repository.upsert(runtime, options), | ||
| 1132 | }); | ||
| 1133 | const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( | ||
| 1134 | Effect.provide( | ||
| 1135 | Layer.fresh(ProviderSessionDirectoryLive).pipe( | ||
| 1136 | Layer.provide( | ||
| 1137 | Layer.succeed( | ||
| 1138 | ProviderSessionRuntime.ProviderSessionRuntimeRepository, | ||
| 1139 | importerRepository, | ||
| 1140 | ), | ||
| 1141 | ), | ||
| 1142 | ), | ||
| 1143 | ), | ||
| 1144 | ); | ||
| 1145 | |||
| 1146 | yield* engine.dispatch({ | ||
| 1147 | type: "project.create", | ||
| 1148 | commandId: CommandId.make("create-import-turn-race-project"), | ||
| 1149 | projectId, | ||
| 1150 | title: "Turn race", | ||
| 1151 | workspaceRoot, | ||
| 1152 | defaultModelSelection: null, | ||
| 1153 | createdAt: "2026-08-24T09:00:00.000Z", | ||
| 1154 | }); | ||
| 1155 | yield* engine.dispatch({ | ||
| 1156 | type: "thread.create", | ||
| 1157 | commandId: CommandId.make("create-import-turn-race-thread"), | ||
| 1158 | threadId, | ||
| 1159 | projectId, | ||
| 1160 | title: "Turn race", | ||
| 1161 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, | ||
| 1162 | runtimeMode: "full-access", | ||
| 1163 | interactionMode: "default", | ||
| 1164 | branch: null, | ||
| 1165 | worktreePath: null, | ||
| 1166 | createdAt: "2026-08-24T10:00:00.000Z", | ||
| 1167 | }); | ||
| 1168 | |||
| 1169 | const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( | ||
| 1170 | Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), | ||
| 1171 | Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), | ||
| 1172 | Effect.forkChild, | ||
| 1173 | ); | ||
| 1174 | yield* Deferred.await(importerAtBindingWrite); | ||
| 1175 | |||
| 1176 | yield* directory.upsert({ | ||
| 1177 | threadId, | ||
| 1178 | provider: ProviderDriverKind.make("codex"), | ||
| 1179 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 1180 | status: "running", | ||
| 1181 | resumeCursor: { threadId: "active-client-session" }, | ||
| 1182 | runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, | ||
| 1183 | }); | ||
| 1184 | yield* engine.dispatch({ | ||
| 1185 | type: "thread.turn.start", | ||
| 1186 | commandId: CommandId.make("start-turn-during-import"), | ||
| 1187 | threadId, | ||
| 1188 | message: { | ||
| 1189 | messageId: MessageId.make("message-during-import"), | ||
| 1190 | role: "user", | ||
| 1191 | text: "Continue while import waits", | ||
| 1192 | attachments: [], | ||
| 1193 | }, | ||
| 1194 | runtimeMode: "full-access", | ||
| 1195 | interactionMode: "default", | ||
| 1196 | createdAt: "2026-08-24T10:02:00.000Z", | ||
| 1197 | }); | ||
| 1198 | yield* Deferred.succeed(releaseImporter, undefined); | ||
| 1199 | |||
| 1200 | expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 }); | ||
| 1201 | expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ | ||
| 1202 | status: "running", | ||
| 1203 | resumeCursor: { threadId: "active-client-session" }, | ||
| 1204 | runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, | ||
| 1205 | }); | ||
| 1206 | expect( | ||
| 1207 | Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( | ||
| 1208 | (message) => message.text, | ||
| 1209 | ), | ||
| 1210 | ).toEqual(["Continue while import waits"]); | ||
| 1211 | }), | ||
| 1212 | ); | ||
| 1213 | }); | ||
apps/server/src/project/AgentSessionImporter.ts created+297| ... | @@ -0,0 +1,297 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | DEFAULT_MODEL, | ||
| 4 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 5 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 6 | DEFAULT_RUNTIME_MODE, | ||
| 7 | AgentSessionImportProjectChangedError, | ||
| 8 | AgentSessionImportProjectNotFoundError, | ||
| 9 | AgentSessionSource, | ||
| 10 | AgentSessionScanError, | ||
| 11 | isImportedAgentSessionMessageId, | ||
| 12 | MessageId, | ||
| 13 | ProjectId, | ||
| 14 | ProviderDriverKind, | ||
| 15 | ThreadId, | ||
| 16 | type AgentSessionImportInput, | ||
| 17 | type AgentSessionImportResult, | ||
| 18 | type OrchestrationThread, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; | ||
| 21 | import * as Crypto from "effect/Crypto"; | ||
| 22 | import * as Effect from "effect/Effect"; | ||
| 23 | import * as Option from "effect/Option"; | ||
| 24 | import * as Schema from "effect/Schema"; | ||
| 25 | import * as Stream from "effect/Stream"; | ||
| 26 | |||
| 27 | import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; | ||
| 28 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 29 | import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; | ||
| 30 | import * as AgentSessionScanner from "./AgentSessionScanner.ts"; | ||
| 31 | |||
| 32 | const CLAUDE_SESSION_ID_PATTERN = | ||
| 33 | /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | ||
| 34 | |||
| 35 | class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass<AgentSessionUnresumableSessionError>()( | ||
| 36 | "AgentSessionUnresumableSessionError", | ||
| 37 | { | ||
| 38 | source: AgentSessionSource, | ||
| 39 | providerSessionId: Schema.String, | ||
| 40 | }, | ||
| 41 | ) { | ||
| 42 | override get message(): string { | ||
| 43 | return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`; | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass<AgentSessionThreadProjectConflictError>()( | ||
| 48 | "AgentSessionThreadProjectConflictError", | ||
| 49 | { | ||
| 50 | threadId: ThreadId, | ||
| 51 | expectedProjectId: ProjectId, | ||
| 52 | actualProjectId: ProjectId, | ||
| 53 | }, | ||
| 54 | ) { | ||
| 55 | override get message(): string { | ||
| 56 | return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`; | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass<AgentSessionThreadModifiedError>()( | ||
| 61 | "AgentSessionThreadModifiedError", | ||
| 62 | { threadId: ThreadId }, | ||
| 63 | ) { | ||
| 64 | override get message(): string { | ||
| 65 | return `Imported thread '${this.threadId}' changed before its history import completed.`; | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | function hasImportedHistory(thread: OrchestrationThread): boolean { | ||
| 70 | return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id)); | ||
| 71 | } | ||
| 72 | |||
| 73 | function hasImportBlockingActivity( | ||
| 74 | thread: OrchestrationThread, | ||
| 75 | importedHistoryPresent: boolean, | ||
| 76 | ): boolean { | ||
| 77 | return ( | ||
| 78 | thread.archivedAt !== null || | ||
| 79 | thread.deletedAt !== null || | ||
| 80 | thread.latestTurn !== null || | ||
| 81 | thread.session !== null || | ||
| 82 | thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) || | ||
| 83 | thread.proposedPlans.length > 0 || | ||
| 84 | thread.activities.length > 0 || | ||
| 85 | thread.checkpoints.length > 0 || | ||
| 86 | thread.snoozedUntil != null || | ||
| 87 | thread.snoozedAt != null || | ||
| 88 | thread.pinnedAt != null || | ||
| 89 | thread.pinOrderKey != null || | ||
| 90 | thread.titleRegeneration != null || | ||
| 91 | thread.linkedPullRequest != null || | ||
| 92 | thread.unsettledAt != null || | ||
| 93 | (importedHistoryPresent | ||
| 94 | ? thread.settledOverride !== "settled" | ||
| 95 | : thread.settledOverride !== null || thread.settledAt !== null) | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | /** Import recent transcript text and persist the cursor needed to resume its provider session. */ | ||
| 100 | export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( | ||
| 101 | input: AgentSessionImportInput, | ||
| 102 | ) { | ||
| 103 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 104 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | ||
| 105 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 106 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 107 | const crypto = yield* Crypto.Crypto; | ||
| 108 | const project = yield* snapshots.getProjectShellById(input.projectId).pipe( | ||
| 109 | Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), | ||
| 110 | Effect.flatMap( | ||
| 111 | Option.match({ | ||
| 112 | onNone: () => | ||
| 113 | Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), | ||
| 114 | onSome: Effect.succeed, | ||
| 115 | }), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | const workspaceRoot = project.workspaceRoot; | ||
| 119 | if ( | ||
| 120 | input.expectedWorkspaceRoot !== undefined && | ||
| 121 | normalizeProjectPathForComparison(workspaceRoot) !== | ||
| 122 | normalizeProjectPathForComparison(input.expectedWorkspaceRoot) | ||
| 123 | ) { | ||
| 124 | return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); | ||
| 125 | } | ||
| 126 | const completedSources = yield* snapshots | ||
| 127 | .getImportedAgentSessionSources(input.projectId) | ||
| 128 | .pipe( | ||
| 129 | Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), | ||
| 130 | ); | ||
| 131 | const threads = scanner.recentThreads( | ||
| 132 | workspaceRoot, | ||
| 133 | completedSources.map((entry) => entry.source), | ||
| 134 | ); | ||
| 135 | const importedThreadIds = new Set<ThreadId>(); | ||
| 136 | let importedCount = 0; | ||
| 137 | let skippedCount = 0; | ||
| 138 | |||
| 139 | yield* Stream.runForEach(threads, (outcome) => | ||
| 140 | Effect.gen(function* () { | ||
| 141 | if (outcome._tag === "Skipped") { | ||
| 142 | skippedCount += 1; | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") { | ||
| 146 | const threadId = ThreadId.make( | ||
| 147 | `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`, | ||
| 148 | ); | ||
| 149 | if (outcome._tag === "AlreadyImported") { | ||
| 150 | importedThreadIds.add(threadId); | ||
| 151 | importedCount += 1; | ||
| 152 | } else if (importedThreadIds.has(threadId)) { | ||
| 153 | const recorded = yield* directory | ||
| 154 | .recordImportedTranscript({ threadId, source: outcome.source }) | ||
| 155 | .pipe(Effect.result); | ||
| 156 | if (recorded._tag === "Failure") { | ||
| 157 | skippedCount += 1; | ||
| 158 | yield* Effect.logWarning("Could not record an imported transcript copy", { | ||
| 159 | threadId, | ||
| 160 | cause: recorded.failure, | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | return; | ||
| 165 | } | ||
| 166 | const thread = outcome.thread; | ||
| 167 | const threadId = ThreadId.make( | ||
| 168 | `import:${thread.providerInstanceId}:${thread.providerSessionId}`, | ||
| 169 | ); | ||
| 170 | const imported = yield* Effect.gen(function* () { | ||
| 171 | const provider = ProviderDriverKind.make(thread.source); | ||
| 172 | const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; | ||
| 173 | const existingThread = yield* snapshots.getThreadDetailById(threadId); | ||
| 174 | const existingBinding = yield* directory.getBinding(threadId); | ||
| 175 | |||
| 176 | if ( | ||
| 177 | thread.source === "claudeAgent" && | ||
| 178 | !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId) | ||
| 179 | ) { | ||
| 180 | return yield* new AgentSessionUnresumableSessionError({ | ||
| 181 | source: thread.source, | ||
| 182 | providerSessionId: thread.providerSessionId, | ||
| 183 | }); | ||
| 184 | } | ||
| 185 | |||
| 186 | if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { | ||
| 187 | return yield* new AgentSessionThreadProjectConflictError({ | ||
| 188 | threadId, | ||
| 189 | expectedProjectId: input.projectId, | ||
| 190 | actualProjectId: existingThread.value.projectId, | ||
| 191 | }); | ||
| 192 | } | ||
| 193 | |||
| 194 | const importedHistoryPresent = Option.isSome(existingThread) | ||
| 195 | ? hasImportedHistory(existingThread.value) | ||
| 196 | : false; | ||
| 197 | if ( | ||
| 198 | Option.isSome(existingThread) && | ||
| 199 | importedHistoryPresent && | ||
| 200 | Option.isSome(existingBinding) | ||
| 201 | ) { | ||
| 202 | yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); | ||
| 203 | return true; | ||
| 204 | } | ||
| 205 | |||
| 206 | if ( | ||
| 207 | Option.isSome(existingThread) && | ||
| 208 | hasImportBlockingActivity(existingThread.value, importedHistoryPresent) | ||
| 209 | ) { | ||
| 210 | return yield* new AgentSessionThreadModifiedError({ threadId }); | ||
| 211 | } | ||
| 212 | |||
| 213 | if ( | ||
| 214 | Option.isSome(existingBinding) && | ||
| 215 | (existingBinding.value.provider !== provider || | ||
| 216 | existingBinding.value.providerInstanceId !== thread.providerInstanceId || | ||
| 217 | existingBinding.value.status !== "stopped") | ||
| 218 | ) { | ||
| 219 | return yield* new AgentSessionThreadModifiedError({ threadId }); | ||
| 220 | } | ||
| 221 | |||
| 222 | // Install the cursor before the thread becomes visible. A concurrent | ||
| 223 | // real session can replace it, while insert-ignore keeps this import | ||
| 224 | // from replacing that newer binding. | ||
| 225 | if (Option.isNone(existingBinding)) { | ||
| 226 | yield* directory.upsert( | ||
| 227 | { | ||
| 228 | threadId, | ||
| 229 | provider, | ||
| 230 | providerInstanceId: thread.providerInstanceId, | ||
| 231 | status: "stopped", | ||
| 232 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 233 | resumeCursor: | ||
| 234 | thread.source === "codex" | ||
| 235 | ? { threadId: thread.providerSessionId } | ||
| 236 | : { threadId, resume: thread.providerSessionId }, | ||
| 237 | runtimePayload: { cwd: workspaceRoot }, | ||
| 238 | }, | ||
| 239 | { onConflict: "ignore" }, | ||
| 240 | ); | ||
| 241 | } | ||
| 242 | |||
| 243 | if (Option.isNone(existingThread)) { | ||
| 244 | yield* engine.dispatch({ | ||
| 245 | type: "thread.create", | ||
| 246 | commandId: CommandId.make(yield* crypto.randomUUIDv4), | ||
| 247 | threadId, | ||
| 248 | projectId: input.projectId, | ||
| 249 | title: thread.title, | ||
| 250 | modelSelection: { instanceId: thread.providerInstanceId, model }, | ||
| 251 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 252 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 253 | branch: null, | ||
| 254 | worktreePath: null, | ||
| 255 | createdAt: thread.createdAt, | ||
| 256 | historyImport: true, | ||
| 257 | }); | ||
| 258 | } | ||
| 259 | |||
| 260 | if (!importedHistoryPresent) { | ||
| 261 | yield* engine.dispatch({ | ||
| 262 | type: "thread.history.import", | ||
| 263 | commandId: CommandId.make(yield* crypto.randomUUIDv4), | ||
| 264 | threadId, | ||
| 265 | messages: thread.messages.map((message, index) => ({ | ||
| 266 | messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`), | ||
| 267 | role: message.role, | ||
| 268 | text: message.text, | ||
| 269 | createdAt: message.createdAt, | ||
| 270 | })), | ||
| 271 | }); | ||
| 272 | } | ||
| 273 | |||
| 274 | yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); | ||
| 275 | |||
| 276 | return true; | ||
| 277 | }).pipe( | ||
| 278 | Effect.catch((cause) => | ||
| 279 | Effect.logWarning("Could not import an agent session", { | ||
| 280 | provider: thread.source, | ||
| 281 | sessionId: thread.providerSessionId, | ||
| 282 | cause, | ||
| 283 | }).pipe(Effect.as(false)), | ||
| 284 | ), | ||
| 285 | ); | ||
| 286 | |||
| 287 | if (imported) { | ||
| 288 | importedThreadIds.add(threadId); | ||
| 289 | importedCount += 1; | ||
| 290 | } else { | ||
| 291 | skippedCount += 1; | ||
| 292 | } | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | |||
| 296 | return { importedCount, skippedCount } satisfies AgentSessionImportResult; | ||
| 297 | }); | ||
apps/server/src/project/AgentSessionScanner.test.ts created+3088| ... | @@ -0,0 +1,3088 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import { | ||
| 5 | type OrchestrationProjectShell, | ||
| 6 | ProjectId, | ||
| 7 | ProviderDriverKind, | ||
| 8 | ProviderInstanceId, | ||
| 9 | type ServerSettings as ContractServerSettings, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as FileSystem from "effect/FileSystem"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Path from "effect/Path"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import * as Stream from "effect/Stream"; | ||
| 19 | import * as TestClock from "effect/testing/TestClock"; | ||
| 20 | |||
| 21 | import * as ServerConfig from "../config.ts"; | ||
| 22 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 23 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 24 | import * as AgentSessionScanner from "./AgentSessionScanner.ts"; | ||
| 25 | |||
| 26 | const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({ | ||
| 27 | id: ProjectId.make("project-1"), | ||
| 28 | title: "Imported", | ||
| 29 | workspaceRoot, | ||
| 30 | defaultModelSelection: null, | ||
| 31 | scripts: [], | ||
| 32 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 33 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 34 | }); | ||
| 35 | |||
| 36 | /** Only `getShellSnapshot` is exercised; the rest must not be called. */ | ||
| 37 | const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray<string>) => | ||
| 38 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | ||
| 39 | getCommandReadModel: () => Effect.die("unused"), | ||
| 40 | getUserInputActivity: () => Effect.die("unused"), | ||
| 41 | getSnapshot: () => Effect.die("unused"), | ||
| 42 | getShellSnapshot: () => | ||
| 43 | Effect.succeed({ | ||
| 44 | snapshotSequence: 0, | ||
| 45 | projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)), | ||
| 46 | threads: [], | ||
| 47 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 48 | }), | ||
| 49 | getArchivedShellSnapshot: () => Effect.die("unused"), | ||
| 50 | getSnapshotSequence: () => Effect.die("unused"), | ||
| 51 | getCounts: () => Effect.die("unused"), | ||
| 52 | getEventReplayStats: () => Effect.die("unused"), | ||
| 53 | getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), | ||
| 54 | getProjectShellById: () => Effect.die("unused"), | ||
| 55 | getImportedAgentSessionSources: () => Effect.succeed([]), | ||
| 56 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | ||
| 57 | getThreadCheckpointContext: () => Effect.die("unused"), | ||
| 58 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 59 | getThreadShellById: () => Effect.die("unused"), | ||
| 60 | getThreadRuntimeContext: () => Effect.die("unused"), | ||
| 61 | getThreadDetailById: () => Effect.die("unused"), | ||
| 62 | getThreadDetailSnapshot: () => Effect.die("unused"), | ||
| 63 | searchThreads: () => Effect.die("unused"), | ||
| 64 | }); | ||
| 65 | |||
| 66 | /** | ||
| 67 | * Run a scan against the given homes. Homes are temp dirs created inside the | ||
| 68 | * test, so the layer is built per run rather than shared. | ||
| 69 | */ | ||
| 70 | interface ScannerTestInput { | ||
| 71 | readonly claudeHomePath: string; | ||
| 72 | readonly codexHomePath: string; | ||
| 73 | readonly importedWorkspaceRoots?: ReadonlyArray<string>; | ||
| 74 | /** Base dir for the test ServerConfig; worktreesDir derives from it. */ | ||
| 75 | readonly configBaseDir?: string; | ||
| 76 | readonly providerInstances?: ContractServerSettings["providerInstances"]; | ||
| 77 | } | ||
| 78 | |||
| 79 | const makeScannerTestLayer = (input: ScannerTestInput) => | ||
| 80 | AgentSessionScanner.layer.pipe( | ||
| 81 | Layer.provide( | ||
| 82 | Layer.mergeAll( | ||
| 83 | ServerSettings.layerTest({ | ||
| 84 | providers: { | ||
| 85 | claudeAgent: { homePath: input.claudeHomePath }, | ||
| 86 | codex: { homePath: input.codexHomePath }, | ||
| 87 | }, | ||
| 88 | ...(input.providerInstances === undefined | ||
| 89 | ? {} | ||
| 90 | : { providerInstances: input.providerInstances }), | ||
| 91 | }), | ||
| 92 | ServerConfig.layerTest( | ||
| 93 | input.claudeHomePath, | ||
| 94 | input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, | ||
| 95 | ), | ||
| 96 | makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | |||
| 101 | const runScan = (input: ScannerTestInput) => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 104 | return yield* scanner.scan; | ||
| 105 | }).pipe(Effect.provide(makeScannerTestLayer(input))); | ||
| 106 | |||
| 107 | const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 110 | return yield* scanner.recentThreads(input.workspaceRoot).pipe( | ||
| 111 | Stream.runCollect, | ||
| 112 | Effect.map((outcomes) => Array.from(outcomes)), | ||
| 113 | ); | ||
| 114 | }).pipe(Effect.provide(makeScannerTestLayer(input))); | ||
| 115 | |||
| 116 | const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => | ||
| 117 | runRecentThreadOutcomes(input).pipe( | ||
| 118 | Effect.map((outcomes) => | ||
| 119 | outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), | ||
| 120 | ), | ||
| 121 | ); | ||
| 122 | |||
| 123 | const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { | ||
| 124 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 125 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 126 | }); | ||
| 127 | |||
| 128 | const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { | ||
| 129 | readonly filePath: string; | ||
| 130 | readonly contents: string; | ||
| 131 | /** Epoch millis, so ordering assertions never depend on write timing. */ | ||
| 132 | readonly mtimeMs: number; | ||
| 133 | }) { | ||
| 134 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 135 | const path = yield* Path.Path; | ||
| 136 | yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); | ||
| 137 | yield* fileSystem.writeFileString(input.filePath, input.contents); | ||
| 138 | // Numeric utimes arguments are seconds, not milliseconds. | ||
| 139 | const seconds = input.mtimeMs / 1000; | ||
| 140 | yield* fileSystem.utimes(input.filePath, seconds, seconds); | ||
| 141 | }); | ||
| 142 | |||
| 143 | /** Claude session line: the first record carries the real `cwd`. */ | ||
| 144 | const claudeSessionLine = (cwd: string) => | ||
| 145 | `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; | ||
| 146 | |||
| 147 | /** Codex rollout line: session metadata is nested under `payload`. */ | ||
| 148 | const codexRolloutLine = (cwd: string) => | ||
| 149 | `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; | ||
| 150 | |||
| 151 | const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 152 | |||
| 153 | function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { | ||
| 154 | const records = | ||
| 155 | [ | ||
| 156 | encodeTranscriptRecord({ | ||
| 157 | type: "session_meta", | ||
| 158 | payload: { id: "record-limit-session", cwd }, | ||
| 159 | }), | ||
| 160 | encodeTranscriptRecord({ | ||
| 161 | type: "event_msg", | ||
| 162 | payload: { type: "user_message", message: "First prompt" }, | ||
| 163 | }), | ||
| 164 | ].join("\n") + | ||
| 165 | "\n" + | ||
| 166 | "{}\n".repeat(99_998); | ||
| 167 | return overflow | ||
| 168 | ? records + | ||
| 169 | "\n" + | ||
| 170 | encodeTranscriptRecord({ | ||
| 171 | type: "event_msg", | ||
| 172 | payload: { type: "user_message", message: "Overflow prompt" }, | ||
| 173 | }) + | ||
| 174 | "\n" | ||
| 175 | : records; | ||
| 176 | } | ||
| 177 | |||
| 178 | it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { | ||
| 179 | describe("scan", () => { | ||
| 180 | it.effect("reads Claude project cwds from transcripts, newest first", () => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | const path = yield* Path.Path; | ||
| 183 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 184 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 185 | const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); | ||
| 186 | const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); | ||
| 187 | |||
| 188 | // Slugs are intentionally lossy; the scanner must not decode them. | ||
| 189 | yield* writeTranscript({ | ||
| 190 | filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), | ||
| 191 | contents: claudeSessionLine(olderWorkspace), | ||
| 192 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 193 | }); | ||
| 194 | yield* writeTranscript({ | ||
| 195 | filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), | ||
| 196 | contents: claudeSessionLine(olderWorkspace), | ||
| 197 | mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), | ||
| 198 | }); | ||
| 199 | yield* writeTranscript({ | ||
| 200 | filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), | ||
| 201 | contents: claudeSessionLine(newerWorkspace), | ||
| 202 | mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), | ||
| 203 | }); | ||
| 204 | |||
| 205 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 206 | |||
| 207 | expect(result.candidates).toEqual([ | ||
| 208 | { | ||
| 209 | path: newerWorkspace, | ||
| 210 | title: path.basename(newerWorkspace), | ||
| 211 | sources: ["claudeAgent"], | ||
| 212 | threadCount: 1, | ||
| 213 | lastActiveAt: "2026-03-01T00:00:00.000Z", | ||
| 214 | alreadyImported: false, | ||
| 215 | }, | ||
| 216 | { | ||
| 217 | path: olderWorkspace, | ||
| 218 | title: path.basename(olderWorkspace), | ||
| 219 | sources: ["claudeAgent"], | ||
| 220 | threadCount: 2, | ||
| 221 | lastActiveAt: "2026-01-02T00:00:00.000Z", | ||
| 222 | alreadyImported: false, | ||
| 223 | }, | ||
| 224 | ]); | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | |||
| 228 | it.effect("groups Codex rollouts by cwd across date directories", () => | ||
| 229 | Effect.gen(function* () { | ||
| 230 | const path = yield* Path.Path; | ||
| 231 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 232 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 233 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 234 | const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); | ||
| 235 | |||
| 236 | const rollout = (year: string, month: string, day: string, name: string) => | ||
| 237 | path.join(codexHomePath, "sessions", year, month, day, name); | ||
| 238 | |||
| 239 | yield* writeTranscript({ | ||
| 240 | filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), | ||
| 241 | contents: codexRolloutLine(workspace), | ||
| 242 | mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), | ||
| 243 | }); | ||
| 244 | yield* writeTranscript({ | ||
| 245 | filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), | ||
| 246 | contents: codexRolloutLine(workspace), | ||
| 247 | mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), | ||
| 248 | }); | ||
| 249 | yield* writeTranscript({ | ||
| 250 | filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), | ||
| 251 | contents: codexRolloutLine(otherWorkspace), | ||
| 252 | mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), | ||
| 253 | }); | ||
| 254 | |||
| 255 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 256 | |||
| 257 | expect(result.candidates).toEqual([ | ||
| 258 | { | ||
| 259 | path: otherWorkspace, | ||
| 260 | title: path.basename(otherWorkspace), | ||
| 261 | sources: ["codex"], | ||
| 262 | threadCount: 1, | ||
| 263 | lastActiveAt: "2026-02-09T11:00:00.000Z", | ||
| 264 | alreadyImported: false, | ||
| 265 | }, | ||
| 266 | { | ||
| 267 | path: workspace, | ||
| 268 | title: path.basename(workspace), | ||
| 269 | sources: ["codex"], | ||
| 270 | threadCount: 2, | ||
| 271 | lastActiveAt: "2026-02-09T10:00:00.000Z", | ||
| 272 | alreadyImported: false, | ||
| 273 | }, | ||
| 274 | ]); | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | |||
| 278 | it.effect.each(["claudeAgent", "codex"] as const)( | ||
| 279 | "does not open a non-file %s transcript", | ||
| 280 | (source) => | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const path = yield* Path.Path; | ||
| 283 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 284 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 285 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 286 | const transcriptPath = | ||
| 287 | source === "claudeAgent" | ||
| 288 | ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") | ||
| 289 | : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); | ||
| 290 | yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); | ||
| 291 | |||
| 292 | let transcriptOpenCount = 0; | ||
| 293 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 294 | ...fileSystem, | ||
| 295 | open: (filePath, options) => { | ||
| 296 | if (filePath === transcriptPath) transcriptOpenCount += 1; | ||
| 297 | return fileSystem.open(filePath, options); | ||
| 298 | }, | ||
| 299 | }); | ||
| 300 | |||
| 301 | const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( | ||
| 302 | Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), | ||
| 303 | ); | ||
| 304 | |||
| 305 | expect(result.candidates).toEqual([]); | ||
| 306 | expect(transcriptOpenCount).toBe(0); | ||
| 307 | }), | ||
| 308 | ); | ||
| 309 | |||
| 310 | it.effect.each(["claudeAgent", "codex"] as const)( | ||
| 311 | "stops %s directory reads at the discovery operation budget", | ||
| 312 | (source) => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | const path = yield* Path.Path; | ||
| 315 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 316 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 317 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 318 | const discoveryRoot = | ||
| 319 | source === "claudeAgent" | ||
| 320 | ? path.join(claudeHomePath, "projects") | ||
| 321 | : path.join(codexHomePath, "sessions"); | ||
| 322 | const emptyDirectories = Array.from( | ||
| 323 | { length: 20_001 }, | ||
| 324 | (_, index) => `empty-${index.toString().padStart(5, "0")}`, | ||
| 325 | ); | ||
| 326 | let directoryReadCount = 0; | ||
| 327 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 328 | ...fileSystem, | ||
| 329 | readDirectory: (directory, options) => { | ||
| 330 | if (directory === discoveryRoot) { | ||
| 331 | directoryReadCount += 1; | ||
| 332 | return Effect.succeed(emptyDirectories); | ||
| 333 | } | ||
| 334 | if (path.dirname(directory) === discoveryRoot) { | ||
| 335 | directoryReadCount += 1; | ||
| 336 | return Effect.succeed([]); | ||
| 337 | } | ||
| 338 | return fileSystem.readDirectory(directory, options); | ||
| 339 | }, | ||
| 340 | }); | ||
| 341 | |||
| 342 | const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( | ||
| 343 | Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), | ||
| 344 | ); | ||
| 345 | |||
| 346 | expect(result.candidates).toEqual([]); | ||
| 347 | expect(directoryReadCount).toBe(20_000); | ||
| 348 | }), | ||
| 349 | ); | ||
| 350 | |||
| 351 | it.effect("merges the same cwd seen by both agents and flags imported projects", () => | ||
| 352 | Effect.gen(function* () { | ||
| 353 | const path = yield* Path.Path; | ||
| 354 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 355 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 356 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 357 | |||
| 358 | yield* writeTranscript({ | ||
| 359 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 360 | contents: claudeSessionLine(workspace), | ||
| 361 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 362 | }); | ||
| 363 | yield* writeTranscript({ | ||
| 364 | filePath: path.join( | ||
| 365 | codexHomePath, | ||
| 366 | "sessions", | ||
| 367 | "2026", | ||
| 368 | "04", | ||
| 369 | "01", | ||
| 370 | "rollout-2026-04-01T09-00-00-aaa.jsonl", | ||
| 371 | ), | ||
| 372 | contents: codexRolloutLine(workspace), | ||
| 373 | mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), | ||
| 374 | }); | ||
| 375 | |||
| 376 | const result = yield* runScan({ | ||
| 377 | claudeHomePath, | ||
| 378 | codexHomePath, | ||
| 379 | importedWorkspaceRoots: [workspace], | ||
| 380 | }); | ||
| 381 | |||
| 382 | expect(result.candidates).toEqual([ | ||
| 383 | { | ||
| 384 | path: workspace, | ||
| 385 | title: path.basename(workspace), | ||
| 386 | projectId: ProjectId.make("project-1"), | ||
| 387 | sources: ["claudeAgent", "codex"], | ||
| 388 | threadCount: 2, | ||
| 389 | lastActiveAt: "2026-04-01T09:00:00.000Z", | ||
| 390 | alreadyImported: true, | ||
| 391 | }, | ||
| 392 | ]); | ||
| 393 | }), | ||
| 394 | ); | ||
| 395 | |||
| 396 | it.effect("returns the imported project ID through a realpath alias", () => | ||
| 397 | Effect.gen(function* () { | ||
| 398 | const path = yield* Path.Path; | ||
| 399 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 400 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 401 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 402 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 403 | const linkParent = yield* makeTempDir("t3code-scanner-links-"); | ||
| 404 | const workspaceAlias = path.join(linkParent, "workspace-alias"); | ||
| 405 | yield* fileSystem.symlink(workspace, workspaceAlias); | ||
| 406 | |||
| 407 | yield* writeTranscript({ | ||
| 408 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 409 | contents: claudeSessionLine(workspaceAlias), | ||
| 410 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 411 | }); | ||
| 412 | |||
| 413 | const result = yield* runScan({ | ||
| 414 | claudeHomePath, | ||
| 415 | codexHomePath, | ||
| 416 | importedWorkspaceRoots: [workspace], | ||
| 417 | }); | ||
| 418 | |||
| 419 | expect(result.candidates[0]).toMatchObject({ | ||
| 420 | path: workspace, | ||
| 421 | projectId: ProjectId.make("project-1"), | ||
| 422 | alreadyImported: true, | ||
| 423 | }); | ||
| 424 | }), | ||
| 425 | ); | ||
| 426 | |||
| 427 | it.effect("matches a persisted project alias to a transcript realpath", () => | ||
| 428 | Effect.gen(function* () { | ||
| 429 | const path = yield* Path.Path; | ||
| 430 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 431 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 432 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 433 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 434 | const linkParent = yield* makeTempDir("t3code-scanner-links-"); | ||
| 435 | const workspaceAlias = path.join(linkParent, "workspace-alias"); | ||
| 436 | yield* fileSystem.symlink(workspace, workspaceAlias); | ||
| 437 | |||
| 438 | yield* writeTranscript({ | ||
| 439 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 440 | contents: claudeSessionLine(workspace), | ||
| 441 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 442 | }); | ||
| 443 | |||
| 444 | const result = yield* runScan({ | ||
| 445 | claudeHomePath, | ||
| 446 | codexHomePath, | ||
| 447 | importedWorkspaceRoots: [workspaceAlias], | ||
| 448 | }); | ||
| 449 | |||
| 450 | expect(result.candidates[0]).toMatchObject({ | ||
| 451 | path: workspaceAlias, | ||
| 452 | projectId: ProjectId.make("project-1"), | ||
| 453 | alreadyImported: true, | ||
| 454 | }); | ||
| 455 | }), | ||
| 456 | ); | ||
| 457 | |||
| 458 | it.effect("merges case aliases and preserves the persisted project path", () => | ||
| 459 | Effect.gen(function* () { | ||
| 460 | const path = yield* Path.Path; | ||
| 461 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 462 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 463 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 464 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 465 | const workspaceAlias = path.join( | ||
| 466 | path.dirname(workspace), | ||
| 467 | path.basename(workspace).toUpperCase(), | ||
| 468 | ); | ||
| 469 | |||
| 470 | yield* writeTranscript({ | ||
| 471 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 472 | contents: claudeSessionLine(workspaceAlias), | ||
| 473 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 474 | }); | ||
| 475 | yield* writeTranscript({ | ||
| 476 | filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), | ||
| 477 | contents: codexRolloutLine(workspace), | ||
| 478 | mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), | ||
| 479 | }); | ||
| 480 | |||
| 481 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 482 | ...fileSystem, | ||
| 483 | stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), | ||
| 484 | }); | ||
| 485 | const result = yield* runScan({ | ||
| 486 | claudeHomePath, | ||
| 487 | codexHomePath, | ||
| 488 | importedWorkspaceRoots: [workspace], | ||
| 489 | }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); | ||
| 490 | |||
| 491 | expect(result.candidates).toEqual([ | ||
| 492 | { | ||
| 493 | path: workspace, | ||
| 494 | title: path.basename(workspace), | ||
| 495 | projectId: ProjectId.make("project-1"), | ||
| 496 | sources: ["claudeAgent", "codex"], | ||
| 497 | threadCount: 2, | ||
| 498 | lastActiveAt: "2026-01-02T00:00:00.000Z", | ||
| 499 | alreadyImported: true, | ||
| 500 | }, | ||
| 501 | ]); | ||
| 502 | }), | ||
| 503 | ); | ||
| 504 | |||
| 505 | it.effect("keeps case variants distinct when the filesystem identities differ", () => | ||
| 506 | Effect.gen(function* () { | ||
| 507 | const path = yield* Path.Path; | ||
| 508 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 509 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 510 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 511 | const backingUpper = yield* makeTempDir("t3code-backing-upper-"); | ||
| 512 | const backingLower = yield* makeTempDir("t3code-backing-lower-"); | ||
| 513 | const aliasParent = yield* makeTempDir("t3code-case-aliases-"); | ||
| 514 | const upperWorkspace = path.join(aliasParent, "Repo"); | ||
| 515 | const lowerWorkspace = path.join(aliasParent, "repo"); | ||
| 516 | |||
| 517 | yield* writeTranscript({ | ||
| 518 | filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), | ||
| 519 | contents: claudeSessionLine(upperWorkspace), | ||
| 520 | mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), | ||
| 521 | }); | ||
| 522 | yield* writeTranscript({ | ||
| 523 | filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), | ||
| 524 | contents: claudeSessionLine(lowerWorkspace), | ||
| 525 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 526 | }); | ||
| 527 | |||
| 528 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 529 | ...fileSystem, | ||
| 530 | stat: (filePath) => | ||
| 531 | fileSystem.stat( | ||
| 532 | filePath === upperWorkspace | ||
| 533 | ? backingUpper | ||
| 534 | : filePath === lowerWorkspace | ||
| 535 | ? backingLower | ||
| 536 | : filePath, | ||
| 537 | ), | ||
| 538 | }); | ||
| 539 | const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( | ||
| 540 | Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), | ||
| 541 | ); | ||
| 542 | |||
| 543 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([ | ||
| 544 | upperWorkspace, | ||
| 545 | lowerWorkspace, | ||
| 546 | ]); | ||
| 547 | }), | ||
| 548 | ); | ||
| 549 | |||
| 550 | it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => | ||
| 551 | Effect.gen(function* () { | ||
| 552 | const path = yield* Path.Path; | ||
| 553 | const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); | ||
| 554 | const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); | ||
| 555 | const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); | ||
| 556 | const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); | ||
| 557 | const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); | ||
| 558 | const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); | ||
| 559 | const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); | ||
| 560 | |||
| 561 | yield* writeTranscript({ | ||
| 562 | filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), | ||
| 563 | contents: claudeSessionLine(legacyWorkspace), | ||
| 564 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 565 | }); | ||
| 566 | yield* writeTranscript({ | ||
| 567 | filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), | ||
| 568 | contents: claudeSessionLine(claudeWorkspace), | ||
| 569 | mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), | ||
| 570 | }); | ||
| 571 | yield* writeTranscript({ | ||
| 572 | filePath: path.join( | ||
| 573 | codexInstanceHome, | ||
| 574 | "sessions", | ||
| 575 | "2026", | ||
| 576 | "03", | ||
| 577 | "01", | ||
| 578 | "rollout-instance.jsonl", | ||
| 579 | ), | ||
| 580 | contents: codexRolloutLine(codexWorkspace), | ||
| 581 | mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), | ||
| 582 | }); | ||
| 583 | |||
| 584 | const result = yield* runScan({ | ||
| 585 | claudeHomePath, | ||
| 586 | codexHomePath, | ||
| 587 | providerInstances: { | ||
| 588 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 589 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 590 | config: { homePath: claudeInstanceHome }, | ||
| 591 | }, | ||
| 592 | [ProviderInstanceId.make("codex")]: { | ||
| 593 | driver: ProviderDriverKind.make("codex"), | ||
| 594 | config: { homePath: codexInstanceHome }, | ||
| 595 | }, | ||
| 596 | }, | ||
| 597 | }); | ||
| 598 | |||
| 599 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([ | ||
| 600 | codexWorkspace, | ||
| 601 | claudeWorkspace, | ||
| 602 | ]); | ||
| 603 | }), | ||
| 604 | ); | ||
| 605 | |||
| 606 | it.effect("scans each distinct home across multiple instances once", () => | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const path = yield* Path.Path; | ||
| 609 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 610 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 611 | const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); | ||
| 612 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 613 | const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); | ||
| 614 | |||
| 615 | for (const [home, cwd] of [ | ||
| 616 | [codexHomePath, workspace], | ||
| 617 | [otherCodexHome, otherWorkspace], | ||
| 618 | ] as const) { | ||
| 619 | yield* writeTranscript({ | ||
| 620 | filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), | ||
| 621 | contents: codexRolloutLine(cwd), | ||
| 622 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 623 | }); | ||
| 624 | } | ||
| 625 | |||
| 626 | const result = yield* runScan({ | ||
| 627 | claudeHomePath, | ||
| 628 | codexHomePath, | ||
| 629 | providerInstances: { | ||
| 630 | [ProviderInstanceId.make("codex-personal")]: { | ||
| 631 | driver: ProviderDriverKind.make("codex"), | ||
| 632 | config: { homePath: codexHomePath }, | ||
| 633 | }, | ||
| 634 | [ProviderInstanceId.make("codex-work")]: { | ||
| 635 | driver: ProviderDriverKind.make("codex"), | ||
| 636 | config: { homePath: otherCodexHome }, | ||
| 637 | }, | ||
| 638 | }, | ||
| 639 | }); | ||
| 640 | |||
| 641 | expect(result.candidates).toHaveLength(2); | ||
| 642 | expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); | ||
| 643 | expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( | ||
| 644 | [workspace, otherWorkspace].sort(), | ||
| 645 | ); | ||
| 646 | }), | ||
| 647 | ); | ||
| 648 | |||
| 649 | it.effect("honors provider instance home directory environment variables", () => | ||
| 650 | Effect.gen(function* () { | ||
| 651 | const path = yield* Path.Path; | ||
| 652 | const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); | ||
| 653 | const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); | ||
| 654 | const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); | ||
| 655 | const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); | ||
| 656 | const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); | ||
| 657 | const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); | ||
| 658 | |||
| 659 | yield* writeTranscript({ | ||
| 660 | filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), | ||
| 661 | contents: claudeSessionLine(claudeWorkspace), | ||
| 662 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 663 | }); | ||
| 664 | yield* writeTranscript({ | ||
| 665 | filePath: path.join( | ||
| 666 | codexEnvironmentHome, | ||
| 667 | "sessions", | ||
| 668 | "2026", | ||
| 669 | "01", | ||
| 670 | "01", | ||
| 671 | "rollout-session.jsonl", | ||
| 672 | ), | ||
| 673 | contents: codexRolloutLine(codexWorkspace), | ||
| 674 | mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), | ||
| 675 | }); | ||
| 676 | |||
| 677 | const result = yield* runScan({ | ||
| 678 | claudeHomePath, | ||
| 679 | codexHomePath, | ||
| 680 | providerInstances: { | ||
| 681 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 682 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 683 | environment: [ | ||
| 684 | { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, | ||
| 685 | ], | ||
| 686 | config: {}, | ||
| 687 | }, | ||
| 688 | [ProviderInstanceId.make("codex")]: { | ||
| 689 | driver: ProviderDriverKind.make("codex"), | ||
| 690 | environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], | ||
| 691 | config: {}, | ||
| 692 | }, | ||
| 693 | }, | ||
| 694 | }); | ||
| 695 | |||
| 696 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([ | ||
| 697 | codexWorkspace, | ||
| 698 | claudeWorkspace, | ||
| 699 | ]); | ||
| 700 | }), | ||
| 701 | ); | ||
| 702 | |||
| 703 | it.effect("ignores invalid provider instances while scanning the remaining providers", () => | ||
| 704 | Effect.gen(function* () { | ||
| 705 | const path = yield* Path.Path; | ||
| 706 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 707 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 708 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 709 | |||
| 710 | yield* writeTranscript({ | ||
| 711 | filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), | ||
| 712 | contents: claudeSessionLine(workspace), | ||
| 713 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 714 | }); | ||
| 715 | |||
| 716 | const result = yield* runScan({ | ||
| 717 | claudeHomePath, | ||
| 718 | codexHomePath, | ||
| 719 | providerInstances: { | ||
| 720 | [ProviderInstanceId.make("codex")]: { | ||
| 721 | driver: ProviderDriverKind.make("codex"), | ||
| 722 | config: { homePath: 123 }, | ||
| 723 | }, | ||
| 724 | }, | ||
| 725 | }); | ||
| 726 | |||
| 727 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 728 | }), | ||
| 729 | ); | ||
| 730 | |||
| 731 | it.effect("does not scan provider instances disabled by the envelope or config", () => | ||
| 732 | Effect.gen(function* () { | ||
| 733 | const path = yield* Path.Path; | ||
| 734 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 735 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 736 | const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); | ||
| 737 | const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); | ||
| 738 | const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); | ||
| 739 | const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); | ||
| 740 | |||
| 741 | for (const [home, workspace, session] of [ | ||
| 742 | [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], | ||
| 743 | [configDisabledHome, configWorkspace, "config-disabled"], | ||
| 744 | ] as const) { | ||
| 745 | yield* writeTranscript({ | ||
| 746 | filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), | ||
| 747 | contents: codexRolloutLine(workspace), | ||
| 748 | mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 749 | }); | ||
| 750 | } | ||
| 751 | |||
| 752 | const result = yield* runScan({ | ||
| 753 | claudeHomePath, | ||
| 754 | codexHomePath, | ||
| 755 | providerInstances: { | ||
| 756 | [ProviderInstanceId.make("codex-envelope-disabled")]: { | ||
| 757 | driver: ProviderDriverKind.make("codex"), | ||
| 758 | enabled: false, | ||
| 759 | config: { homePath: envelopeDisabledHome }, | ||
| 760 | }, | ||
| 761 | [ProviderInstanceId.make("codex-config-disabled")]: { | ||
| 762 | driver: ProviderDriverKind.make("codex"), | ||
| 763 | config: { enabled: false, homePath: configDisabledHome }, | ||
| 764 | }, | ||
| 765 | }, | ||
| 766 | }); | ||
| 767 | |||
| 768 | expect(result.candidates).toEqual([]); | ||
| 769 | }), | ||
| 770 | ); | ||
| 771 | |||
| 772 | it.effect("ignores relative working directories from malformed transcripts", () => | ||
| 773 | Effect.gen(function* () { | ||
| 774 | const path = yield* Path.Path; | ||
| 775 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 776 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 777 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 778 | |||
| 779 | yield* writeTranscript({ | ||
| 780 | filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), | ||
| 781 | contents: claudeSessionLine(path.relative(path.resolve(), workspace)), | ||
| 782 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 783 | }); | ||
| 784 | |||
| 785 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 786 | |||
| 787 | expect(result.candidates).toEqual([]); | ||
| 788 | }), | ||
| 789 | ); | ||
| 790 | |||
| 791 | it.effect("drops candidates whose directory no longer exists", () => | ||
| 792 | Effect.gen(function* () { | ||
| 793 | const path = yield* Path.Path; | ||
| 794 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 795 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 796 | |||
| 797 | yield* writeTranscript({ | ||
| 798 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 799 | contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), | ||
| 800 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 801 | }); | ||
| 802 | |||
| 803 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 804 | |||
| 805 | expect(result.candidates).toEqual([]); | ||
| 806 | }), | ||
| 807 | ); | ||
| 808 | |||
| 809 | it.effect("excludes the home directory, temporary root, and T3 data directory", () => | ||
| 810 | Effect.gen(function* () { | ||
| 811 | const path = yield* Path.Path; | ||
| 812 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 813 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 814 | const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); | ||
| 815 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 816 | |||
| 817 | for (const [index, cwd] of [ | ||
| 818 | NodeOS.homedir(), | ||
| 819 | NodeOS.tmpdir(), | ||
| 820 | configBaseDir, | ||
| 821 | workspace, | ||
| 822 | ].entries()) { | ||
| 823 | yield* writeTranscript({ | ||
| 824 | filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), | ||
| 825 | contents: claudeSessionLine(cwd), | ||
| 826 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, | ||
| 827 | }); | ||
| 828 | } | ||
| 829 | |||
| 830 | const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); | ||
| 831 | |||
| 832 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 833 | }), | ||
| 834 | ); | ||
| 835 | |||
| 836 | it.effect("excludes T3-managed worktree sandboxes", () => | ||
| 837 | Effect.gen(function* () { | ||
| 838 | const path = yield* Path.Path; | ||
| 839 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 840 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 841 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 842 | |||
| 843 | const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); | ||
| 844 | yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); | ||
| 845 | yield* writeTranscript({ | ||
| 846 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 847 | contents: claudeSessionLine(worktreeCwd), | ||
| 848 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 849 | }); | ||
| 850 | |||
| 851 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 852 | |||
| 853 | expect(result.candidates).toEqual([]); | ||
| 854 | }), | ||
| 855 | ); | ||
| 856 | |||
| 857 | it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => | ||
| 858 | Effect.gen(function* () { | ||
| 859 | const path = yield* Path.Path; | ||
| 860 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 861 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 862 | const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); | ||
| 863 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 864 | |||
| 865 | // worktreesDir derives as `<baseDir>/worktrees`, and the temp base | ||
| 866 | // dir contains no `.t3` segment — only the config-based prefix match | ||
| 867 | // can exclude this one. | ||
| 868 | const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); | ||
| 869 | yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); | ||
| 870 | yield* writeTranscript({ | ||
| 871 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 872 | contents: claudeSessionLine(worktreeCwd), | ||
| 873 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 874 | }); | ||
| 875 | |||
| 876 | const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); | ||
| 877 | |||
| 878 | expect(result.candidates).toEqual([]); | ||
| 879 | }), | ||
| 880 | ); | ||
| 881 | |||
| 882 | it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => | ||
| 883 | Effect.gen(function* () { | ||
| 884 | const path = yield* Path.Path; | ||
| 885 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 886 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 887 | const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); | ||
| 888 | const linkParent = yield* makeTempDir("t3code-scanner-links-"); | ||
| 889 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 890 | |||
| 891 | // The recorded cwd is a symlink whose own spelling looks harmless; | ||
| 892 | // only its realpath reveals the managed sandbox. | ||
| 893 | const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); | ||
| 894 | yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); | ||
| 895 | const symlinkCwd = path.join(linkParent, "innocent-project"); | ||
| 896 | yield* fileSystem.symlink(worktreeCwd, symlinkCwd); | ||
| 897 | yield* writeTranscript({ | ||
| 898 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 899 | contents: claudeSessionLine(symlinkCwd), | ||
| 900 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 901 | }); | ||
| 902 | |||
| 903 | const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); | ||
| 904 | |||
| 905 | expect(result.candidates).toEqual([]); | ||
| 906 | }), | ||
| 907 | ); | ||
| 908 | |||
| 909 | it.effect("finds the cwd on a later line when the first records carry none", () => | ||
| 910 | Effect.gen(function* () { | ||
| 911 | const path = yield* Path.Path; | ||
| 912 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 913 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 914 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 915 | |||
| 916 | // Claude transcripts often open with records that have no cwd. | ||
| 917 | const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; | ||
| 918 | yield* writeTranscript({ | ||
| 919 | filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), | ||
| 920 | contents, | ||
| 921 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 922 | }); | ||
| 923 | |||
| 924 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 925 | |||
| 926 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 927 | }), | ||
| 928 | ); | ||
| 929 | |||
| 930 | it.effect("reads a complete transcript record at the exact chunk boundary", () => | ||
| 931 | Effect.gen(function* () { | ||
| 932 | const path = yield* Path.Path; | ||
| 933 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 934 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 935 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 936 | const record = claudeSessionLine(workspace).split("\n")[0]!; | ||
| 937 | const prefix = '{"padding":"'; | ||
| 938 | const suffix = `",${record.slice(1)}`; | ||
| 939 | const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; | ||
| 940 | |||
| 941 | yield* writeTranscript({ | ||
| 942 | filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), | ||
| 943 | contents, | ||
| 944 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 945 | }); | ||
| 946 | |||
| 947 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 948 | |||
| 949 | expect(contents).toHaveLength(32 * 1024); | ||
| 950 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 951 | }), | ||
| 952 | ); | ||
| 953 | |||
| 954 | it.effect("finds session metadata after a first record larger than one chunk", () => | ||
| 955 | Effect.gen(function* () { | ||
| 956 | const path = yield* Path.Path; | ||
| 957 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 958 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 959 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 960 | const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; | ||
| 961 | |||
| 962 | yield* writeTranscript({ | ||
| 963 | filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), | ||
| 964 | contents: `${history}${claudeSessionLine(workspace)}`, | ||
| 965 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 966 | }); | ||
| 967 | |||
| 968 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 969 | |||
| 970 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 971 | }), | ||
| 972 | ); | ||
| 973 | |||
| 974 | it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => | ||
| 975 | Effect.gen(function* () { | ||
| 976 | const path = yield* Path.Path; | ||
| 977 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 978 | const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); | ||
| 979 | const secondHome = yield* makeTempDir("t3code-metadata-second-"); | ||
| 980 | const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); | ||
| 981 | const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); | ||
| 982 | const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); | ||
| 983 | const directories = [ | ||
| 984 | path.join(claudeHomePath, "projects", "p"), | ||
| 985 | path.join(secondHome, "projects", "p"), | ||
| 986 | ]; | ||
| 987 | const templates = directories.map((directory) => path.join(directory, "template.jsonl")); | ||
| 988 | for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { | ||
| 989 | const record = encodeTranscriptRecord({ cwd: workspace }); | ||
| 990 | yield* writeTranscript({ | ||
| 991 | filePath: templates[index]!, | ||
| 992 | contents: | ||
| 993 | " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, | ||
| 994 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, | ||
| 995 | }); | ||
| 996 | } | ||
| 997 | const resolveFile = (filePath: string) => { | ||
| 998 | const index = directories.indexOf(path.dirname(filePath)); | ||
| 999 | return index === -1 ? filePath : templates[index]!; | ||
| 1000 | }; | ||
| 1001 | let reservedBytes = 0; | ||
| 1002 | let opens = 0; | ||
| 1003 | const requests: number[] = []; | ||
| 1004 | const observedFileSystem = FileSystem.FileSystem.of({ | ||
| 1005 | ...fileSystem, | ||
| 1006 | readDirectory: (directory, options) => { | ||
| 1007 | const index = directories.indexOf(directory); | ||
| 1008 | return index === -1 | ||
| 1009 | ? fileSystem.readDirectory(directory, options) | ||
| 1010 | : Effect.succeed( | ||
| 1011 | Array.from( | ||
| 1012 | { length: index === 0 ? 32 : count - 32 }, | ||
| 1013 | (_, item) => `session-${item}.jsonl`, | ||
| 1014 | ), | ||
| 1015 | ); | ||
| 1016 | }, | ||
| 1017 | stat: (filePath) => fileSystem.stat(resolveFile(filePath)), | ||
| 1018 | open: (filePath, options) => { | ||
| 1019 | if (!directories.includes(path.dirname(filePath))) | ||
| 1020 | return fileSystem.open(filePath, options); | ||
| 1021 | opens += 1; | ||
| 1022 | return fileSystem.open(resolveFile(filePath), options).pipe( | ||
| 1023 | Effect.map((file) => ({ | ||
| 1024 | ...file, | ||
| 1025 | stat: file.stat, | ||
| 1026 | readAlloc: (size: FileSystem.SizeInput) => { | ||
| 1027 | reservedBytes += Number(size); | ||
| 1028 | requests.push(Number(size)); | ||
| 1029 | return file.readAlloc(size); | ||
| 1030 | }, | ||
| 1031 | })), | ||
| 1032 | ); | ||
| 1033 | }, | ||
| 1034 | }); | ||
| 1035 | const result = yield* runScan({ | ||
| 1036 | claudeHomePath, | ||
| 1037 | codexHomePath, | ||
| 1038 | providerInstances: { | ||
| 1039 | [ProviderInstanceId.make("claude-work")]: { | ||
| 1040 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 1041 | config: { homePath: secondHome }, | ||
| 1042 | }, | ||
| 1043 | }, | ||
| 1044 | }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); | ||
| 1045 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([ | ||
| 1046 | firstWorkspace, | ||
| 1047 | secondWorkspace, | ||
| 1048 | ]); | ||
| 1049 | expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); | ||
| 1050 | expect(result.truncated).toBe(count === 65 ? true : undefined); | ||
| 1051 | expect(opens).toBe(64); | ||
| 1052 | expect(reservedBytes).toBe(64 * 1024 * 1024); | ||
| 1053 | expect(requests[0]).toBe(8 * 1024); | ||
| 1054 | expect(Math.max(...requests)).toBe(8 * 1024); | ||
| 1055 | }), | ||
| 1056 | ); | ||
| 1057 | |||
| 1058 | it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => | ||
| 1059 | Effect.gen(function* () { | ||
| 1060 | const path = yield* Path.Path; | ||
| 1061 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1062 | const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); | ||
| 1063 | const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); | ||
| 1064 | const workspace = yield* makeTempDir("t3code-short-metadata-project-"); | ||
| 1065 | const directory = path.join(claudeHomePath, "projects", "p"); | ||
| 1066 | const template = path.join(directory, "template.jsonl"); | ||
| 1067 | const record = encodeTranscriptRecord({ cwd: workspace }); | ||
| 1068 | const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; | ||
| 1069 | const bytes = new TextEncoder().encode(contents); | ||
| 1070 | yield* writeTranscript({ | ||
| 1071 | filePath: template, | ||
| 1072 | contents, | ||
| 1073 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 1074 | }); | ||
| 1075 | let operations = 0; | ||
| 1076 | const observedFileSystem = FileSystem.FileSystem.of({ | ||
| 1077 | ...fileSystem, | ||
| 1078 | readDirectory: (target, options) => | ||
| 1079 | target === directory | ||
| 1080 | ? Effect.succeed( | ||
| 1081 | Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), | ||
| 1082 | ) | ||
| 1083 | : fileSystem.readDirectory(target, options), | ||
| 1084 | stat: (filePath) => | ||
| 1085 | fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), | ||
| 1086 | open: (filePath, options) => { | ||
| 1087 | if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); | ||
| 1088 | operations += 1; | ||
| 1089 | let offset = 0; | ||
| 1090 | return fileSystem.open(template, options).pipe( | ||
| 1091 | Effect.map((file) => ({ | ||
| 1092 | ...file, | ||
| 1093 | stat: file.stat, | ||
| 1094 | readAlloc: () => | ||
| 1095 | Effect.sync(() => { | ||
| 1096 | operations += 1; | ||
| 1097 | if (offset === bytes.length) return Option.none<Uint8Array>(); | ||
| 1098 | return Option.some(bytes.subarray(offset, ++offset)); | ||
| 1099 | }), | ||
| 1100 | })), | ||
| 1101 | ); | ||
| 1102 | }, | ||
| 1103 | }); | ||
| 1104 | const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( | ||
| 1105 | Effect.provideService(FileSystem.FileSystem, observedFileSystem), | ||
| 1106 | ); | ||
| 1107 | expect(operations).toBe(20_000); | ||
| 1108 | expect(result.candidates[0]?.threadCount).toBe(50); | ||
| 1109 | expect(result.truncated).toBe(count === 51 ? true : undefined); | ||
| 1110 | }), | ||
| 1111 | ); | ||
| 1112 | |||
| 1113 | it.effect("bounds malformed metadata records without excluding another account", () => | ||
| 1114 | Effect.gen(function* () { | ||
| 1115 | const path = yield* Path.Path; | ||
| 1116 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1117 | const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); | ||
| 1118 | const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); | ||
| 1119 | const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); | ||
| 1120 | const workspace = yield* makeTempDir("t3code-record-metadata-project-"); | ||
| 1121 | const directory = path.join(claudeHomePath, "projects", "p"); | ||
| 1122 | const template = path.join(directory, "template.jsonl"); | ||
| 1123 | yield* writeTranscript({ | ||
| 1124 | filePath: template, | ||
| 1125 | contents: "x\n".repeat(1_001), | ||
| 1126 | mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), | ||
| 1127 | }); | ||
| 1128 | yield* writeTranscript({ | ||
| 1129 | filePath: path.join(secondHome, "projects", "p", "session.jsonl"), | ||
| 1130 | contents: encodeTranscriptRecord({ cwd: workspace }), | ||
| 1131 | mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), | ||
| 1132 | }); | ||
| 1133 | let malformedOpens = 0; | ||
| 1134 | const observedFileSystem = FileSystem.FileSystem.of({ | ||
| 1135 | ...fileSystem, | ||
| 1136 | readDirectory: (target, options) => | ||
| 1137 | target === directory | ||
| 1138 | ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) | ||
| 1139 | : fileSystem.readDirectory(target, options), | ||
| 1140 | stat: (filePath) => | ||
| 1141 | fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), | ||
| 1142 | open: (filePath, options) => { | ||
| 1143 | if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); | ||
| 1144 | malformedOpens += 1; | ||
| 1145 | return fileSystem.open(template, options); | ||
| 1146 | }, | ||
| 1147 | }); | ||
| 1148 | const result = yield* runScan({ | ||
| 1149 | claudeHomePath, | ||
| 1150 | codexHomePath, | ||
| 1151 | providerInstances: { | ||
| 1152 | [ProviderInstanceId.make("claude-work")]: { | ||
| 1153 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 1154 | config: { homePath: secondHome }, | ||
| 1155 | }, | ||
| 1156 | }, | ||
| 1157 | }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); | ||
| 1158 | expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 1159 | expect(malformedOpens).toBe(100); | ||
| 1160 | expect(result.truncated).toBe(true); | ||
| 1161 | }), | ||
| 1162 | ); | ||
| 1163 | |||
| 1164 | it.effect.each([19_999, 20_000])( | ||
| 1165 | "reports unfinished directory work for %s project directories", | ||
| 1166 | (count) => | ||
| 1167 | Effect.gen(function* () { | ||
| 1168 | const path = yield* Path.Path; | ||
| 1169 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1170 | const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); | ||
| 1171 | const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); | ||
| 1172 | const projectsDir = path.join(claudeHomePath, "projects"); | ||
| 1173 | let reads = 0; | ||
| 1174 | const observedFileSystem = FileSystem.FileSystem.of({ | ||
| 1175 | ...fileSystem, | ||
| 1176 | readDirectory: (directory, options) => { | ||
| 1177 | if (directory === projectsDir) { | ||
| 1178 | reads += 1; | ||
| 1179 | return Effect.succeed( | ||
| 1180 | Array.from({ length: count }, (_, index) => `project-${index}`), | ||
| 1181 | ); | ||
| 1182 | } | ||
| 1183 | if (path.dirname(directory) === projectsDir) { | ||
| 1184 | reads += 1; | ||
| 1185 | return Effect.succeed([]); | ||
| 1186 | } | ||
| 1187 | return fileSystem.readDirectory(directory, options); | ||
| 1188 | }, | ||
| 1189 | }); | ||
| 1190 | const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( | ||
| 1191 | Effect.provideService(FileSystem.FileSystem, observedFileSystem), | ||
| 1192 | ); | ||
| 1193 | expect(reads).toBe(20_000); | ||
| 1194 | expect(result.candidates).toEqual([]); | ||
| 1195 | expect(result.truncated).toBe(count === 20_000 ? true : undefined); | ||
| 1196 | }), | ||
| 1197 | ); | ||
| 1198 | |||
| 1199 | it.effect("skips malformed transcripts without failing the scan", () => | ||
| 1200 | Effect.gen(function* () { | ||
| 1201 | const path = yield* Path.Path; | ||
| 1202 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1203 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1204 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1205 | |||
| 1206 | yield* writeTranscript({ | ||
| 1207 | filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), | ||
| 1208 | contents: "not json at all\n", | ||
| 1209 | mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), | ||
| 1210 | }); | ||
| 1211 | // Valid JSON, but no cwd anywhere in the record. | ||
| 1212 | yield* writeTranscript({ | ||
| 1213 | filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), | ||
| 1214 | contents: `{"type":"summary"}\n`, | ||
| 1215 | mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), | ||
| 1216 | }); | ||
| 1217 | yield* writeTranscript({ | ||
| 1218 | filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), | ||
| 1219 | contents: claudeSessionLine(workspace), | ||
| 1220 | mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), | ||
| 1221 | }); | ||
| 1222 | |||
| 1223 | const result = yield* runScan({ claudeHomePath, codexHomePath }); | ||
| 1224 | |||
| 1225 | expect(result.candidates).toEqual([ | ||
| 1226 | { | ||
| 1227 | path: workspace, | ||
| 1228 | title: path.basename(workspace), | ||
| 1229 | sources: ["claudeAgent"], | ||
| 1230 | threadCount: 1, | ||
| 1231 | lastActiveAt: "2026-05-03T00:00:00.000Z", | ||
| 1232 | alreadyImported: false, | ||
| 1233 | }, | ||
| 1234 | ]); | ||
| 1235 | }), | ||
| 1236 | ); | ||
| 1237 | |||
| 1238 | it.effect("returns an empty result when neither home directory exists", () => | ||
| 1239 | Effect.gen(function* () { | ||
| 1240 | const path = yield* Path.Path; | ||
| 1241 | const root = yield* makeTempDir("t3code-missing-homes-"); | ||
| 1242 | |||
| 1243 | const result = yield* runScan({ | ||
| 1244 | claudeHomePath: path.join(root, "no-claude"), | ||
| 1245 | codexHomePath: path.join(root, "no-codex"), | ||
| 1246 | }); | ||
| 1247 | |||
| 1248 | expect(result.candidates).toEqual([]); | ||
| 1249 | expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); | ||
| 1250 | }), | ||
| 1251 | ); | ||
| 1252 | }); | ||
| 1253 | |||
| 1254 | describe("recentThreads", () => { | ||
| 1255 | it.effect.each([false, true])( | ||
| 1256 | "counts terminal newlines correctly with record overflow=%s", | ||
| 1257 | (overflow) => | ||
| 1258 | Effect.gen(function* () { | ||
| 1259 | const path = yield* Path.Path; | ||
| 1260 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1261 | yield* TestClock.setTime(nowMs); | ||
| 1262 | const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); | ||
| 1263 | const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); | ||
| 1264 | const workspace = yield* makeTempDir("t3code-record-limit-project-"); | ||
| 1265 | const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); | ||
| 1266 | yield* writeTranscript({ | ||
| 1267 | filePath: path.join(directory, "rollout-records.jsonl"), | ||
| 1268 | contents: makeRecordLimitTranscript(workspace, overflow), | ||
| 1269 | mtimeMs: nowMs, | ||
| 1270 | }); | ||
| 1271 | yield* writeTranscript({ | ||
| 1272 | filePath: path.join(directory, "rollout-older.jsonl"), | ||
| 1273 | contents: [ | ||
| 1274 | encodeTranscriptRecord({ | ||
| 1275 | type: "session_meta", | ||
| 1276 | payload: { id: "older-session", cwd: workspace }, | ||
| 1277 | }), | ||
| 1278 | encodeTranscriptRecord({ | ||
| 1279 | type: "event_msg", | ||
| 1280 | payload: { type: "user_message", message: "Older prompt" }, | ||
| 1281 | }), | ||
| 1282 | ].join("\n"), | ||
| 1283 | mtimeMs: nowMs - 1_000, | ||
| 1284 | }); | ||
| 1285 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 1286 | claudeHomePath, | ||
| 1287 | codexHomePath, | ||
| 1288 | workspaceRoot: workspace, | ||
| 1289 | }); | ||
| 1290 | expect(outcomes.map((outcome) => outcome._tag)).toEqual( | ||
| 1291 | overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], | ||
| 1292 | ); | ||
| 1293 | expect( | ||
| 1294 | outcomes.flatMap((outcome) => | ||
| 1295 | outcome._tag === "Importable" | ||
| 1296 | ? outcome.thread.messages.map((message) => message.text) | ||
| 1297 | : [], | ||
| 1298 | ), | ||
| 1299 | ).toEqual([overflow ? "Older prompt" : "First prompt"]); | ||
| 1300 | }), | ||
| 1301 | ); | ||
| 1302 | |||
| 1303 | it.effect("imports recent Claude and Codex sessions for the selected project only", () => | ||
| 1304 | Effect.gen(function* () { | ||
| 1305 | const path = yield* Path.Path; | ||
| 1306 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1307 | yield* TestClock.setTime(nowMs); | ||
| 1308 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1309 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1310 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1311 | const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); | ||
| 1312 | |||
| 1313 | const claudeTranscript = (cwd: string, sessionId: string) => | ||
| 1314 | `${JSON.stringify({ | ||
| 1315 | type: "user", | ||
| 1316 | cwd, | ||
| 1317 | sessionId, | ||
| 1318 | timestamp: "2026-08-23T12:00:00.000Z", | ||
| 1319 | message: { role: "user", content: "Fix the project" }, | ||
| 1320 | })}\n${JSON.stringify({ | ||
| 1321 | type: "assistant", | ||
| 1322 | sessionId, | ||
| 1323 | timestamp: "2026-08-23T12:01:00.000Z", | ||
| 1324 | message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, | ||
| 1325 | })}\n`; | ||
| 1326 | |||
| 1327 | yield* writeTranscript({ | ||
| 1328 | filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), | ||
| 1329 | contents: claudeTranscript(workspace, "claude-recent"), | ||
| 1330 | mtimeMs: nowMs - 24 * 60 * 60 * 1000, | ||
| 1331 | }); | ||
| 1332 | yield* writeTranscript({ | ||
| 1333 | filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), | ||
| 1334 | contents: claudeTranscript(workspace, "claude-old"), | ||
| 1335 | mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, | ||
| 1336 | }); | ||
| 1337 | yield* writeTranscript({ | ||
| 1338 | filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), | ||
| 1339 | contents: claudeTranscript(otherWorkspace, "claude-other"), | ||
| 1340 | mtimeMs: nowMs - 24 * 60 * 60 * 1000, | ||
| 1341 | }); | ||
| 1342 | yield* writeTranscript({ | ||
| 1343 | filePath: path.join( | ||
| 1344 | codexHomePath, | ||
| 1345 | "sessions", | ||
| 1346 | "2026", | ||
| 1347 | "08", | ||
| 1348 | "24", | ||
| 1349 | "rollout-codex-recent.jsonl", | ||
| 1350 | ), | ||
| 1351 | contents: [ | ||
| 1352 | encodeTranscriptRecord({ | ||
| 1353 | type: "session_meta", | ||
| 1354 | payload: { id: "codex-recent", cwd: workspace }, | ||
| 1355 | }), | ||
| 1356 | encodeTranscriptRecord({ | ||
| 1357 | type: "event_msg", | ||
| 1358 | timestamp: "2026-08-24T10:00:00.000Z", | ||
| 1359 | payload: { type: "user_message", message: "Review this code" }, | ||
| 1360 | }), | ||
| 1361 | encodeTranscriptRecord({ | ||
| 1362 | type: "response_item", | ||
| 1363 | timestamp: "2026-08-24T10:01:00.000Z", | ||
| 1364 | payload: { | ||
| 1365 | type: "message", | ||
| 1366 | role: "assistant", | ||
| 1367 | content: [{ type: "output_text", text: "Looks good" }], | ||
| 1368 | }, | ||
| 1369 | }), | ||
| 1370 | ].join("\n"), | ||
| 1371 | mtimeMs: nowMs - 60 * 60 * 1000, | ||
| 1372 | }); | ||
| 1373 | |||
| 1374 | const threads = yield* runRecentThreads({ | ||
| 1375 | claudeHomePath, | ||
| 1376 | codexHomePath, | ||
| 1377 | workspaceRoot: workspace, | ||
| 1378 | }); | ||
| 1379 | |||
| 1380 | expect(threads.map((thread) => thread.providerSessionId)).toEqual([ | ||
| 1381 | "codex-recent", | ||
| 1382 | "claude-recent", | ||
| 1383 | ]); | ||
| 1384 | expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ | ||
| 1385 | ["Review this code", "Looks good"], | ||
| 1386 | ["Fix the project", "Done"], | ||
| 1387 | ]); | ||
| 1388 | }), | ||
| 1389 | ); | ||
| 1390 | |||
| 1391 | it.effect("imports history recorded with a case alias", () => | ||
| 1392 | Effect.gen(function* () { | ||
| 1393 | const path = yield* Path.Path; | ||
| 1394 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1395 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1396 | yield* TestClock.setTime(nowMs); | ||
| 1397 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1398 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1399 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1400 | const workspaceAlias = path.join( | ||
| 1401 | path.dirname(workspace), | ||
| 1402 | path.basename(workspace).toUpperCase(), | ||
| 1403 | ); | ||
| 1404 | |||
| 1405 | yield* writeTranscript({ | ||
| 1406 | filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), | ||
| 1407 | contents: [ | ||
| 1408 | encodeTranscriptRecord({ | ||
| 1409 | type: "user", | ||
| 1410 | cwd: workspaceAlias, | ||
| 1411 | sessionId: "case-session", | ||
| 1412 | timestamp: "2026-08-24T10:00:00.000Z", | ||
| 1413 | message: { role: "user", content: "Import case alias history" }, | ||
| 1414 | }), | ||
| 1415 | encodeTranscriptRecord({ | ||
| 1416 | type: "assistant", | ||
| 1417 | sessionId: "case-session", | ||
| 1418 | timestamp: "2026-08-24T10:01:00.000Z", | ||
| 1419 | message: { role: "assistant", content: "Imported" }, | ||
| 1420 | }), | ||
| 1421 | ].join("\n"), | ||
| 1422 | mtimeMs: nowMs, | ||
| 1423 | }); | ||
| 1424 | |||
| 1425 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 1426 | ...fileSystem, | ||
| 1427 | stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), | ||
| 1428 | }); | ||
| 1429 | const threads = yield* runRecentThreads({ | ||
| 1430 | claudeHomePath, | ||
| 1431 | codexHomePath, | ||
| 1432 | workspaceRoot: workspace, | ||
| 1433 | }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); | ||
| 1434 | |||
| 1435 | expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); | ||
| 1436 | }), | ||
| 1437 | ); | ||
| 1438 | |||
| 1439 | it.effect("keeps the provider instance that owns a custom session home", () => | ||
| 1440 | Effect.gen(function* () { | ||
| 1441 | const path = yield* Path.Path; | ||
| 1442 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1443 | yield* TestClock.setTime(nowMs); | ||
| 1444 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1445 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1446 | const customHome = yield* makeTempDir("t3code-codex-custom-"); | ||
| 1447 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1448 | |||
| 1449 | yield* writeTranscript({ | ||
| 1450 | filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), | ||
| 1451 | contents: [ | ||
| 1452 | encodeTranscriptRecord({ | ||
| 1453 | type: "session_meta", | ||
| 1454 | payload: { id: "custom-session", cwd: workspace }, | ||
| 1455 | }), | ||
| 1456 | encodeTranscriptRecord({ | ||
| 1457 | type: "event_msg", | ||
| 1458 | payload: { type: "user_message", message: "Use my work account" }, | ||
| 1459 | }), | ||
| 1460 | ].join("\n"), | ||
| 1461 | mtimeMs: nowMs, | ||
| 1462 | }); | ||
| 1463 | |||
| 1464 | const threads = yield* runRecentThreads({ | ||
| 1465 | claudeHomePath, | ||
| 1466 | codexHomePath, | ||
| 1467 | workspaceRoot: workspace, | ||
| 1468 | providerInstances: { | ||
| 1469 | [ProviderInstanceId.make("codex-work")]: { | ||
| 1470 | driver: ProviderDriverKind.make("codex"), | ||
| 1471 | config: { homePath: customHome }, | ||
| 1472 | }, | ||
| 1473 | }, | ||
| 1474 | }); | ||
| 1475 | |||
| 1476 | expect(threads[0]?.providerInstanceId).toBe("codex-work"); | ||
| 1477 | }), | ||
| 1478 | ); | ||
| 1479 | |||
| 1480 | it.effect("suppresses duplicate session copies without reporting a skipped import", () => | ||
| 1481 | Effect.gen(function* () { | ||
| 1482 | const path = yield* Path.Path; | ||
| 1483 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1484 | yield* TestClock.setTime(nowMs); | ||
| 1485 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1486 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1487 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1488 | const contents = [ | ||
| 1489 | encodeTranscriptRecord({ | ||
| 1490 | type: "session_meta", | ||
| 1491 | payload: { id: "copied-session", cwd: workspace }, | ||
| 1492 | }), | ||
| 1493 | encodeTranscriptRecord({ | ||
| 1494 | type: "event_msg", | ||
| 1495 | payload: { type: "user_message", message: "Import this session once" }, | ||
| 1496 | }), | ||
| 1497 | ].join("\n"); | ||
| 1498 | |||
| 1499 | for (const [name, mtimeMs] of [ | ||
| 1500 | ["rollout-copy-a.jsonl", nowMs], | ||
| 1501 | ["rollout-copy-b.jsonl", nowMs - 1], | ||
| 1502 | ] as const) { | ||
| 1503 | yield* writeTranscript({ | ||
| 1504 | filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), | ||
| 1505 | contents, | ||
| 1506 | mtimeMs, | ||
| 1507 | }); | ||
| 1508 | } | ||
| 1509 | |||
| 1510 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 1511 | claudeHomePath, | ||
| 1512 | codexHomePath, | ||
| 1513 | workspaceRoot: workspace, | ||
| 1514 | }); | ||
| 1515 | |||
| 1516 | expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); | ||
| 1517 | expect(outcomes[0]).toMatchObject({ | ||
| 1518 | _tag: "Importable", | ||
| 1519 | thread: { providerSessionId: "copied-session" }, | ||
| 1520 | }); | ||
| 1521 | }), | ||
| 1522 | ); | ||
| 1523 | |||
| 1524 | it.effect("shares a 64 MiB full-read budget across providers without hiding projects", () => | ||
| 1525 | Effect.gen(function* () { | ||
| 1526 | const path = yield* Path.Path; | ||
| 1527 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1528 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1529 | yield* TestClock.setTime(nowMs); | ||
| 1530 | const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); | ||
| 1531 | const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); | ||
| 1532 | const workspace = yield* makeTempDir("t3code-budget-workspace-"); | ||
| 1533 | const transcriptPaths = new Set<string>(); | ||
| 1534 | for (const [index, source] of [ | ||
| 1535 | "codex", | ||
| 1536 | "claudeAgent", | ||
| 1537 | "codex", | ||
| 1538 | "claudeAgent", | ||
| 1539 | "codex", | ||
| 1540 | ].entries()) { | ||
| 1541 | const sessionId = `budget-session-${index}`; | ||
| 1542 | const filePath = | ||
| 1543 | source === "codex" | ||
| 1544 | ? path.join( | ||
| 1545 | codexHomePath, | ||
| 1546 | "sessions", | ||
| 1547 | "2026", | ||
| 1548 | "08", | ||
| 1549 | "24", | ||
| 1550 | `rollout-${sessionId}.jsonl`, | ||
| 1551 | ) | ||
| 1552 | : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); | ||
| 1553 | const contents = | ||
| 1554 | source === "codex" | ||
| 1555 | ? [ | ||
| 1556 | encodeTranscriptRecord({ | ||
| 1557 | type: "session_meta", | ||
| 1558 | payload: { id: sessionId, cwd: workspace }, | ||
| 1559 | }), | ||
| 1560 | encodeTranscriptRecord({ | ||
| 1561 | type: "event_msg", | ||
| 1562 | payload: { type: "user_message", message: "Imported prompt" }, | ||
| 1563 | }), | ||
| 1564 | ].join("\n") | ||
| 1565 | : encodeTranscriptRecord({ | ||
| 1566 | type: "user", | ||
| 1567 | cwd: workspace, | ||
| 1568 | sessionId, | ||
| 1569 | message: { content: "Imported prompt" }, | ||
| 1570 | }); | ||
| 1571 | transcriptPaths.add(filePath); | ||
| 1572 | yield* writeTranscript({ | ||
| 1573 | filePath, | ||
| 1574 | contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), | ||
| 1575 | mtimeMs: nowMs - index * 1_000, | ||
| 1576 | }); | ||
| 1577 | } | ||
| 1578 | |||
| 1579 | const opens = new Map<string, number>(); | ||
| 1580 | let fullReadBytes = 0; | ||
| 1581 | const trackedFileSystem = FileSystem.FileSystem.of({ | ||
| 1582 | ...fileSystem, | ||
| 1583 | open: (filePath, options) => { | ||
| 1584 | const count = (opens.get(filePath) ?? 0) + 1; | ||
| 1585 | opens.set(filePath, count); | ||
| 1586 | return fileSystem.open(filePath, options).pipe( | ||
| 1587 | Effect.map((file) => | ||
| 1588 | !transcriptPaths.has(filePath) || count === 1 | ||
| 1589 | ? file | ||
| 1590 | : { | ||
| 1591 | ...file, | ||
| 1592 | stat: file.stat, | ||
| 1593 | readAlloc: (size: FileSystem.SizeInput) => | ||
| 1594 | file.readAlloc(size).pipe( | ||
| 1595 | Effect.tap((chunk) => | ||
| 1596 | Effect.sync(() => { | ||
| 1597 | if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; | ||
| 1598 | }), | ||
| 1599 | ), | ||
| 1600 | ), | ||
| 1601 | }, | ||
| 1602 | ), | ||
| 1603 | ); | ||
| 1604 | }, | ||
| 1605 | }); | ||
| 1606 | const outcomes = yield* Effect.gen(function* () { | ||
| 1607 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 1608 | const scan = yield* scanner.scan; | ||
| 1609 | expect(scan.candidates[0]?.threadCount).toBe(5); | ||
| 1610 | return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); | ||
| 1611 | }).pipe( | ||
| 1612 | Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), | ||
| 1613 | Effect.provideService(FileSystem.FileSystem, trackedFileSystem), | ||
| 1614 | ); | ||
| 1615 | |||
| 1616 | expect(outcomes.map((outcome) => outcome._tag)).toEqual([ | ||
| 1617 | "Importable", | ||
| 1618 | "Importable", | ||
| 1619 | "Importable", | ||
| 1620 | "Importable", | ||
| 1621 | "Skipped", | ||
| 1622 | ]); | ||
| 1623 | expect(fullReadBytes).toBe(64 * 1024 * 1024); | ||
| 1624 | }), | ||
| 1625 | ); | ||
| 1626 | |||
| 1627 | it.effect("skips excessive records without blocking an older valid transcript", () => | ||
| 1628 | Effect.gen(function* () { | ||
| 1629 | const path = yield* Path.Path; | ||
| 1630 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1631 | yield* TestClock.setTime(nowMs); | ||
| 1632 | const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); | ||
| 1633 | const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); | ||
| 1634 | const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); | ||
| 1635 | for (const [sessionId, padding, mtimeMs] of [ | ||
| 1636 | ["excessive", "\n".repeat(100_001), nowMs], | ||
| 1637 | ["older", "", nowMs - 1_000], | ||
| 1638 | ] as const) { | ||
| 1639 | yield* writeTranscript({ | ||
| 1640 | filePath: path.join( | ||
| 1641 | codexHomePath, | ||
| 1642 | "sessions", | ||
| 1643 | "2026", | ||
| 1644 | "08", | ||
| 1645 | "24", | ||
| 1646 | `rollout-${sessionId}.jsonl`, | ||
| 1647 | ), | ||
| 1648 | contents: | ||
| 1649 | [ | ||
| 1650 | encodeTranscriptRecord({ | ||
| 1651 | type: "session_meta", | ||
| 1652 | payload: { id: sessionId, cwd: workspace }, | ||
| 1653 | }), | ||
| 1654 | encodeTranscriptRecord({ | ||
| 1655 | type: "event_msg", | ||
| 1656 | payload: { type: "user_message", message: "Imported prompt" }, | ||
| 1657 | }), | ||
| 1658 | ].join("\n") + padding, | ||
| 1659 | mtimeMs, | ||
| 1660 | }); | ||
| 1661 | } | ||
| 1662 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 1663 | claudeHomePath, | ||
| 1664 | codexHomePath, | ||
| 1665 | workspaceRoot: workspace, | ||
| 1666 | }); | ||
| 1667 | expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); | ||
| 1668 | expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); | ||
| 1669 | }), | ||
| 1670 | ); | ||
| 1671 | |||
| 1672 | for (const source of ["claudeAgent", "codex"] as const) { | ||
| 1673 | for (const replacement of [ | ||
| 1674 | "same root", | ||
| 1675 | "other root", | ||
| 1676 | "symlink alias", | ||
| 1677 | "other then same", | ||
| 1678 | ] as const) { | ||
| 1679 | it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( | ||
| 1680 | `rechecks ${source} snapshot cwd after replacement with ${replacement}`, | ||
| 1681 | () => | ||
| 1682 | Effect.gen(function* () { | ||
| 1683 | const path = yield* Path.Path; | ||
| 1684 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1685 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1686 | yield* TestClock.setTime(nowMs); | ||
| 1687 | const fixture = yield* makeTempDir("t3code-replaced-cwd-"); | ||
| 1688 | const workspace = path.join(fixture, "original"); | ||
| 1689 | const otherWorkspace = path.join(fixture, "other"); | ||
| 1690 | const alias = path.join(fixture, "alias"); | ||
| 1691 | const claudeHomePath = path.join(fixture, "claude"); | ||
| 1692 | const codexHomePath = path.join(fixture, "codex"); | ||
| 1693 | yield* fileSystem.makeDirectory(workspace); | ||
| 1694 | yield* fileSystem.makeDirectory(otherWorkspace); | ||
| 1695 | if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); | ||
| 1696 | const filePath = | ||
| 1697 | source === "codex" | ||
| 1698 | ? path.join( | ||
| 1699 | codexHomePath, | ||
| 1700 | "sessions", | ||
| 1701 | "2026", | ||
| 1702 | "08", | ||
| 1703 | "24", | ||
| 1704 | "rollout-replaced.jsonl", | ||
| 1705 | ) | ||
| 1706 | : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); | ||
| 1707 | const makeContents = (cwd: string, text: string, laterCwd?: string) => | ||
| 1708 | [ | ||
| 1709 | ...(source === "codex" | ||
| 1710 | ? [ | ||
| 1711 | { type: "session_meta", payload: { id: "replacement-session", cwd } }, | ||
| 1712 | { type: "event_msg", payload: { type: "user_message", message: text } }, | ||
| 1713 | ] | ||
| 1714 | : [ | ||
| 1715 | { | ||
| 1716 | type: "user", | ||
| 1717 | cwd, | ||
| 1718 | sessionId: "replacement-session", | ||
| 1719 | message: { content: text }, | ||
| 1720 | }, | ||
| 1721 | ]), | ||
| 1722 | ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), | ||
| 1723 | ] | ||
| 1724 | .map((record) => encodeTranscriptRecord(record)) | ||
| 1725 | .join("\n"); | ||
| 1726 | yield* writeTranscript({ | ||
| 1727 | filePath, | ||
| 1728 | contents: makeContents(workspace, "Original prompt"), | ||
| 1729 | mtimeMs: nowMs, | ||
| 1730 | }); | ||
| 1731 | |||
| 1732 | yield* Effect.gen(function* () { | ||
| 1733 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 1734 | const scan = yield* scanner.scan; | ||
| 1735 | expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); | ||
| 1736 | const replacementCwd = | ||
| 1737 | replacement === "symlink alias" | ||
| 1738 | ? alias | ||
| 1739 | : replacement === "same root" | ||
| 1740 | ? workspace | ||
| 1741 | : otherWorkspace; | ||
| 1742 | yield* fileSystem.remove(filePath); | ||
| 1743 | yield* writeTranscript({ | ||
| 1744 | filePath, | ||
| 1745 | contents: makeContents( | ||
| 1746 | replacementCwd, | ||
| 1747 | "Replacement prompt", | ||
| 1748 | replacement === "other then same" ? workspace : undefined, | ||
| 1749 | ), | ||
| 1750 | mtimeMs: nowMs, | ||
| 1751 | }); | ||
| 1752 | const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); | ||
| 1753 | if (replacement === "same root" || replacement === "symlink alias") { | ||
| 1754 | expect(outcomes).toHaveLength(1); | ||
| 1755 | expect(outcomes[0]).toMatchObject({ | ||
| 1756 | _tag: "Importable", | ||
| 1757 | thread: { messages: [{ text: "Replacement prompt" }] }, | ||
| 1758 | }); | ||
| 1759 | } else { | ||
| 1760 | expect(outcomes).toEqual([{ _tag: "Skipped" }]); | ||
| 1761 | } | ||
| 1762 | }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); | ||
| 1763 | }), | ||
| 1764 | ); | ||
| 1765 | } | ||
| 1766 | } | ||
| 1767 | |||
| 1768 | it.effect("checks file identity and provider before skipping completed history", () => | ||
| 1769 | Effect.gen(function* () { | ||
| 1770 | const path = yield* Path.Path; | ||
| 1771 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1772 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1773 | yield* TestClock.setTime(nowMs); | ||
| 1774 | const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); | ||
| 1775 | const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); | ||
| 1776 | const workspace = yield* makeTempDir("t3code-completed-workspace-"); | ||
| 1777 | const filePath = path.join( | ||
| 1778 | codexHomePath, | ||
| 1779 | "sessions", | ||
| 1780 | "2026", | ||
| 1781 | "08", | ||
| 1782 | "24", | ||
| 1783 | "rollout-replaced.jsonl", | ||
| 1784 | ); | ||
| 1785 | const contents = (sessionId: string) => | ||
| 1786 | [ | ||
| 1787 | encodeTranscriptRecord({ | ||
| 1788 | type: "session_meta", | ||
| 1789 | payload: { id: sessionId, cwd: workspace }, | ||
| 1790 | }), | ||
| 1791 | encodeTranscriptRecord({ | ||
| 1792 | type: "event_msg", | ||
| 1793 | payload: { type: "user_message", message: "Imported prompt" }, | ||
| 1794 | }), | ||
| 1795 | ].join("\n"); | ||
| 1796 | yield* writeTranscript({ | ||
| 1797 | filePath, | ||
| 1798 | contents: contents("original-session"), | ||
| 1799 | mtimeMs: nowMs, | ||
| 1800 | }); | ||
| 1801 | |||
| 1802 | yield* Effect.gen(function* () { | ||
| 1803 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 1804 | const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); | ||
| 1805 | const imported = initial[0]; | ||
| 1806 | expect(imported?._tag).toBe("Importable"); | ||
| 1807 | if (imported?._tag !== "Importable") return; | ||
| 1808 | const completed = yield* scanner | ||
| 1809 | .recentThreads(workspace, [imported.source]) | ||
| 1810 | .pipe(Stream.runCollect); | ||
| 1811 | expect(completed[0]?._tag).toBe("AlreadyImported"); | ||
| 1812 | const wrongProvider = yield* scanner | ||
| 1813 | .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) | ||
| 1814 | .pipe(Stream.runCollect); | ||
| 1815 | expect(wrongProvider[0]?._tag).toBe("Importable"); | ||
| 1816 | |||
| 1817 | // Keep the old inode allocated while replacing the path with an equal-size file. | ||
| 1818 | yield* fileSystem.open(filePath); | ||
| 1819 | yield* fileSystem.remove(filePath); | ||
| 1820 | yield* writeTranscript({ | ||
| 1821 | filePath, | ||
| 1822 | contents: contents("replaced-session"), | ||
| 1823 | mtimeMs: nowMs, | ||
| 1824 | }); | ||
| 1825 | const replaced = yield* scanner | ||
| 1826 | .recentThreads(workspace, [imported.source]) | ||
| 1827 | .pipe(Stream.runCollect); | ||
| 1828 | expect(replaced[0]).toMatchObject({ | ||
| 1829 | _tag: "Importable", | ||
| 1830 | thread: { providerSessionId: "replaced-session" }, | ||
| 1831 | source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, | ||
| 1832 | }); | ||
| 1833 | }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); | ||
| 1834 | }), | ||
| 1835 | ); | ||
| 1836 | |||
| 1837 | it.effect("reports an eligible transcript over 16 MiB as skipped", () => | ||
| 1838 | Effect.gen(function* () { | ||
| 1839 | const path = yield* Path.Path; | ||
| 1840 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1841 | yield* TestClock.setTime(nowMs); | ||
| 1842 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1843 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1844 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1845 | const transcript = [ | ||
| 1846 | encodeTranscriptRecord({ | ||
| 1847 | type: "session_meta", | ||
| 1848 | payload: { id: "large-session", cwd: workspace }, | ||
| 1849 | }), | ||
| 1850 | encodeTranscriptRecord({ | ||
| 1851 | type: "event_msg", | ||
| 1852 | payload: { type: "user_message", message: "Import this large session" }, | ||
| 1853 | }), | ||
| 1854 | ] | ||
| 1855 | .join("\n") | ||
| 1856 | .padEnd(16 * 1024 * 1024 + 1, " "); | ||
| 1857 | yield* writeTranscript({ | ||
| 1858 | filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), | ||
| 1859 | contents: transcript, | ||
| 1860 | mtimeMs: nowMs, | ||
| 1861 | }); | ||
| 1862 | |||
| 1863 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 1864 | claudeHomePath, | ||
| 1865 | codexHomePath, | ||
| 1866 | workspaceRoot: workspace, | ||
| 1867 | }); | ||
| 1868 | |||
| 1869 | expect(outcomes).toEqual([{ _tag: "Skipped" }]); | ||
| 1870 | }), | ||
| 1871 | ); | ||
| 1872 | |||
| 1873 | it.effect("reports stat, read, and parse failures as skipped", () => | ||
| 1874 | Effect.gen(function* () { | ||
| 1875 | const path = yield* Path.Path; | ||
| 1876 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1877 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1878 | yield* TestClock.setTime(nowMs); | ||
| 1879 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1880 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1881 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1882 | const missingPath = path.join(codexHomePath, "missing.jsonl"); | ||
| 1883 | const transcriptPaths = { | ||
| 1884 | stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), | ||
| 1885 | read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), | ||
| 1886 | parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), | ||
| 1887 | }; | ||
| 1888 | const transcriptContents = (sessionId: string) => | ||
| 1889 | [ | ||
| 1890 | encodeTranscriptRecord({ | ||
| 1891 | type: "session_meta", | ||
| 1892 | payload: { id: sessionId, cwd: workspace }, | ||
| 1893 | }), | ||
| 1894 | encodeTranscriptRecord({ | ||
| 1895 | type: "event_msg", | ||
| 1896 | payload: { type: "user_message", message: "Import this session" }, | ||
| 1897 | }), | ||
| 1898 | ].join("\n"); | ||
| 1899 | |||
| 1900 | yield* writeTranscript({ | ||
| 1901 | filePath: transcriptPaths.stat, | ||
| 1902 | contents: transcriptContents("stat-session"), | ||
| 1903 | mtimeMs: nowMs, | ||
| 1904 | }); | ||
| 1905 | yield* writeTranscript({ | ||
| 1906 | filePath: transcriptPaths.read, | ||
| 1907 | contents: transcriptContents("read-session"), | ||
| 1908 | mtimeMs: nowMs, | ||
| 1909 | }); | ||
| 1910 | yield* writeTranscript({ | ||
| 1911 | filePath: transcriptPaths.parse, | ||
| 1912 | contents: encodeTranscriptRecord({ | ||
| 1913 | type: "session_meta", | ||
| 1914 | payload: { id: "parse-session", cwd: workspace }, | ||
| 1915 | }), | ||
| 1916 | mtimeMs: nowMs, | ||
| 1917 | }); | ||
| 1918 | |||
| 1919 | let statCount = 0; | ||
| 1920 | let readOpenCount = 0; | ||
| 1921 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 1922 | ...fileSystem, | ||
| 1923 | stat: (filePath) => { | ||
| 1924 | if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); | ||
| 1925 | statCount += 1; | ||
| 1926 | return fileSystem.stat(statCount === 1 ? filePath : missingPath); | ||
| 1927 | }, | ||
| 1928 | open: (filePath, options) => { | ||
| 1929 | if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); | ||
| 1930 | readOpenCount += 1; | ||
| 1931 | return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); | ||
| 1932 | }, | ||
| 1933 | }); | ||
| 1934 | |||
| 1935 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 1936 | claudeHomePath, | ||
| 1937 | codexHomePath, | ||
| 1938 | workspaceRoot: workspace, | ||
| 1939 | }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); | ||
| 1940 | |||
| 1941 | expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); | ||
| 1942 | }), | ||
| 1943 | ); | ||
| 1944 | |||
| 1945 | it.effect("does not reopen a transcript that becomes a non-file after discovery", () => | ||
| 1946 | Effect.gen(function* () { | ||
| 1947 | const path = yield* Path.Path; | ||
| 1948 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1949 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 1950 | yield* TestClock.setTime(nowMs); | ||
| 1951 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 1952 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 1953 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 1954 | const nonFilePath = yield* makeTempDir("t3code-non-file-"); | ||
| 1955 | const transcriptPath = path.join( | ||
| 1956 | codexHomePath, | ||
| 1957 | "sessions", | ||
| 1958 | "2026", | ||
| 1959 | "08", | ||
| 1960 | "24", | ||
| 1961 | "rollout-changed.jsonl", | ||
| 1962 | ); | ||
| 1963 | yield* writeTranscript({ | ||
| 1964 | filePath: transcriptPath, | ||
| 1965 | contents: [ | ||
| 1966 | encodeTranscriptRecord({ | ||
| 1967 | type: "session_meta", | ||
| 1968 | payload: { id: "changed-session", cwd: workspace }, | ||
| 1969 | }), | ||
| 1970 | encodeTranscriptRecord({ | ||
| 1971 | type: "event_msg", | ||
| 1972 | payload: { type: "user_message", message: "Do not import this session" }, | ||
| 1973 | }), | ||
| 1974 | ].join("\n"), | ||
| 1975 | mtimeMs: nowMs, | ||
| 1976 | }); | ||
| 1977 | |||
| 1978 | let transcriptStatCount = 0; | ||
| 1979 | let transcriptOpenCount = 0; | ||
| 1980 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 1981 | ...fileSystem, | ||
| 1982 | stat: (filePath) => { | ||
| 1983 | if (filePath !== transcriptPath) return fileSystem.stat(filePath); | ||
| 1984 | transcriptStatCount += 1; | ||
| 1985 | return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); | ||
| 1986 | }, | ||
| 1987 | open: (filePath, options) => { | ||
| 1988 | if (filePath === transcriptPath) transcriptOpenCount += 1; | ||
| 1989 | return fileSystem.open(filePath, options); | ||
| 1990 | }, | ||
| 1991 | }); | ||
| 1992 | |||
| 1993 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 1994 | claudeHomePath, | ||
| 1995 | codexHomePath, | ||
| 1996 | workspaceRoot: workspace, | ||
| 1997 | }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); | ||
| 1998 | |||
| 1999 | expect(transcriptStatCount).toBe(2); | ||
| 2000 | expect(transcriptOpenCount).toBe(1); | ||
| 2001 | expect(outcomes).toEqual([{ _tag: "Skipped" }]); | ||
| 2002 | }), | ||
| 2003 | ); | ||
| 2004 | |||
| 2005 | it.effect("does not import a transcript dated after the current time", () => | ||
| 2006 | Effect.gen(function* () { | ||
| 2007 | const path = yield* Path.Path; | ||
| 2008 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2009 | yield* TestClock.setTime(nowMs); | ||
| 2010 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2011 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2012 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 2013 | yield* writeTranscript({ | ||
| 2014 | filePath: path.join( | ||
| 2015 | codexHomePath, | ||
| 2016 | "sessions", | ||
| 2017 | "2026", | ||
| 2018 | "08", | ||
| 2019 | "24", | ||
| 2020 | "rollout-future.jsonl", | ||
| 2021 | ), | ||
| 2022 | contents: [ | ||
| 2023 | encodeTranscriptRecord({ | ||
| 2024 | type: "session_meta", | ||
| 2025 | payload: { id: "future-session", cwd: workspace }, | ||
| 2026 | }), | ||
| 2027 | encodeTranscriptRecord({ | ||
| 2028 | type: "event_msg", | ||
| 2029 | payload: { type: "user_message", message: "Future work" }, | ||
| 2030 | }), | ||
| 2031 | ].join("\n"), | ||
| 2032 | mtimeMs: nowMs + 1, | ||
| 2033 | }); | ||
| 2034 | |||
| 2035 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 2036 | claudeHomePath, | ||
| 2037 | codexHomePath, | ||
| 2038 | workspaceRoot: workspace, | ||
| 2039 | }); | ||
| 2040 | |||
| 2041 | expect(outcomes).toEqual([]); | ||
| 2042 | }), | ||
| 2043 | ); | ||
| 2044 | |||
| 2045 | it.effect("skips growth during reading without exceeding the reserved bytes", () => | ||
| 2046 | Effect.gen(function* () { | ||
| 2047 | const path = yield* Path.Path; | ||
| 2048 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2049 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2050 | yield* TestClock.setTime(nowMs); | ||
| 2051 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2052 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2053 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 2054 | const transcriptPath = path.join( | ||
| 2055 | codexHomePath, | ||
| 2056 | "sessions", | ||
| 2057 | "2026", | ||
| 2058 | "08", | ||
| 2059 | "24", | ||
| 2060 | "rollout-growing.jsonl", | ||
| 2061 | ); | ||
| 2062 | const contents = [ | ||
| 2063 | encodeTranscriptRecord({ | ||
| 2064 | type: "session_meta", | ||
| 2065 | payload: { id: "growing-session", cwd: workspace }, | ||
| 2066 | }), | ||
| 2067 | encodeTranscriptRecord({ | ||
| 2068 | type: "event_msg", | ||
| 2069 | payload: { type: "user_message", message: "Do not import a changing file" }, | ||
| 2070 | }), | ||
| 2071 | ].join("\n"); | ||
| 2072 | yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); | ||
| 2073 | let transcriptOpenCount = 0; | ||
| 2074 | let fullReadBytes = 0; | ||
| 2075 | let grew = false; | ||
| 2076 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 2077 | ...fileSystem, | ||
| 2078 | open: (filePath, options) => { | ||
| 2079 | if (filePath !== transcriptPath) return fileSystem.open(filePath, options); | ||
| 2080 | transcriptOpenCount += 1; | ||
| 2081 | if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); | ||
| 2082 | return fileSystem.open(filePath, options).pipe( | ||
| 2083 | Effect.map((file) => ({ | ||
| 2084 | ...file, | ||
| 2085 | stat: file.stat, | ||
| 2086 | readAlloc: (size: FileSystem.SizeInput) => | ||
| 2087 | file.readAlloc(size).pipe( | ||
| 2088 | Effect.tap((chunk) => | ||
| 2089 | Effect.gen(function* () { | ||
| 2090 | if (chunk._tag === "None") return; | ||
| 2091 | fullReadBytes += chunk.value.byteLength; | ||
| 2092 | if (!grew) { | ||
| 2093 | grew = true; | ||
| 2094 | yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); | ||
| 2095 | } | ||
| 2096 | }), | ||
| 2097 | ), | ||
| 2098 | ), | ||
| 2099 | })), | ||
| 2100 | ); | ||
| 2101 | }, | ||
| 2102 | }); | ||
| 2103 | |||
| 2104 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 2105 | claudeHomePath, | ||
| 2106 | codexHomePath, | ||
| 2107 | workspaceRoot: workspace, | ||
| 2108 | }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); | ||
| 2109 | |||
| 2110 | expect(transcriptOpenCount).toBe(2); | ||
| 2111 | expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); | ||
| 2112 | expect(outcomes).toEqual([{ _tag: "Skipped" }]); | ||
| 2113 | }), | ||
| 2114 | ); | ||
| 2115 | |||
| 2116 | it.effect("skips a transcript that shrinks after its size check", () => | ||
| 2117 | Effect.gen(function* () { | ||
| 2118 | const path = yield* Path.Path; | ||
| 2119 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2120 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2121 | yield* TestClock.setTime(nowMs); | ||
| 2122 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2123 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2124 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 2125 | const transcriptPath = path.join( | ||
| 2126 | codexHomePath, | ||
| 2127 | "sessions", | ||
| 2128 | "2026", | ||
| 2129 | "08", | ||
| 2130 | "24", | ||
| 2131 | "rollout-shrinking.jsonl", | ||
| 2132 | ); | ||
| 2133 | const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); | ||
| 2134 | const contents = [ | ||
| 2135 | encodeTranscriptRecord({ | ||
| 2136 | type: "session_meta", | ||
| 2137 | payload: { id: "shrinking-session", cwd: workspace }, | ||
| 2138 | }), | ||
| 2139 | encodeTranscriptRecord({ | ||
| 2140 | type: "event_msg", | ||
| 2141 | payload: { type: "user_message", message: "Do not import a changing file" }, | ||
| 2142 | }), | ||
| 2143 | ].join("\n"); | ||
| 2144 | yield* writeTranscript({ | ||
| 2145 | filePath: transcriptPath, | ||
| 2146 | contents: `${contents}\n${"padding".repeat(100)}`, | ||
| 2147 | mtimeMs: nowMs, | ||
| 2148 | }); | ||
| 2149 | yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); | ||
| 2150 | |||
| 2151 | let transcriptOpenCount = 0; | ||
| 2152 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 2153 | ...fileSystem, | ||
| 2154 | open: (filePath, options) => { | ||
| 2155 | if (filePath !== transcriptPath) return fileSystem.open(filePath, options); | ||
| 2156 | transcriptOpenCount += 1; | ||
| 2157 | return fileSystem.open( | ||
| 2158 | transcriptOpenCount === 1 ? transcriptPath : shrunkPath, | ||
| 2159 | options, | ||
| 2160 | ); | ||
| 2161 | }, | ||
| 2162 | }); | ||
| 2163 | |||
| 2164 | const outcomes = yield* runRecentThreadOutcomes({ | ||
| 2165 | claudeHomePath, | ||
| 2166 | codexHomePath, | ||
| 2167 | workspaceRoot: workspace, | ||
| 2168 | }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); | ||
| 2169 | |||
| 2170 | expect(transcriptOpenCount).toBe(2); | ||
| 2171 | expect(outcomes).toEqual([{ _tag: "Skipped" }]); | ||
| 2172 | }), | ||
| 2173 | ); | ||
| 2174 | |||
| 2175 | it.effect("does not read the second transcript when the consumer takes one thread", () => | ||
| 2176 | Effect.gen(function* () { | ||
| 2177 | const path = yield* Path.Path; | ||
| 2178 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2179 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2180 | yield* TestClock.setTime(nowMs); | ||
| 2181 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2182 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2183 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 2184 | const makeCodexTranscript = (sessionId: string, text: string) => | ||
| 2185 | [ | ||
| 2186 | encodeTranscriptRecord({ | ||
| 2187 | type: "session_meta", | ||
| 2188 | payload: { id: sessionId, cwd: workspace }, | ||
| 2189 | }), | ||
| 2190 | encodeTranscriptRecord({ | ||
| 2191 | type: "event_msg", | ||
| 2192 | payload: { type: "user_message", message: text }, | ||
| 2193 | }), | ||
| 2194 | ].join("\n"); | ||
| 2195 | const olderPath = path.join( | ||
| 2196 | codexHomePath, | ||
| 2197 | "sessions", | ||
| 2198 | "2026", | ||
| 2199 | "08", | ||
| 2200 | "23", | ||
| 2201 | "rollout-older.jsonl", | ||
| 2202 | ); | ||
| 2203 | const newerPath = path.join( | ||
| 2204 | codexHomePath, | ||
| 2205 | "sessions", | ||
| 2206 | "2026", | ||
| 2207 | "08", | ||
| 2208 | "24", | ||
| 2209 | "rollout-newer.jsonl", | ||
| 2210 | ); | ||
| 2211 | yield* writeTranscript({ | ||
| 2212 | filePath: olderPath, | ||
| 2213 | contents: makeCodexTranscript("older-session", "Older prompt"), | ||
| 2214 | mtimeMs: nowMs - 1_000, | ||
| 2215 | }); | ||
| 2216 | yield* writeTranscript({ | ||
| 2217 | filePath: newerPath, | ||
| 2218 | contents: makeCodexTranscript("newer-session", "Newer prompt"), | ||
| 2219 | mtimeMs: nowMs, | ||
| 2220 | }); | ||
| 2221 | |||
| 2222 | const openCounts = new Map<string, number>(); | ||
| 2223 | const contentReads: Array<string> = []; | ||
| 2224 | const trackedPaths = new Set([olderPath, newerPath]); | ||
| 2225 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 2226 | ...fileSystem, | ||
| 2227 | open: (filePath, options) => { | ||
| 2228 | if (trackedPaths.has(filePath)) { | ||
| 2229 | const count = (openCounts.get(filePath) ?? 0) + 1; | ||
| 2230 | openCounts.set(filePath, count); | ||
| 2231 | if (count === 2) contentReads.push(filePath); | ||
| 2232 | } | ||
| 2233 | return fileSystem.open(filePath, options); | ||
| 2234 | }, | ||
| 2235 | }); | ||
| 2236 | |||
| 2237 | const threads = yield* Effect.gen(function* () { | ||
| 2238 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 2239 | return yield* scanner.recentThreads(workspace).pipe( | ||
| 2240 | Stream.take(1), | ||
| 2241 | Stream.runCollect, | ||
| 2242 | Effect.map((items) => Array.from(items)), | ||
| 2243 | ); | ||
| 2244 | }).pipe( | ||
| 2245 | Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), | ||
| 2246 | Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), | ||
| 2247 | ); | ||
| 2248 | |||
| 2249 | expect( | ||
| 2250 | threads.flatMap((outcome) => | ||
| 2251 | outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], | ||
| 2252 | ), | ||
| 2253 | ).toEqual(["newer-session"]); | ||
| 2254 | expect(contentReads).toEqual([newerPath]); | ||
| 2255 | expect(openCounts.get(olderPath)).toBe(1); | ||
| 2256 | }), | ||
| 2257 | ); | ||
| 2258 | |||
| 2259 | it.effect("does not import sessions from a T3-managed worktree", () => | ||
| 2260 | Effect.gen(function* () { | ||
| 2261 | const path = yield* Path.Path; | ||
| 2262 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2263 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2264 | yield* TestClock.setTime(nowMs); | ||
| 2265 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2266 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2267 | const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); | ||
| 2268 | const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); | ||
| 2269 | yield* fileSystem.makeDirectory(workspace, { recursive: true }); | ||
| 2270 | |||
| 2271 | yield* writeTranscript({ | ||
| 2272 | filePath: path.join( | ||
| 2273 | codexHomePath, | ||
| 2274 | "sessions", | ||
| 2275 | "2026", | ||
| 2276 | "08", | ||
| 2277 | "24", | ||
| 2278 | "rollout-managed.jsonl", | ||
| 2279 | ), | ||
| 2280 | contents: [ | ||
| 2281 | encodeTranscriptRecord({ | ||
| 2282 | type: "session_meta", | ||
| 2283 | payload: { id: "managed-session", cwd: workspace }, | ||
| 2284 | }), | ||
| 2285 | encodeTranscriptRecord({ | ||
| 2286 | type: "event_msg", | ||
| 2287 | payload: { type: "user_message", message: "Do not import this session" }, | ||
| 2288 | }), | ||
| 2289 | ].join("\n"), | ||
| 2290 | mtimeMs: nowMs, | ||
| 2291 | }); | ||
| 2292 | |||
| 2293 | const threads = yield* runRecentThreads({ | ||
| 2294 | claudeHomePath, | ||
| 2295 | codexHomePath, | ||
| 2296 | configBaseDir, | ||
| 2297 | workspaceRoot: workspace, | ||
| 2298 | }); | ||
| 2299 | |||
| 2300 | expect(threads).toEqual([]); | ||
| 2301 | }), | ||
| 2302 | ); | ||
| 2303 | |||
| 2304 | it.effect("uses one deterministic provider instance for a shared session home", () => | ||
| 2305 | Effect.gen(function* () { | ||
| 2306 | const path = yield* Path.Path; | ||
| 2307 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2308 | yield* TestClock.setTime(nowMs); | ||
| 2309 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2310 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2311 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 2312 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 2313 | |||
| 2314 | yield* writeTranscript({ | ||
| 2315 | filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), | ||
| 2316 | contents: [ | ||
| 2317 | encodeTranscriptRecord({ | ||
| 2318 | type: "session_meta", | ||
| 2319 | payload: { id: "shared-session", cwd: workspace }, | ||
| 2320 | }), | ||
| 2321 | encodeTranscriptRecord({ | ||
| 2322 | type: "event_msg", | ||
| 2323 | payload: { type: "user_message", message: "Use the shared session" }, | ||
| 2324 | }), | ||
| 2325 | ].join("\n"), | ||
| 2326 | mtimeMs: nowMs, | ||
| 2327 | }); | ||
| 2328 | |||
| 2329 | const threads = yield* runRecentThreads({ | ||
| 2330 | claudeHomePath, | ||
| 2331 | codexHomePath, | ||
| 2332 | workspaceRoot: workspace, | ||
| 2333 | providerInstances: { | ||
| 2334 | [ProviderInstanceId.make("codex")]: { | ||
| 2335 | driver: ProviderDriverKind.make("codex"), | ||
| 2336 | config: { homePath: sharedHome }, | ||
| 2337 | }, | ||
| 2338 | [ProviderInstanceId.make("codex-personal")]: { | ||
| 2339 | driver: ProviderDriverKind.make("codex"), | ||
| 2340 | config: { homePath: sharedHome }, | ||
| 2341 | }, | ||
| 2342 | [ProviderInstanceId.make("codex-work")]: { | ||
| 2343 | driver: ProviderDriverKind.make("codex"), | ||
| 2344 | config: { homePath: sharedHome }, | ||
| 2345 | }, | ||
| 2346 | }, | ||
| 2347 | }); | ||
| 2348 | |||
| 2349 | expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); | ||
| 2350 | }), | ||
| 2351 | ); | ||
| 2352 | |||
| 2353 | it.effect("uses configured order when custom instances share a session home", () => | ||
| 2354 | Effect.gen(function* () { | ||
| 2355 | const path = yield* Path.Path; | ||
| 2356 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2357 | yield* TestClock.setTime(nowMs); | ||
| 2358 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2359 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2360 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); | ||
| 2361 | const workspace = yield* makeTempDir("t3code-workspace-"); | ||
| 2362 | |||
| 2363 | yield* writeTranscript({ | ||
| 2364 | filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), | ||
| 2365 | contents: [ | ||
| 2366 | encodeTranscriptRecord({ | ||
| 2367 | type: "session_meta", | ||
| 2368 | payload: { id: "shared-session", cwd: workspace }, | ||
| 2369 | }), | ||
| 2370 | encodeTranscriptRecord({ | ||
| 2371 | type: "event_msg", | ||
| 2372 | payload: { type: "user_message", message: "Use the first account" }, | ||
| 2373 | }), | ||
| 2374 | ].join("\n"), | ||
| 2375 | mtimeMs: nowMs, | ||
| 2376 | }); | ||
| 2377 | |||
| 2378 | const threads = yield* runRecentThreads({ | ||
| 2379 | claudeHomePath, | ||
| 2380 | codexHomePath, | ||
| 2381 | workspaceRoot: workspace, | ||
| 2382 | providerInstances: { | ||
| 2383 | [ProviderInstanceId.make("codex-work")]: { | ||
| 2384 | driver: ProviderDriverKind.make("codex"), | ||
| 2385 | config: { homePath: sharedHome }, | ||
| 2386 | }, | ||
| 2387 | [ProviderInstanceId.make("codex-personal")]: { | ||
| 2388 | driver: ProviderDriverKind.make("codex"), | ||
| 2389 | config: { homePath: sharedHome }, | ||
| 2390 | }, | ||
| 2391 | }, | ||
| 2392 | }); | ||
| 2393 | |||
| 2394 | expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); | ||
| 2395 | }), | ||
| 2396 | ); | ||
| 2397 | |||
| 2398 | it.effect("keeps a second account when the first has 5000 newer files", () => | ||
| 2399 | Effect.gen(function* () { | ||
| 2400 | const path = yield* Path.Path; | ||
| 2401 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2402 | const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); | ||
| 2403 | yield* TestClock.setTime(nowMs); | ||
| 2404 | const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); | ||
| 2405 | const codexHomePath = yield* makeTempDir("t3code-codex-home-"); | ||
| 2406 | const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); | ||
| 2407 | const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); | ||
| 2408 | const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); | ||
| 2409 | const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); | ||
| 2410 | const oldTranscript = path.join(oldDirectory, "old.jsonl"); | ||
| 2411 | const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); | ||
| 2412 | |||
| 2413 | yield* writeTranscript({ | ||
| 2414 | filePath: oldTranscript, | ||
| 2415 | contents: encodeTranscriptRecord({ | ||
| 2416 | type: "user", | ||
| 2417 | cwd: oldWorkspace, | ||
| 2418 | sessionId: "old-session", | ||
| 2419 | message: { role: "user", content: "Old work" }, | ||
| 2420 | }), | ||
| 2421 | mtimeMs: nowMs, | ||
| 2422 | }); | ||
| 2423 | yield* writeTranscript({ | ||
| 2424 | filePath: path.join(recentDirectory, "recent.jsonl"), | ||
| 2425 | contents: encodeTranscriptRecord({ | ||
| 2426 | type: "user", | ||
| 2427 | cwd: recentWorkspace, | ||
| 2428 | sessionId: "recent-session", | ||
| 2429 | message: { role: "user", content: "Recent work" }, | ||
| 2430 | }), | ||
| 2431 | mtimeMs: nowMs - 1_000, | ||
| 2432 | }); | ||
| 2433 | |||
| 2434 | const simulatedOldTranscripts = Array.from( | ||
| 2435 | { length: 5_000 }, | ||
| 2436 | (_, index) => `old-${index}.jsonl`, | ||
| 2437 | ); | ||
| 2438 | const resolveTranscript = (filePath: string) => | ||
| 2439 | path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") | ||
| 2440 | ? oldTranscript | ||
| 2441 | : filePath; | ||
| 2442 | const simulatedFileSystem = FileSystem.FileSystem.of({ | ||
| 2443 | ...fileSystem, | ||
| 2444 | readDirectory: (directory, options) => | ||
| 2445 | directory === oldDirectory | ||
| 2446 | ? Effect.succeed(simulatedOldTranscripts) | ||
| 2447 | : fileSystem.readDirectory(directory, options), | ||
| 2448 | stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), | ||
| 2449 | open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), | ||
| 2450 | }); | ||
| 2451 | |||
| 2452 | const input = { | ||
| 2453 | claudeHomePath, | ||
| 2454 | codexHomePath, | ||
| 2455 | providerInstances: { | ||
| 2456 | [ProviderInstanceId.make("claude-work")]: { | ||
| 2457 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 2458 | config: { homePath: recentHome }, | ||
| 2459 | }, | ||
| 2460 | }, | ||
| 2461 | }; | ||
| 2462 | const threads = yield* Effect.gen(function* () { | ||
| 2463 | const scanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 2464 | const scan = yield* scanner.scan; | ||
| 2465 | expect(scan.truncated).toBe(true); | ||
| 2466 | return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); | ||
| 2467 | }).pipe( | ||
| 2468 | Effect.provide(makeScannerTestLayer(input)), | ||
| 2469 | Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), | ||
| 2470 | ); | ||
| 2471 | |||
| 2472 | expect( | ||
| 2473 | threads.flatMap((outcome) => | ||
| 2474 | outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], | ||
| 2475 | ), | ||
| 2476 | ).toEqual(["recent-session"]); | ||
| 2477 | }), | ||
| 2478 | ); | ||
| 2479 | }); | ||
| 2480 | }); | ||
| 2481 | |||
| 2482 | describe("parseAgentSessionTranscript", () => { | ||
| 2483 | it.each([false, true])( | ||
| 2484 | "handles the exact record limit and an interior blank overflow=%s", | ||
| 2485 | (overflow) => { | ||
| 2486 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2487 | contents: makeRecordLimitTranscript("/project", overflow), | ||
| 2488 | source: "codex", | ||
| 2489 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2490 | fallbackSessionId: "unused", | ||
| 2491 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2492 | }); | ||
| 2493 | if (overflow) expect(thread).toBeNull(); | ||
| 2494 | else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); | ||
| 2495 | }, | ||
| 2496 | ); | ||
| 2497 | |||
| 2498 | it("keeps Claude text and titles while dropping malformed and tool records", () => { | ||
| 2499 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2500 | contents: [ | ||
| 2501 | "not valid json", | ||
| 2502 | JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), | ||
| 2503 | JSON.stringify({ | ||
| 2504 | type: "user", | ||
| 2505 | sessionId: "claude-session", | ||
| 2506 | isMeta: true, | ||
| 2507 | message: { role: "user", content: "Injected skill instructions" }, | ||
| 2508 | }), | ||
| 2509 | JSON.stringify({ | ||
| 2510 | type: "user", | ||
| 2511 | sessionId: "claude-session", | ||
| 2512 | isCompactSummary: true, | ||
| 2513 | message: { role: "user", content: "Injected compaction summary" }, | ||
| 2514 | }), | ||
| 2515 | JSON.stringify({ | ||
| 2516 | type: "user", | ||
| 2517 | sessionId: "claude-session", | ||
| 2518 | timestamp: "2026-08-24T10:00:00.000Z", | ||
| 2519 | message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, | ||
| 2520 | }), | ||
| 2521 | JSON.stringify({ | ||
| 2522 | type: "user", | ||
| 2523 | sessionId: "claude-session", | ||
| 2524 | message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, | ||
| 2525 | }), | ||
| 2526 | JSON.stringify({ | ||
| 2527 | type: "assistant", | ||
| 2528 | sessionId: "claude-session", | ||
| 2529 | message: { | ||
| 2530 | role: "assistant", | ||
| 2531 | model: "claude-sonnet-5", | ||
| 2532 | content: [{ type: "text", text: "Updated the login flow" }], | ||
| 2533 | }, | ||
| 2534 | }), | ||
| 2535 | JSON.stringify({ | ||
| 2536 | type: "assistant", | ||
| 2537 | sessionId: "claude-session", | ||
| 2538 | message: { | ||
| 2539 | role: "assistant", | ||
| 2540 | model: "<synthetic>", | ||
| 2541 | content: [{ type: "text", text: "The provider request failed" }], | ||
| 2542 | }, | ||
| 2543 | }), | ||
| 2544 | ].join("\n"), | ||
| 2545 | source: "claudeAgent", | ||
| 2546 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 2547 | fallbackSessionId: "fallback", | ||
| 2548 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2549 | }); | ||
| 2550 | |||
| 2551 | expect(thread).toMatchObject({ | ||
| 2552 | providerSessionId: "claude-session", | ||
| 2553 | title: "Fix authentication", | ||
| 2554 | model: "claude-sonnet-5", | ||
| 2555 | messages: [ | ||
| 2556 | { role: "user", text: "Fix authentication" }, | ||
| 2557 | { role: "assistant", text: "Updated the login flow" }, | ||
| 2558 | { role: "assistant", text: "The provider request failed" }, | ||
| 2559 | ], | ||
| 2560 | }); | ||
| 2561 | }); | ||
| 2562 | |||
| 2563 | it("drops injected Codex instructions while keeping the visible user event", () => { | ||
| 2564 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2565 | contents: [ | ||
| 2566 | JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2567 | JSON.stringify({ | ||
| 2568 | type: "response_item", | ||
| 2569 | payload: { | ||
| 2570 | type: "message", | ||
| 2571 | role: "user", | ||
| 2572 | internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, | ||
| 2573 | content: [ | ||
| 2574 | { | ||
| 2575 | type: "input_text", | ||
| 2576 | text: "<user_instructions>\nInternal setup instructions\n</user_instructions>", | ||
| 2577 | }, | ||
| 2578 | ], | ||
| 2579 | }, | ||
| 2580 | }), | ||
| 2581 | JSON.stringify({ | ||
| 2582 | type: "event_msg", | ||
| 2583 | payload: { type: "user_message", message: "Fix the actual bug" }, | ||
| 2584 | }), | ||
| 2585 | JSON.stringify({ | ||
| 2586 | type: "response_item", | ||
| 2587 | payload: { | ||
| 2588 | type: "message", | ||
| 2589 | role: "user", | ||
| 2590 | internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, | ||
| 2591 | content: [{ type: "input_text", text: "Fix the actual bug" }], | ||
| 2592 | }, | ||
| 2593 | }), | ||
| 2594 | JSON.stringify({ | ||
| 2595 | type: "response_item", | ||
| 2596 | payload: { | ||
| 2597 | type: "message", | ||
| 2598 | role: "assistant", | ||
| 2599 | content: [{ type: "output_text", text: "Fixed" }], | ||
| 2600 | }, | ||
| 2601 | }), | ||
| 2602 | ].join("\n"), | ||
| 2603 | source: "codex", | ||
| 2604 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2605 | fallbackSessionId: "fallback", | ||
| 2606 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2607 | }); | ||
| 2608 | |||
| 2609 | expect(thread?.messages.map((message) => message.text)).toEqual([ | ||
| 2610 | "Fix the actual bug", | ||
| 2611 | "Fixed", | ||
| 2612 | ]); | ||
| 2613 | }); | ||
| 2614 | |||
| 2615 | it("keeps the canonical first prompt after long Codex transcripts are capped", () => { | ||
| 2616 | const canonicalPrompt = "\n Keep the canonical prompt \n"; | ||
| 2617 | const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; | ||
| 2618 | const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => | ||
| 2619 | encodeTranscriptRecord({ | ||
| 2620 | type: "response_item", | ||
| 2621 | timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, | ||
| 2622 | payload: { | ||
| 2623 | type: "message", | ||
| 2624 | role: "assistant", | ||
| 2625 | content: [{ type: "output_text", text: `Assistant message ${index}` }], | ||
| 2626 | }, | ||
| 2627 | }), | ||
| 2628 | ); | ||
| 2629 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2630 | contents: [ | ||
| 2631 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2632 | encodeTranscriptRecord({ | ||
| 2633 | type: "response_item", | ||
| 2634 | timestamp: "2026-08-24T10:00:00.000Z", | ||
| 2635 | payload: { | ||
| 2636 | type: "message", | ||
| 2637 | role: "user", | ||
| 2638 | content: [{ type: "input_text", text: "Keep the canonical prompt" }], | ||
| 2639 | }, | ||
| 2640 | }), | ||
| 2641 | encodeTranscriptRecord({ | ||
| 2642 | type: "event_msg", | ||
| 2643 | timestamp: canonicalTimestamp, | ||
| 2644 | payload: { type: "user_message", message: canonicalPrompt }, | ||
| 2645 | }), | ||
| 2646 | ...laterAssistantMessages, | ||
| 2647 | ].join("\n"), | ||
| 2648 | source: "codex", | ||
| 2649 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2650 | fallbackSessionId: "fallback", | ||
| 2651 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2652 | }); | ||
| 2653 | |||
| 2654 | expect(thread?.messages).toHaveLength(200); | ||
| 2655 | expect(thread?.messages[0]).toMatchObject({ | ||
| 2656 | role: "user", | ||
| 2657 | text: canonicalPrompt, | ||
| 2658 | createdAt: canonicalTimestamp, | ||
| 2659 | }); | ||
| 2660 | }); | ||
| 2661 | |||
| 2662 | it("restores the canonical first prompt when a later user message remains", () => { | ||
| 2663 | const canonicalPrompt = "\n Keep the canonical prompt \n"; | ||
| 2664 | const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; | ||
| 2665 | const assistantMessages = Array.from({ length: 198 }, (_, index) => | ||
| 2666 | encodeTranscriptRecord({ | ||
| 2667 | type: "response_item", | ||
| 2668 | timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, | ||
| 2669 | payload: { | ||
| 2670 | type: "message", | ||
| 2671 | role: "assistant", | ||
| 2672 | content: [{ type: "output_text", text: `Assistant message ${index}` }], | ||
| 2673 | }, | ||
| 2674 | }), | ||
| 2675 | ); | ||
| 2676 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2677 | contents: [ | ||
| 2678 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2679 | encodeTranscriptRecord({ | ||
| 2680 | type: "response_item", | ||
| 2681 | timestamp: "2026-08-24T10:00:00.000Z", | ||
| 2682 | payload: { | ||
| 2683 | type: "message", | ||
| 2684 | role: "user", | ||
| 2685 | internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, | ||
| 2686 | content: [{ type: "input_text", text: "Keep the canonical prompt" }], | ||
| 2687 | }, | ||
| 2688 | }), | ||
| 2689 | encodeTranscriptRecord({ | ||
| 2690 | type: "event_msg", | ||
| 2691 | timestamp: canonicalTimestamp, | ||
| 2692 | payload: { type: "user_message", message: canonicalPrompt }, | ||
| 2693 | }), | ||
| 2694 | ...assistantMessages, | ||
| 2695 | encodeTranscriptRecord({ | ||
| 2696 | type: "event_msg", | ||
| 2697 | timestamp: "2026-08-24T11:58:30.000Z", | ||
| 2698 | payload: { type: "user_message", message: "Keep this later prompt" }, | ||
| 2699 | }), | ||
| 2700 | encodeTranscriptRecord({ | ||
| 2701 | type: "response_item", | ||
| 2702 | timestamp: "2026-08-24T11:59:00.000Z", | ||
| 2703 | payload: { | ||
| 2704 | type: "message", | ||
| 2705 | role: "assistant", | ||
| 2706 | content: [{ type: "output_text", text: "Keep this latest response" }], | ||
| 2707 | }, | ||
| 2708 | }), | ||
| 2709 | ].join("\n"), | ||
| 2710 | source: "codex", | ||
| 2711 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2712 | fallbackSessionId: "fallback", | ||
| 2713 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2714 | }); | ||
| 2715 | |||
| 2716 | expect(thread?.messages).toHaveLength(200); | ||
| 2717 | expect(thread?.messages[0]).toMatchObject({ | ||
| 2718 | role: "user", | ||
| 2719 | text: canonicalPrompt, | ||
| 2720 | createdAt: canonicalTimestamp, | ||
| 2721 | }); | ||
| 2722 | expect( | ||
| 2723 | thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), | ||
| 2724 | ).toHaveLength(1); | ||
| 2725 | expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( | ||
| 2726 | true, | ||
| 2727 | ); | ||
| 2728 | expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); | ||
| 2729 | }); | ||
| 2730 | |||
| 2731 | it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { | ||
| 2732 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2733 | contents: [ | ||
| 2734 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2735 | encodeTranscriptRecord({ | ||
| 2736 | type: "response_item", | ||
| 2737 | payload: { | ||
| 2738 | type: "message", | ||
| 2739 | role: "user", | ||
| 2740 | internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, | ||
| 2741 | content: [{ type: "input_text", text: "Keep this older prompt" }], | ||
| 2742 | }, | ||
| 2743 | }), | ||
| 2744 | encodeTranscriptRecord({ | ||
| 2745 | type: "event_msg", | ||
| 2746 | payload: { type: "user_message", message: "Keep this newer prompt" }, | ||
| 2747 | }), | ||
| 2748 | encodeTranscriptRecord({ | ||
| 2749 | type: "response_item", | ||
| 2750 | payload: { | ||
| 2751 | type: "message", | ||
| 2752 | role: "user", | ||
| 2753 | internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, | ||
| 2754 | content: [{ type: "input_text", text: "Keep this newer prompt" }], | ||
| 2755 | }, | ||
| 2756 | }), | ||
| 2757 | encodeTranscriptRecord({ | ||
| 2758 | type: "response_item", | ||
| 2759 | payload: { | ||
| 2760 | type: "message", | ||
| 2761 | role: "assistant", | ||
| 2762 | content: [{ type: "output_text", text: "Ask again when needed" }], | ||
| 2763 | }, | ||
| 2764 | }), | ||
| 2765 | encodeTranscriptRecord({ | ||
| 2766 | type: "response_item", | ||
| 2767 | payload: { | ||
| 2768 | type: "message", | ||
| 2769 | role: "user", | ||
| 2770 | internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, | ||
| 2771 | content: [{ type: "input_text", text: "Keep this newer prompt" }], | ||
| 2772 | }, | ||
| 2773 | }), | ||
| 2774 | ].join("\n"), | ||
| 2775 | source: "codex", | ||
| 2776 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2777 | fallbackSessionId: "fallback", | ||
| 2778 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2779 | }); | ||
| 2780 | |||
| 2781 | expect(thread?.messages.map((message) => message.text)).toEqual([ | ||
| 2782 | "Keep this older prompt", | ||
| 2783 | "Keep this newer prompt", | ||
| 2784 | "Ask again when needed", | ||
| 2785 | "Keep this newer prompt", | ||
| 2786 | ]); | ||
| 2787 | }); | ||
| 2788 | |||
| 2789 | it("preserves response user text when Codex turn metadata is ambiguous", () => { | ||
| 2790 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2791 | contents: [ | ||
| 2792 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2793 | encodeTranscriptRecord({ | ||
| 2794 | type: "response_item", | ||
| 2795 | payload: { | ||
| 2796 | type: "message", | ||
| 2797 | role: "user", | ||
| 2798 | internal_chat_message_metadata_passthrough: ["unexpected"], | ||
| 2799 | content: [{ type: "input_text", text: "Keep this legacy prompt" }], | ||
| 2800 | }, | ||
| 2801 | }), | ||
| 2802 | encodeTranscriptRecord({ | ||
| 2803 | type: "response_item", | ||
| 2804 | payload: { | ||
| 2805 | type: "message", | ||
| 2806 | role: "user", | ||
| 2807 | internal_chat_message_metadata_passthrough: { turn_id: " " }, | ||
| 2808 | content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], | ||
| 2809 | }, | ||
| 2810 | }), | ||
| 2811 | ].join("\n"), | ||
| 2812 | source: "codex", | ||
| 2813 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2814 | fallbackSessionId: "fallback", | ||
| 2815 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2816 | }); | ||
| 2817 | |||
| 2818 | expect(thread?.messages.map((message) => message.text)).toEqual([ | ||
| 2819 | "Keep this legacy prompt", | ||
| 2820 | "Keep this prompt with a blank turn ID", | ||
| 2821 | ]); | ||
| 2822 | }); | ||
| 2823 | |||
| 2824 | it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { | ||
| 2825 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2826 | contents: [ | ||
| 2827 | encodeTranscriptRecord({ | ||
| 2828 | type: "session_meta", | ||
| 2829 | payload: { id: "fork-session", forked_from_id: "parent-session" }, | ||
| 2830 | }), | ||
| 2831 | encodeTranscriptRecord({ | ||
| 2832 | type: "session_meta", | ||
| 2833 | payload: { id: "parent-session" }, | ||
| 2834 | }), | ||
| 2835 | encodeTranscriptRecord({ | ||
| 2836 | type: "event_msg", | ||
| 2837 | payload: { type: "user_message", message: "Continue in the fork" }, | ||
| 2838 | }), | ||
| 2839 | ].join("\n"), | ||
| 2840 | source: "codex", | ||
| 2841 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2842 | fallbackSessionId: "fallback", | ||
| 2843 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2844 | }); | ||
| 2845 | |||
| 2846 | expect(thread?.providerSessionId).toBe("fork-session"); | ||
| 2847 | }); | ||
| 2848 | |||
| 2849 | it("skips Codex transcripts without a resumable session ID", () => { | ||
| 2850 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2851 | contents: encodeTranscriptRecord({ | ||
| 2852 | type: "event_msg", | ||
| 2853 | payload: { type: "user_message", message: "This transcript has no session metadata" }, | ||
| 2854 | }), | ||
| 2855 | source: "codex", | ||
| 2856 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2857 | fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", | ||
| 2858 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 2859 | }); | ||
| 2860 | |||
| 2861 | expect(thread).toBeNull(); | ||
| 2862 | }); | ||
| 2863 | |||
| 2864 | it("uses the canonical Codex event when its turn has generated response context", () => { | ||
| 2865 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2866 | contents: [ | ||
| 2867 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2868 | encodeTranscriptRecord({ | ||
| 2869 | type: "response_item", | ||
| 2870 | payload: { | ||
| 2871 | type: "message", | ||
| 2872 | role: "user", | ||
| 2873 | internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, | ||
| 2874 | content: [ | ||
| 2875 | { | ||
| 2876 | type: "input_text", | ||
| 2877 | text: "<environment_context>\n<cwd>/tmp/project</cwd>\n<shell>zsh</shell>\n</environment_context>", | ||
| 2878 | }, | ||
| 2879 | ], | ||
| 2880 | }, | ||
| 2881 | }), | ||
| 2882 | encodeTranscriptRecord({ | ||
| 2883 | type: "response_item", | ||
| 2884 | payload: { | ||
| 2885 | type: "message", | ||
| 2886 | role: "user", | ||
| 2887 | internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, | ||
| 2888 | content: [ | ||
| 2889 | { | ||
| 2890 | type: "input_text", | ||
| 2891 | text: "# AGENTS.md instructions for /tmp/project\n\n<INSTRUCTIONS>\nPrivate project rules\n</INSTRUCTIONS>", | ||
| 2892 | }, | ||
| 2893 | ], | ||
| 2894 | }, | ||
| 2895 | }), | ||
| 2896 | encodeTranscriptRecord({ | ||
| 2897 | type: "event_msg", | ||
| 2898 | payload: { | ||
| 2899 | type: "user_message", | ||
| 2900 | message: "Do something here so it looks like a real project.", | ||
| 2901 | }, | ||
| 2902 | }), | ||
| 2903 | encodeTranscriptRecord({ | ||
| 2904 | type: "response_item", | ||
| 2905 | payload: { | ||
| 2906 | type: "message", | ||
| 2907 | role: "user", | ||
| 2908 | internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, | ||
| 2909 | content: [ | ||
| 2910 | { | ||
| 2911 | type: "input_text", | ||
| 2912 | text: "Do something here so it looks like a real project.", | ||
| 2913 | }, | ||
| 2914 | ], | ||
| 2915 | }, | ||
| 2916 | }), | ||
| 2917 | encodeTranscriptRecord({ | ||
| 2918 | type: "response_item", | ||
| 2919 | payload: { | ||
| 2920 | type: "message", | ||
| 2921 | role: "assistant", | ||
| 2922 | content: [{ type: "output_text", text: "Created the project." }], | ||
| 2923 | }, | ||
| 2924 | }), | ||
| 2925 | ].join("\n"), | ||
| 2926 | source: "codex", | ||
| 2927 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2928 | fallbackSessionId: "fallback", | ||
| 2929 | lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), | ||
| 2930 | }); | ||
| 2931 | |||
| 2932 | expect(thread?.title).toBe("Do something here so it looks like a real project."); | ||
| 2933 | expect(thread?.messages.map((message) => message.text)).toEqual([ | ||
| 2934 | "Do something here so it looks like a real project.", | ||
| 2935 | "Created the project.", | ||
| 2936 | ]); | ||
| 2937 | }); | ||
| 2938 | |||
| 2939 | it("preserves context markup in response-only Codex messages", () => { | ||
| 2940 | const context = "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"; | ||
| 2941 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2942 | contents: [ | ||
| 2943 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2944 | encodeTranscriptRecord({ | ||
| 2945 | type: "response_item", | ||
| 2946 | payload: { | ||
| 2947 | type: "message", | ||
| 2948 | role: "user", | ||
| 2949 | content: [ | ||
| 2950 | { | ||
| 2951 | type: "input_text", | ||
| 2952 | text: context, | ||
| 2953 | }, | ||
| 2954 | ], | ||
| 2955 | }, | ||
| 2956 | }), | ||
| 2957 | encodeTranscriptRecord({ | ||
| 2958 | type: "response_item", | ||
| 2959 | payload: { | ||
| 2960 | type: "message", | ||
| 2961 | role: "user", | ||
| 2962 | content: [{ type: "input_text", text: "Initialize Git and add a README." }], | ||
| 2963 | }, | ||
| 2964 | }), | ||
| 2965 | ].join("\n"), | ||
| 2966 | source: "codex", | ||
| 2967 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2968 | fallbackSessionId: "fallback", | ||
| 2969 | lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), | ||
| 2970 | }); | ||
| 2971 | |||
| 2972 | expect(thread?.title).toBe("<environment_context>"); | ||
| 2973 | expect(thread?.messages.map((message) => message.text)).toEqual([ | ||
| 2974 | context, | ||
| 2975 | "Initialize Git and add a README.", | ||
| 2976 | ]); | ||
| 2977 | }); | ||
| 2978 | |||
| 2979 | it("preserves a canonical Codex event that starts with context markup", () => { | ||
| 2980 | const prompt = | ||
| 2981 | "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>\n\nCreate a useful project."; | ||
| 2982 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 2983 | contents: [ | ||
| 2984 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 2985 | encodeTranscriptRecord({ | ||
| 2986 | type: "event_msg", | ||
| 2987 | payload: { | ||
| 2988 | type: "user_message", | ||
| 2989 | message: prompt, | ||
| 2990 | }, | ||
| 2991 | }), | ||
| 2992 | ].join("\n"), | ||
| 2993 | source: "codex", | ||
| 2994 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 2995 | fallbackSessionId: "fallback", | ||
| 2996 | lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), | ||
| 2997 | }); | ||
| 2998 | |||
| 2999 | expect(thread?.title).toBe("<environment_context>"); | ||
| 3000 | expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); | ||
| 3001 | }); | ||
| 3002 | |||
| 3003 | it("preserves a Codex request heading in a canonical event", () => { | ||
| 3004 | const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; | ||
| 3005 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 3006 | contents: [ | ||
| 3007 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 3008 | encodeTranscriptRecord({ | ||
| 3009 | type: "event_msg", | ||
| 3010 | payload: { | ||
| 3011 | type: "user_message", | ||
| 3012 | message: prompt, | ||
| 3013 | }, | ||
| 3014 | }), | ||
| 3015 | ].join("\n"), | ||
| 3016 | source: "codex", | ||
| 3017 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 3018 | fallbackSessionId: "fallback", | ||
| 3019 | lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), | ||
| 3020 | }); | ||
| 3021 | |||
| 3022 | expect(thread?.title).toBe("## My request for Codex:"); | ||
| 3023 | expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); | ||
| 3024 | }); | ||
| 3025 | |||
| 3026 | it("keeps context markup quoted inside visible Codex user text", () => { | ||
| 3027 | const quoted = | ||
| 3028 | "Do not remove this example:\n<environment_context>\n<cwd>/tmp/example</cwd>\n</environment_context>"; | ||
| 3029 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 3030 | contents: [ | ||
| 3031 | encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), | ||
| 3032 | encodeTranscriptRecord({ | ||
| 3033 | type: "event_msg", | ||
| 3034 | payload: { type: "user_message", message: quoted }, | ||
| 3035 | }), | ||
| 3036 | ].join("\n"), | ||
| 3037 | source: "codex", | ||
| 3038 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 3039 | fallbackSessionId: "fallback", | ||
| 3040 | lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), | ||
| 3041 | }); | ||
| 3042 | |||
| 3043 | expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); | ||
| 3044 | }); | ||
| 3045 | |||
| 3046 | it("skips sessions without a visible user message", () => { | ||
| 3047 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 3048 | contents: JSON.stringify({ | ||
| 3049 | type: "assistant", | ||
| 3050 | message: { role: "assistant", content: "Done" }, | ||
| 3051 | }), | ||
| 3052 | source: "claudeAgent", | ||
| 3053 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 3054 | fallbackSessionId: "claude-session", | ||
| 3055 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 3056 | }); | ||
| 3057 | |||
| 3058 | expect(thread).toBeNull(); | ||
| 3059 | }); | ||
| 3060 | |||
| 3061 | it("keeps the first prompt when later assistant output exceeds the message limit", () => { | ||
| 3062 | const transcript = [ | ||
| 3063 | encodeTranscriptRecord({ | ||
| 3064 | type: "user", | ||
| 3065 | sessionId: "claude-session", | ||
| 3066 | message: { role: "user", content: "Keep this prompt" }, | ||
| 3067 | }), | ||
| 3068 | ...Array.from({ length: 250 }, (_, index) => | ||
| 3069 | encodeTranscriptRecord({ | ||
| 3070 | type: "assistant", | ||
| 3071 | message: { role: "assistant", content: `Assistant update ${index}` }, | ||
| 3072 | }), | ||
| 3073 | ), | ||
| 3074 | ].join("\n"); | ||
| 3075 | |||
| 3076 | const thread = AgentSessionScanner.parseAgentSessionTranscript({ | ||
| 3077 | contents: transcript, | ||
| 3078 | source: "claudeAgent", | ||
| 3079 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 3080 | fallbackSessionId: "fallback", | ||
| 3081 | lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), | ||
| 3082 | }); | ||
| 3083 | |||
| 3084 | expect(thread?.messages).toHaveLength(200); | ||
| 3085 | expect(thread?.messages[0]?.text).toBe("Keep this prompt"); | ||
| 3086 | expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); | ||
| 3087 | }); | ||
| 3088 | }); | ||
apps/server/src/project/AgentSessionScanner.ts created+1313| ... | @@ -0,0 +1,1313 @@ | ||
| 1 | /** | ||
| 2 | * AgentSessionScanner - discovery of projects a user already works on. | ||
| 3 | * | ||
| 4 | * Claude Code and Codex both keep a per-session transcript on disk, and each | ||
| 5 | * transcript records the directory the session ran in. Reading those `cwd` | ||
| 6 | * values gives us the set of directories worth offering as projects during | ||
| 7 | * onboarding, without asking the user to browse the filesystem. | ||
| 8 | * | ||
| 9 | * The scan is read-only and best-effort: an unreadable home, a malformed | ||
| 10 | * transcript, or a directory that has since been deleted is skipped rather | ||
| 11 | * than failing the scan. Project creation stays with the client, which | ||
| 12 | * dispatches `project.create` for whichever candidates the user picks. | ||
| 13 | * | ||
| 14 | * @module project/AgentSessionScanner | ||
| 15 | */ | ||
| 16 | import * as NodeOS from "node:os"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | AgentSessionScanError, | ||
| 20 | ClaudeSettings, | ||
| 21 | CodexSettings, | ||
| 22 | ProviderDriverKind, | ||
| 23 | ProviderInstanceId, | ||
| 24 | resolveProviderInstanceEnabled, | ||
| 25 | type AgentSessionImportSource, | ||
| 26 | type AgentSessionProjectCandidate, | ||
| 27 | type AgentSessionScanResult, | ||
| 28 | type ProviderInstanceConfig, | ||
| 29 | } from "@t3tools/contracts"; | ||
| 30 | import * as Context from "effect/Context"; | ||
| 31 | import * as DateTime from "effect/DateTime"; | ||
| 32 | import * as Effect from "effect/Effect"; | ||
| 33 | import * as FileSystem from "effect/FileSystem"; | ||
| 34 | import * as Layer from "effect/Layer"; | ||
| 35 | import * as Option from "effect/Option"; | ||
| 36 | import * as Path from "effect/Path"; | ||
| 37 | import * as Schema from "effect/Schema"; | ||
| 38 | import * as Stream from "effect/Stream"; | ||
| 39 | |||
| 40 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 41 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; | ||
| 42 | |||
| 43 | import * as ServerConfig from "../config.ts"; | ||
| 44 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 45 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | ||
| 46 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 47 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 48 | |||
| 49 | /** Chunk size for full transcript reads. */ | ||
| 50 | const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; | ||
| 51 | /** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ | ||
| 52 | const METADATA_READ_BYTES = 8 * 1024; | ||
| 53 | /** Prevent malformed transcripts from turning project discovery into a full file scan. */ | ||
| 54 | const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; | ||
| 55 | |||
| 56 | /** | ||
| 57 | * Upper bound on transcripts inspected (first line read) per source. | ||
| 58 | * Newest-first ordering means the cap drops only stale sessions when a home | ||
| 59 | * directory is unusually large. | ||
| 60 | */ | ||
| 61 | const MAX_TRANSCRIPTS_PER_SOURCE = 5000; | ||
| 62 | |||
| 63 | /** | ||
| 64 | * Upper bound on discovery filesystem operations per source. Newest-first | ||
| 65 | * ordering needs mtimes before the read cap can be applied, so directory reads | ||
| 66 | * and candidate stats share a larger budget. Once it runs out the scan stops. | ||
| 67 | */ | ||
| 68 | const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; | ||
| 69 | const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; | ||
| 70 | const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; | ||
| 71 | const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; | ||
| 72 | const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; | ||
| 73 | const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; | ||
| 74 | const MAX_IMPORTED_TRANSCRIPT_BYTES = 16 * 1024 * 1024; | ||
| 75 | const MAX_IMPORTED_MESSAGES = 200; | ||
| 76 | const MAX_IMPORT_BYTES = 64 * 1024 * 1024; | ||
| 77 | const MAX_IMPORT_TRANSCRIPTS = 100; | ||
| 78 | const MAX_IMPORT_RECORDS = 100_000; | ||
| 79 | |||
| 80 | const TranscriptContentBlock = Schema.Struct({ | ||
| 81 | type: Schema.optional(Schema.String), | ||
| 82 | text: Schema.optional(Schema.String), | ||
| 83 | }); | ||
| 84 | |||
| 85 | const TranscriptMessage = Schema.Struct({ | ||
| 86 | role: Schema.optional(Schema.String), | ||
| 87 | content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), | ||
| 88 | model: Schema.optional(Schema.String), | ||
| 89 | }); | ||
| 90 | |||
| 91 | const CodexTurnMetadata = Schema.Struct({ | ||
| 92 | turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), | ||
| 93 | }); | ||
| 94 | |||
| 95 | const TranscriptRecord = Schema.Struct({ | ||
| 96 | type: Schema.optional(Schema.String), | ||
| 97 | timestamp: Schema.optional(Schema.String), | ||
| 98 | sessionId: Schema.optional(Schema.String), | ||
| 99 | aiTitle: Schema.optional(Schema.String), | ||
| 100 | isSidechain: Schema.optional(Schema.Boolean), | ||
| 101 | isMeta: Schema.optional(Schema.Boolean), | ||
| 102 | isCompactSummary: Schema.optional(Schema.Boolean), | ||
| 103 | message: Schema.optional(TranscriptMessage), | ||
| 104 | payload: Schema.optional( | ||
| 105 | Schema.Struct({ | ||
| 106 | id: Schema.optional(Schema.String), | ||
| 107 | session_id: Schema.optional(Schema.String), | ||
| 108 | type: Schema.optional(Schema.String), | ||
| 109 | role: Schema.optional(Schema.String), | ||
| 110 | message: Schema.optional(Schema.String), | ||
| 111 | model: Schema.optional(Schema.String), | ||
| 112 | content: Schema.optional(Schema.Array(TranscriptContentBlock)), | ||
| 113 | internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), | ||
| 114 | }), | ||
| 115 | ), | ||
| 116 | }); | ||
| 117 | |||
| 118 | const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); | ||
| 119 | const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); | ||
| 120 | const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); | ||
| 121 | const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); | ||
| 122 | |||
| 123 | export interface AgentSessionThreadMessage { | ||
| 124 | readonly role: "user" | "assistant"; | ||
| 125 | readonly text: string; | ||
| 126 | readonly createdAt: string; | ||
| 127 | } | ||
| 128 | |||
| 129 | export interface AgentSessionThread { | ||
| 130 | readonly source: AgentSessionSource; | ||
| 131 | readonly providerInstanceId: ProviderInstanceId; | ||
| 132 | readonly providerSessionId: string; | ||
| 133 | readonly title: string; | ||
| 134 | readonly model: string | null; | ||
| 135 | readonly createdAt: string; | ||
| 136 | readonly updatedAt: string; | ||
| 137 | readonly messages: ReadonlyArray<AgentSessionThreadMessage>; | ||
| 138 | } | ||
| 139 | |||
| 140 | export type AgentSessionRecentThread = | ||
| 141 | | { | ||
| 142 | readonly _tag: "Importable"; | ||
| 143 | readonly thread: AgentSessionThread; | ||
| 144 | readonly source: AgentSessionImportSource; | ||
| 145 | } | ||
| 146 | | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } | ||
| 147 | | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } | ||
| 148 | | { readonly _tag: "Skipped" }; | ||
| 149 | |||
| 150 | /** Service tag for agent session discovery. */ | ||
| 151 | export class AgentSessionScanner extends Context.Service< | ||
| 152 | AgentSessionScanner, | ||
| 153 | { | ||
| 154 | /** | ||
| 155 | * Discover every directory the configured Claude and Codex homes have run | ||
| 156 | * a session in. Candidates are returned newest-first; the client decides | ||
| 157 | * which ones to import and how far back to look. Fails with the contract | ||
| 158 | * error directly — there is no server-local context worth wrapping. | ||
| 159 | */ | ||
| 160 | readonly scan: Effect.Effect<AgentSessionScanResult, AgentSessionScanError>; | ||
| 161 | readonly recentThreads: ( | ||
| 162 | workspaceRoot: string, | ||
| 163 | completedSources?: ReadonlyArray<AgentSessionImportSource>, | ||
| 164 | ) => Stream.Stream<AgentSessionRecentThread, AgentSessionScanError>; | ||
| 165 | } | ||
| 166 | >()("t3/project/AgentSessionScanner") {} | ||
| 167 | |||
| 168 | type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; | ||
| 169 | |||
| 170 | /** A single directory's worth of evidence from one source. */ | ||
| 171 | interface RawCandidate { | ||
| 172 | readonly cwd: string; | ||
| 173 | readonly source: AgentSessionSource; | ||
| 174 | readonly providerInstanceId: ProviderInstanceId; | ||
| 175 | readonly threadCount: number; | ||
| 176 | readonly lastActiveAtMs: number | null; | ||
| 177 | readonly transcripts: ReadonlyArray<{ | ||
| 178 | readonly filePath: string; | ||
| 179 | readonly mtimeMs: number | null; | ||
| 180 | }>; | ||
| 181 | } | ||
| 182 | |||
| 183 | interface TranscriptCandidate { | ||
| 184 | readonly filePath: string; | ||
| 185 | readonly mtimeMs: number; | ||
| 186 | readonly providerInstanceId: ProviderInstanceId; | ||
| 187 | readonly size: number; | ||
| 188 | } | ||
| 189 | |||
| 190 | interface MetadataReadBudget { | ||
| 191 | bytesRemaining: number; | ||
| 192 | operationsRemaining: number; | ||
| 193 | recordsRemaining: number; | ||
| 194 | truncated: boolean; | ||
| 195 | } | ||
| 196 | |||
| 197 | function selectMetadataTranscripts(transcripts: ReadonlyArray<TranscriptCandidate>) { | ||
| 198 | const selected: Array<TranscriptCandidate> = []; | ||
| 199 | let pending = Array.from( | ||
| 200 | Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), | ||
| 201 | (entries) => entries.values(), | ||
| 202 | ); | ||
| 203 | while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { | ||
| 204 | const nextRound: typeof pending = []; | ||
| 205 | for (const iterator of pending) { | ||
| 206 | if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; | ||
| 207 | const next = iterator.next(); | ||
| 208 | if (next.done) continue; | ||
| 209 | selected.push(next.value); | ||
| 210 | nextRound.push(iterator); | ||
| 211 | } | ||
| 212 | pending = nextRound; | ||
| 213 | } | ||
| 214 | return selected; | ||
| 215 | } | ||
| 216 | |||
| 217 | function splitTranscriptRecords(contents: string, limit: number): string[] { | ||
| 218 | const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; | ||
| 219 | return records.split("\n", limit); | ||
| 220 | } | ||
| 221 | |||
| 222 | function extractText( | ||
| 223 | content: string | ReadonlyArray<typeof TranscriptContentBlock.Type> | undefined, | ||
| 224 | ): string { | ||
| 225 | if (typeof content === "string") return content.trim(); | ||
| 226 | if (content === undefined) return ""; | ||
| 227 | return content | ||
| 228 | .filter( | ||
| 229 | (block) => | ||
| 230 | block.type === "text" || block.type === "input_text" || block.type === "output_text", | ||
| 231 | ) | ||
| 232 | .map((block) => block.text?.trim() ?? "") | ||
| 233 | .filter((text) => text.length > 0) | ||
| 234 | .join("\n"); | ||
| 235 | } | ||
| 236 | |||
| 237 | function normalizeTimestamp(value: string | undefined, fallback: string): string { | ||
| 238 | if (value === undefined) return fallback; | ||
| 239 | const parsed = DateTime.make(value); | ||
| 240 | return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; | ||
| 241 | } | ||
| 242 | |||
| 243 | function codexTurnId(metadata: unknown): string | null { | ||
| 244 | const decoded = decodeCodexTurnMetadata(metadata); | ||
| 245 | if ( | ||
| 246 | Option.isNone(decoded) || | ||
| 247 | typeof decoded.value.turn_id !== "string" || | ||
| 248 | decoded.value.turn_id.trim().length === 0 | ||
| 249 | ) { | ||
| 250 | return null; | ||
| 251 | } | ||
| 252 | return decoded.value.turn_id; | ||
| 253 | } | ||
| 254 | |||
| 255 | /** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ | ||
| 256 | export function parseAgentSessionTranscript( | ||
| 257 | input: { | ||
| 258 | readonly contents: string; | ||
| 259 | readonly source: AgentSessionSource; | ||
| 260 | readonly providerInstanceId: ProviderInstanceId; | ||
| 261 | readonly fallbackSessionId: string; | ||
| 262 | readonly lastActiveAtMs: number; | ||
| 263 | }, | ||
| 264 | lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), | ||
| 265 | ): AgentSessionThread | null { | ||
| 266 | if (lines.length > MAX_IMPORT_RECORDS) return null; | ||
| 267 | const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); | ||
| 268 | // Claude filenames are session IDs. Codex rollout filenames include extra | ||
| 269 | // timestamp text, so only transcript metadata can provide a resumable ID. | ||
| 270 | let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; | ||
| 271 | let title: string | null = null; | ||
| 272 | let model: string | null = null; | ||
| 273 | let hasCodexSessionId = false; | ||
| 274 | const messages: Array<AgentSessionThreadMessage & { readonly codexResponseUser: boolean }> = []; | ||
| 275 | let firstUserMessage: | ||
| 276 | | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) | ||
| 277 | | undefined; | ||
| 278 | function* decodedRecords() { | ||
| 279 | for (const line of lines) { | ||
| 280 | const decoded = decodeTranscriptRecord(line); | ||
| 281 | if (Option.isSome(decoded)) yield decoded.value; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | // A Codex response item can include generated setup text beside the real | ||
| 286 | // prompt. Suppress response-user records only when the shared turn ID and a | ||
| 287 | // verbatim event copy prove which prompt the user submitted. | ||
| 288 | const canonicalCodexResponseUserIndices = new Set<number>(); | ||
| 289 | let canonicalUserTextsInTurn = new Set<string>(); | ||
| 290 | let responseUsersInTurn: Array<{ | ||
| 291 | readonly index: number; | ||
| 292 | readonly turnId: string; | ||
| 293 | readonly text: string; | ||
| 294 | }> = []; | ||
| 295 | const finishCodexTurn = () => { | ||
| 296 | const canonicalTurnIds = new Set( | ||
| 297 | responseUsersInTurn.flatMap((responseUser) => | ||
| 298 | canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], | ||
| 299 | ), | ||
| 300 | ); | ||
| 301 | for (const responseUser of responseUsersInTurn) { | ||
| 302 | if (canonicalTurnIds.has(responseUser.turnId)) { | ||
| 303 | canonicalCodexResponseUserIndices.add(responseUser.index); | ||
| 304 | } | ||
| 305 | } | ||
| 306 | canonicalUserTextsInTurn = new Set(); | ||
| 307 | responseUsersInTurn = []; | ||
| 308 | }; | ||
| 309 | if (input.source === "codex") { | ||
| 310 | let recordIndex = -1; | ||
| 311 | for (const record of decodedRecords()) { | ||
| 312 | recordIndex += 1; | ||
| 313 | if ( | ||
| 314 | record.type === "response_item" && | ||
| 315 | record.payload?.type === "message" && | ||
| 316 | record.payload.role === "assistant" | ||
| 317 | ) { | ||
| 318 | finishCodexTurn(); | ||
| 319 | continue; | ||
| 320 | } | ||
| 321 | if (record.type === "event_msg" && record.payload?.type === "user_message") { | ||
| 322 | const text = record.payload.message?.trim() ?? ""; | ||
| 323 | if (text.length > 0) canonicalUserTextsInTurn.add(text); | ||
| 324 | continue; | ||
| 325 | } | ||
| 326 | if ( | ||
| 327 | record.type === "response_item" && | ||
| 328 | record.payload?.type === "message" && | ||
| 329 | record.payload.role === "user" | ||
| 330 | ) { | ||
| 331 | const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); | ||
| 332 | const text = extractText(record.payload.content); | ||
| 333 | if (turnId !== null && text.length > 0) { | ||
| 334 | responseUsersInTurn.push({ index: recordIndex, turnId, text }); | ||
| 335 | } | ||
| 336 | } | ||
| 337 | } | ||
| 338 | finishCodexTurn(); | ||
| 339 | } | ||
| 340 | |||
| 341 | const retainMessage = ( | ||
| 342 | message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, | ||
| 343 | ) => { | ||
| 344 | if (firstUserMessage === undefined && message.role === "user") { | ||
| 345 | firstUserMessage = message; | ||
| 346 | } | ||
| 347 | messages.push(message); | ||
| 348 | if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); | ||
| 349 | }; | ||
| 350 | |||
| 351 | const hasMatchingCodexEventInTurn = (text: string) => { | ||
| 352 | const comparisonText = text.trim(); | ||
| 353 | for (let index = messages.length - 1; index >= 0; index--) { | ||
| 354 | const message = messages[index]; | ||
| 355 | if (message?.role === "assistant") return false; | ||
| 356 | if ( | ||
| 357 | message?.role === "user" && | ||
| 358 | !message.codexResponseUser && | ||
| 359 | message.text.trim() === comparisonText | ||
| 360 | ) { | ||
| 361 | return true; | ||
| 362 | } | ||
| 363 | } | ||
| 364 | return false; | ||
| 365 | }; | ||
| 366 | |||
| 367 | let recordIndex = -1; | ||
| 368 | for (const record of decodedRecords()) { | ||
| 369 | recordIndex += 1; | ||
| 370 | if (input.source === "claudeAgent") { | ||
| 371 | if ( | ||
| 372 | record.isSidechain === true || | ||
| 373 | record.isMeta === true || | ||
| 374 | record.isCompactSummary === true | ||
| 375 | ) { | ||
| 376 | continue; | ||
| 377 | } | ||
| 378 | if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); | ||
| 379 | if (record.aiTitle?.trim()) title = record.aiTitle.trim(); | ||
| 380 | const messageModel = record.message?.model?.trim(); | ||
| 381 | // Claude uses this sentinel for local error responses. It is not a | ||
| 382 | // model ID that can be selected when the imported session resumes. | ||
| 383 | if (messageModel && messageModel !== "<synthetic>") model = messageModel; | ||
| 384 | if (record.type !== "user" && record.type !== "assistant") { | ||
| 385 | continue; | ||
| 386 | } | ||
| 387 | |||
| 388 | const text = extractText(record.message?.content); | ||
| 389 | if (text.length === 0) continue; | ||
| 390 | retainMessage({ | ||
| 391 | role: record.type, | ||
| 392 | text, | ||
| 393 | createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), | ||
| 394 | codexResponseUser: false, | ||
| 395 | }); | ||
| 396 | continue; | ||
| 397 | } | ||
| 398 | |||
| 399 | if (record.type === "session_meta") { | ||
| 400 | const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); | ||
| 401 | if (!hasCodexSessionId && sessionId) { | ||
| 402 | providerSessionId = sessionId; | ||
| 403 | hasCodexSessionId = true; | ||
| 404 | } | ||
| 405 | continue; | ||
| 406 | } | ||
| 407 | if (record.type === "turn_context" && record.payload?.model?.trim()) { | ||
| 408 | model = record.payload.model.trim(); | ||
| 409 | continue; | ||
| 410 | } | ||
| 411 | if (record.type === "event_msg" && record.payload?.type === "user_message") { | ||
| 412 | const text = record.payload.message ?? ""; | ||
| 413 | if (text.trim().length === 0) continue; | ||
| 414 | // Codex can write the same prompt as both a response item and an event. | ||
| 415 | // Remove only the matching response copy so mixed-format logs keep every | ||
| 416 | // distinct user message. | ||
| 417 | for (let index = messages.length - 1; index >= 0; index--) { | ||
| 418 | const message = messages[index]; | ||
| 419 | if (message?.role === "assistant") break; | ||
| 420 | if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { | ||
| 421 | if (firstUserMessage === message) firstUserMessage = undefined; | ||
| 422 | messages.splice(index, 1); | ||
| 423 | break; | ||
| 424 | } | ||
| 425 | } | ||
| 426 | retainMessage({ | ||
| 427 | role: "user", | ||
| 428 | text, | ||
| 429 | createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), | ||
| 430 | codexResponseUser: false, | ||
| 431 | }); | ||
| 432 | continue; | ||
| 433 | } | ||
| 434 | if ( | ||
| 435 | record.type !== "response_item" || | ||
| 436 | record.payload?.type !== "message" || | ||
| 437 | (record.payload.role !== "user" && record.payload.role !== "assistant") | ||
| 438 | ) { | ||
| 439 | continue; | ||
| 440 | } | ||
| 441 | |||
| 442 | const extractedText = extractText(record.payload.content); | ||
| 443 | if (extractedText.length === 0) continue; | ||
| 444 | if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { | ||
| 445 | continue; | ||
| 446 | } | ||
| 447 | if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { | ||
| 448 | continue; | ||
| 449 | } | ||
| 450 | retainMessage({ | ||
| 451 | role: record.payload.role, | ||
| 452 | text: extractedText, | ||
| 453 | createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), | ||
| 454 | codexResponseUser: record.payload.role === "user", | ||
| 455 | }); | ||
| 456 | } | ||
| 457 | |||
| 458 | const visibleMessages = messages.map( | ||
| 459 | ({ codexResponseUser: _codexResponseUser, ...message }) => message, | ||
| 460 | ); | ||
| 461 | if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; | ||
| 462 | const firstUserMessageRetained = messages.includes(firstUserMessage); | ||
| 463 | const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; | ||
| 464 | const retainedMessages = firstUserMessageRetained | ||
| 465 | ? visibleMessages | ||
| 466 | : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; | ||
| 467 | const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); | ||
| 468 | |||
| 469 | return { | ||
| 470 | source: input.source, | ||
| 471 | providerInstanceId: input.providerInstanceId, | ||
| 472 | providerSessionId, | ||
| 473 | title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), | ||
| 474 | model, | ||
| 475 | createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, | ||
| 476 | updatedAt: fallbackTimestamp, | ||
| 477 | messages: retainedMessages, | ||
| 478 | }; | ||
| 479 | } | ||
| 480 | |||
| 481 | /** | ||
| 482 | * T3 Code runs its own agent sessions inside disposable worktrees. Their | ||
| 483 | * transcripts look exactly like user sessions, but re-importing the app's own | ||
| 484 | * sandboxes as projects is never right. Matches this server's configured | ||
| 485 | * worktrees directory plus the conventional `.t3/worktrees` layout, which | ||
| 486 | * also catches sandboxes from other T3 homes on the same machine. Separators | ||
| 487 | * are normalized (and, on Windows, case folded) so the prefix match holds | ||
| 488 | * there too. Callers check both the recorded spelling and its realpath so a | ||
| 489 | * symlink into the worktrees directory cannot bypass the filter. | ||
| 490 | */ | ||
| 491 | function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { | ||
| 492 | const normalized = `${value.replaceAll("\\", "/")}/`; | ||
| 493 | return caseFold ? normalized.toLowerCase() : normalized; | ||
| 494 | } | ||
| 495 | |||
| 496 | function isT3ManagedWorktree( | ||
| 497 | candidatePath: string, | ||
| 498 | worktreesDir: string, | ||
| 499 | caseFold: boolean, | ||
| 500 | ): boolean { | ||
| 501 | const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); | ||
| 502 | return ( | ||
| 503 | normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || | ||
| 504 | normalized.includes("/.t3/worktrees/") | ||
| 505 | ); | ||
| 506 | } | ||
| 507 | |||
| 508 | /** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ | ||
| 509 | function extractCwd(line: string): string | null { | ||
| 510 | let parsed: unknown; | ||
| 511 | try { | ||
| 512 | parsed = JSON.parse(line); | ||
| 513 | } catch { | ||
| 514 | return null; | ||
| 515 | } | ||
| 516 | if (typeof parsed !== "object" || parsed === null) return null; | ||
| 517 | |||
| 518 | const record = parsed as Record<string, unknown>; | ||
| 519 | if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { | ||
| 520 | return record.cwd; | ||
| 521 | } | ||
| 522 | // Codex nests session metadata under `payload`. | ||
| 523 | const payload = record.payload; | ||
| 524 | if (typeof payload === "object" && payload !== null) { | ||
| 525 | const nested = (payload as Record<string, unknown>).cwd; | ||
| 526 | if (typeof nested === "string" && nested.trim().length > 0) { | ||
| 527 | return nested; | ||
| 528 | } | ||
| 529 | } | ||
| 530 | return null; | ||
| 531 | } | ||
| 532 | |||
| 533 | function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { | ||
| 534 | return { | ||
| 535 | filePath, | ||
| 536 | size: Number(stats.size), | ||
| 537 | mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), | ||
| 538 | device: stats.dev, | ||
| 539 | inode: Option.getOrNull(stats.ino), | ||
| 540 | birthtimeMs: Option.match(stats.birthtime, { | ||
| 541 | onNone: () => null, | ||
| 542 | onSome: (date) => date.getTime(), | ||
| 543 | }), | ||
| 544 | }; | ||
| 545 | } | ||
| 546 | |||
| 547 | function sameTranscriptIdentity( | ||
| 548 | left: ReturnType<typeof transcriptIdentity>, | ||
| 549 | right: ReturnType<typeof transcriptIdentity>, | ||
| 550 | ): boolean { | ||
| 551 | return ( | ||
| 552 | left.filePath === right.filePath && | ||
| 553 | left.size === right.size && | ||
| 554 | left.mtimeMs === right.mtimeMs && | ||
| 555 | left.device === right.device && | ||
| 556 | left.inode === right.inode && | ||
| 557 | left.birthtimeMs === right.birthtimeMs | ||
| 558 | ); | ||
| 559 | } | ||
| 560 | |||
| 561 | export const make = Effect.gen(function* () { | ||
| 562 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 563 | const path = yield* Path.Path; | ||
| 564 | const serverConfig = yield* ServerConfig.ServerConfig; | ||
| 565 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 566 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 567 | const baseDir = path.resolve(serverConfig.baseDir); | ||
| 568 | const worktreesDir = path.resolve(serverConfig.worktreesDir); | ||
| 569 | // Windows filesystems are case-insensitive, so path prefix checks there | ||
| 570 | // must case fold. | ||
| 571 | const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; | ||
| 572 | const hostEnvironment = yield* HostProcessEnvironment; | ||
| 573 | const excludedProjectRoots = new Set( | ||
| 574 | [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => | ||
| 575 | normalizeProjectPathForComparison(path.resolve(directory)), | ||
| 576 | ), | ||
| 577 | ); | ||
| 578 | |||
| 579 | const isExcludedProjectPath = (candidatePath: string) => | ||
| 580 | excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || | ||
| 581 | normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( | ||
| 582 | normalizeForWorktreeMatch(baseDir, foldWorktreeCase), | ||
| 583 | ) || | ||
| 584 | isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); | ||
| 585 | |||
| 586 | const listDirectory = (directory: string) => | ||
| 587 | fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])); | ||
| 588 | |||
| 589 | const statOption = (target: string) => | ||
| 590 | fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); | ||
| 591 | |||
| 592 | /** Match directory aliases without assuming the host volume is case-insensitive. */ | ||
| 593 | const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( | ||
| 594 | target: string, | ||
| 595 | knownStats?: FileSystem.File.Info, | ||
| 596 | ) { | ||
| 597 | const resolved = path.resolve(target); | ||
| 598 | const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); | ||
| 599 | if ( | ||
| 600 | Option.isSome(stats) && | ||
| 601 | Option.isSome(stats.value.ino) && | ||
| 602 | Number.isSafeInteger(stats.value.ino.value) && | ||
| 603 | stats.value.ino.value > 0 | ||
| 604 | ) { | ||
| 605 | return `inode:${stats.value.dev}:${stats.value.ino.value}`; | ||
| 606 | } | ||
| 607 | const realPath = yield* fileSystem | ||
| 608 | .realPath(resolved) | ||
| 609 | .pipe(Effect.orElseSucceed(() => resolved)); | ||
| 610 | return `path:${normalizeProjectPathForComparison(realPath)}`; | ||
| 611 | }); | ||
| 612 | |||
| 613 | // A large history snapshot can precede session metadata. Read bounded | ||
| 614 | // chunks until a complete record names its cwd or the safety budget ends. | ||
| 615 | const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( | ||
| 616 | transcript: TranscriptCandidate, | ||
| 617 | budget: MetadataReadBudget, | ||
| 618 | ) { | ||
| 619 | if (transcript.size === 0) return null; | ||
| 620 | if ( | ||
| 621 | budget.bytesRemaining === 0 || | ||
| 622 | budget.operationsRemaining < 2 || | ||
| 623 | budget.recordsRemaining === 0 | ||
| 624 | ) { | ||
| 625 | budget.truncated = true; | ||
| 626 | return null; | ||
| 627 | } | ||
| 628 | budget.operationsRemaining -= 1; | ||
| 629 | return yield* Effect.scoped( | ||
| 630 | fileSystem.open(transcript.filePath, { flag: "r" }).pipe( | ||
| 631 | Effect.flatMap((file) => | ||
| 632 | Effect.gen(function* () { | ||
| 633 | const decoder = new TextDecoder(); | ||
| 634 | let remaining = ""; | ||
| 635 | let bytesRead = 0; | ||
| 636 | let recordsRead = 0; | ||
| 637 | const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); | ||
| 638 | const reserveRecord = () => { | ||
| 639 | if ( | ||
| 640 | recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || | ||
| 641 | budget.recordsRemaining === 0 | ||
| 642 | ) { | ||
| 643 | budget.truncated = true; | ||
| 644 | return false; | ||
| 645 | } | ||
| 646 | recordsRead += 1; | ||
| 647 | budget.recordsRemaining -= 1; | ||
| 648 | return true; | ||
| 649 | }; | ||
| 650 | const readLastRecord = () => { | ||
| 651 | const record = remaining + decoder.decode(); | ||
| 652 | return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); | ||
| 653 | }; | ||
| 654 | |||
| 655 | while (bytesRead < maxBytes) { | ||
| 656 | if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { | ||
| 657 | budget.truncated = true; | ||
| 658 | return null; | ||
| 659 | } | ||
| 660 | const readSize = Math.min( | ||
| 661 | METADATA_READ_BYTES, | ||
| 662 | maxBytes - bytesRead, | ||
| 663 | budget.bytesRemaining, | ||
| 664 | ); | ||
| 665 | budget.operationsRemaining -= 1; | ||
| 666 | budget.bytesRemaining -= readSize; | ||
| 667 | const next = yield* file.readAlloc(readSize); | ||
| 668 | if (Option.isNone(next)) { | ||
| 669 | return readLastRecord(); | ||
| 670 | } | ||
| 671 | |||
| 672 | bytesRead += next.value.byteLength; | ||
| 673 | remaining += decoder.decode(next.value, { stream: true }); | ||
| 674 | const lines = remaining.split("\n"); | ||
| 675 | remaining = lines.pop() ?? ""; | ||
| 676 | |||
| 677 | for (const line of lines) { | ||
| 678 | if (!reserveRecord()) return null; | ||
| 679 | const cwd = extractCwd(line.trim()); | ||
| 680 | if (cwd !== null) return cwd; | ||
| 681 | } | ||
| 682 | } | ||
| 683 | |||
| 684 | if (bytesRead < transcript.size) { | ||
| 685 | budget.truncated = true; | ||
| 686 | return null; | ||
| 687 | } | ||
| 688 | return readLastRecord(); | ||
| 689 | }), | ||
| 690 | ), | ||
| 691 | ), | ||
| 692 | ).pipe(Effect.orElseSucceed(() => null)); | ||
| 693 | }); | ||
| 694 | |||
| 695 | /** Check the open file before and after reading, without reading past its reserved byte budget. */ | ||
| 696 | const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( | ||
| 697 | filePath: string, | ||
| 698 | expected: ReturnType<typeof transcriptIdentity>, | ||
| 699 | ) { | ||
| 700 | if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; | ||
| 701 | |||
| 702 | return yield* Effect.scoped( | ||
| 703 | fileSystem.open(filePath, { flag: "r" }).pipe( | ||
| 704 | Effect.flatMap((file) => | ||
| 705 | Effect.gen(function* () { | ||
| 706 | if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { | ||
| 707 | return null; | ||
| 708 | } | ||
| 709 | const decoder = new TextDecoder(); | ||
| 710 | let contents = ""; | ||
| 711 | let bytesRead = 0; | ||
| 712 | |||
| 713 | while (bytesRead < expected.size) { | ||
| 714 | const next = yield* file.readAlloc( | ||
| 715 | Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), | ||
| 716 | ); | ||
| 717 | if (Option.isNone(next)) { | ||
| 718 | return null; | ||
| 719 | } | ||
| 720 | |||
| 721 | bytesRead += next.value.byteLength; | ||
| 722 | contents += decoder.decode(next.value, { stream: true }); | ||
| 723 | } | ||
| 724 | |||
| 725 | return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) | ||
| 726 | ? contents + decoder.decode() | ||
| 727 | : null; | ||
| 728 | }), | ||
| 729 | ), | ||
| 730 | ), | ||
| 731 | ).pipe(Effect.orElseSucceed(() => null)); | ||
| 732 | }); | ||
| 733 | |||
| 734 | /** | ||
| 735 | * Resolve the Claude config directory the CLI would use, matching the | ||
| 736 | * precedence the spawned CLI sees: the instance's `homePath` (exported as | ||
| 737 | * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the | ||
| 738 | * environment, then `~/.claude`. | ||
| 739 | */ | ||
| 740 | const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { | ||
| 741 | const configured = homePath.trim(); | ||
| 742 | if (configured.length > 0) { | ||
| 743 | return path.resolve(expandHomePath(configured)); | ||
| 744 | } | ||
| 745 | const fromEnvironment = environmentHome?.trim() ?? ""; | ||
| 746 | if (fromEnvironment.length > 0) { | ||
| 747 | return path.resolve(expandHomePath(fromEnvironment)); | ||
| 748 | } | ||
| 749 | return path.join(NodeOS.homedir(), ".claude"); | ||
| 750 | }; | ||
| 751 | |||
| 752 | const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( | ||
| 753 | function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { | ||
| 754 | const projectsDir = path.join(homePath, "projects"); | ||
| 755 | let operationsRemaining = operationBudget; | ||
| 756 | let truncated = false; | ||
| 757 | const readDirectory = (directory: string) => { | ||
| 758 | if (operationsRemaining <= 0) { | ||
| 759 | truncated = true; | ||
| 760 | return Effect.succeed<ReadonlyArray<string>>([]); | ||
| 761 | } | ||
| 762 | operationsRemaining -= 1; | ||
| 763 | return listDirectory(directory); | ||
| 764 | }; | ||
| 765 | const projectDirectories = yield* readDirectory(projectsDir); | ||
| 766 | const transcripts: Array<TranscriptCandidate> = []; | ||
| 767 | |||
| 768 | for (const projectDirectory of projectDirectories) { | ||
| 769 | if (operationsRemaining <= 0) { | ||
| 770 | truncated = true; | ||
| 771 | break; | ||
| 772 | } | ||
| 773 | const directory = path.join(projectsDir, projectDirectory); | ||
| 774 | const directoryTranscripts = (yield* readDirectory(directory)) | ||
| 775 | .filter((entry) => entry.endsWith(".jsonl")) | ||
| 776 | .map((entry) => path.join(directory, entry)); | ||
| 777 | |||
| 778 | for (const filePath of directoryTranscripts) { | ||
| 779 | if (operationsRemaining <= 0) { | ||
| 780 | truncated = true; | ||
| 781 | break; | ||
| 782 | } | ||
| 783 | operationsRemaining -= 1; | ||
| 784 | const stats = yield* statOption(filePath); | ||
| 785 | if ( | ||
| 786 | Option.isNone(stats) || | ||
| 787 | stats.value.type !== "File" || | ||
| 788 | Option.isNone(stats.value.mtime) | ||
| 789 | ) { | ||
| 790 | continue; | ||
| 791 | } | ||
| 792 | transcripts.push({ | ||
| 793 | filePath, | ||
| 794 | mtimeMs: stats.value.mtime.value.getTime(), | ||
| 795 | providerInstanceId, | ||
| 796 | size: Number(stats.value.size), | ||
| 797 | }); | ||
| 798 | } | ||
| 799 | } | ||
| 800 | return { transcripts, truncated }; | ||
| 801 | }, | ||
| 802 | ); | ||
| 803 | |||
| 804 | const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( | ||
| 805 | function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { | ||
| 806 | const sessionsDir = path.join(homePath, "sessions"); | ||
| 807 | |||
| 808 | const transcripts: Array<TranscriptCandidate> = []; | ||
| 809 | let operationsRemaining = operationBudget; | ||
| 810 | let truncated = false; | ||
| 811 | const readDirectory = (directory: string) => { | ||
| 812 | if (operationsRemaining <= 0) { | ||
| 813 | truncated = true; | ||
| 814 | return Effect.succeed<ReadonlyArray<string>>([]); | ||
| 815 | } | ||
| 816 | operationsRemaining -= 1; | ||
| 817 | return listDirectory(directory); | ||
| 818 | }; | ||
| 819 | // Date-partitioned directories sort chronologically, so walking them in | ||
| 820 | // reverse spends each home's share of the operation budget on recent sessions. | ||
| 821 | for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { | ||
| 822 | if (operationsRemaining <= 0) { | ||
| 823 | truncated = true; | ||
| 824 | break; | ||
| 825 | } | ||
| 826 | for (const month of (yield* readDirectory(path.join(sessionsDir, year))) | ||
| 827 | .toSorted() | ||
| 828 | .toReversed()) { | ||
| 829 | if (operationsRemaining <= 0) { | ||
| 830 | truncated = true; | ||
| 831 | break; | ||
| 832 | } | ||
| 833 | for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) | ||
| 834 | .toSorted() | ||
| 835 | .toReversed()) { | ||
| 836 | if (operationsRemaining <= 0) { | ||
| 837 | truncated = true; | ||
| 838 | break; | ||
| 839 | } | ||
| 840 | const directory = path.join(sessionsDir, year, month, day); | ||
| 841 | for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { | ||
| 842 | if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; | ||
| 843 | if (operationsRemaining <= 0) { | ||
| 844 | truncated = true; | ||
| 845 | break; | ||
| 846 | } | ||
| 847 | const filePath = path.join(directory, entry); | ||
| 848 | operationsRemaining -= 1; | ||
| 849 | const stats = yield* statOption(filePath); | ||
| 850 | if ( | ||
| 851 | Option.isSome(stats) && | ||
| 852 | stats.value.type === "File" && | ||
| 853 | Option.isSome(stats.value.mtime) | ||
| 854 | ) { | ||
| 855 | transcripts.push({ | ||
| 856 | filePath, | ||
| 857 | mtimeMs: stats.value.mtime.value.getTime(), | ||
| 858 | providerInstanceId, | ||
| 859 | size: Number(stats.value.size), | ||
| 860 | }); | ||
| 861 | } | ||
| 862 | } | ||
| 863 | } | ||
| 864 | } | ||
| 865 | } | ||
| 866 | return { transcripts, truncated }; | ||
| 867 | }, | ||
| 868 | ); | ||
| 869 | |||
| 870 | const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( | ||
| 871 | source: AgentSessionSource, | ||
| 872 | transcripts: ReadonlyArray<TranscriptCandidate>, | ||
| 873 | budget: MetadataReadBudget, | ||
| 874 | ) { | ||
| 875 | const byOwnerAndCwd = new Map< | ||
| 876 | string, | ||
| 877 | { | ||
| 878 | cwd: string; | ||
| 879 | providerInstanceId: ProviderInstanceId; | ||
| 880 | lastActiveAtMs: number; | ||
| 881 | transcripts: Array<{ filePath: string; mtimeMs: number }>; | ||
| 882 | } | ||
| 883 | >(); | ||
| 884 | |||
| 885 | for (const transcript of transcripts) { | ||
| 886 | const cwd = yield* readCwd(transcript, budget); | ||
| 887 | if (cwd === null) continue; | ||
| 888 | const key = `${transcript.providerInstanceId}\0${cwd}`; | ||
| 889 | const existing = byOwnerAndCwd.get(key); | ||
| 890 | if (existing) { | ||
| 891 | existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); | ||
| 892 | existing.transcripts.push(transcript); | ||
| 893 | } else { | ||
| 894 | byOwnerAndCwd.set(key, { | ||
| 895 | cwd, | ||
| 896 | providerInstanceId: transcript.providerInstanceId, | ||
| 897 | lastActiveAtMs: transcript.mtimeMs, | ||
| 898 | transcripts: [transcript], | ||
| 899 | }); | ||
| 900 | } | ||
| 901 | } | ||
| 902 | |||
| 903 | return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({ | ||
| 904 | cwd: group.cwd, | ||
| 905 | source, | ||
| 906 | providerInstanceId: group.providerInstanceId, | ||
| 907 | threadCount: group.transcripts.length, | ||
| 908 | lastActiveAtMs: group.lastActiveAtMs, | ||
| 909 | transcripts: group.transcripts, | ||
| 910 | })); | ||
| 911 | }); | ||
| 912 | |||
| 913 | const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { | ||
| 914 | const settings = yield* serverSettings.getSettings.pipe( | ||
| 915 | Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), | ||
| 916 | ); | ||
| 917 | |||
| 918 | const raw: Array<RawCandidate> = []; | ||
| 919 | let truncated = false; | ||
| 920 | |||
| 921 | for (const source of ["claudeAgent", "codex"] as const) { | ||
| 922 | const instances: Array<{ | ||
| 923 | readonly instanceId: ProviderInstanceId; | ||
| 924 | readonly config: ProviderInstanceConfig; | ||
| 925 | }> = Object.entries(settings.providerInstances) | ||
| 926 | .filter( | ||
| 927 | ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), | ||
| 928 | ) | ||
| 929 | .map(([instanceId, config]) => ({ | ||
| 930 | instanceId: ProviderInstanceId.make(instanceId), | ||
| 931 | config, | ||
| 932 | })); | ||
| 933 | if (!Object.hasOwn(settings.providerInstances, source)) { | ||
| 934 | const legacyInstance = { | ||
| 935 | instanceId: ProviderInstanceId.make(source), | ||
| 936 | config: { | ||
| 937 | driver: ProviderDriverKind.make(source), | ||
| 938 | config: settings.providers[source], | ||
| 939 | }, | ||
| 940 | }; | ||
| 941 | if (resolveProviderInstanceEnabled(legacyInstance.config)) { | ||
| 942 | instances.push(legacyInstance); | ||
| 943 | } | ||
| 944 | } | ||
| 945 | |||
| 946 | // A shared home contains one copy of each session. Prefer the built-in | ||
| 947 | // instance as its owner, then keep configured order for custom accounts. | ||
| 948 | instances.sort((left, right) => { | ||
| 949 | const leftDefault = left.instanceId === source ? 0 : 1; | ||
| 950 | const rightDefault = right.instanceId === source ? 0 : 1; | ||
| 951 | return leftDefault - rightDefault; | ||
| 952 | }); | ||
| 953 | const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; | ||
| 954 | const seenHomes = new Set<string>(); | ||
| 955 | for (const { instanceId, config: instance } of instances) { | ||
| 956 | const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; | ||
| 957 | const environmentHome = | ||
| 958 | instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? | ||
| 959 | hostEnvironment[homeVariable]; | ||
| 960 | |||
| 961 | let homePath: string; | ||
| 962 | if (source === "claudeAgent") { | ||
| 963 | const config = decodeClaudeSettings(instance.config ?? {}); | ||
| 964 | if (Option.isNone(config)) continue; | ||
| 965 | homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); | ||
| 966 | } else { | ||
| 967 | const config = decodeCodexSettings(instance.config ?? {}); | ||
| 968 | if (Option.isNone(config)) continue; | ||
| 969 | const codexSettings = | ||
| 970 | config.value.homePath.trim().length === 0 && | ||
| 971 | config.value.shadowHomePath.trim().length === 0 && | ||
| 972 | environmentHome?.trim() | ||
| 973 | ? { ...config.value, homePath: environmentHome } | ||
| 974 | : config.value; | ||
| 975 | const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( | ||
| 976 | Effect.provideService(Path.Path, path), | ||
| 977 | ); | ||
| 978 | homePath = layout.sharedHomePath; | ||
| 979 | } | ||
| 980 | |||
| 981 | const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; | ||
| 982 | if (seenHomes.has(homeKey)) continue; | ||
| 983 | seenHomes.add(homeKey); | ||
| 984 | homes.push({ homePath, providerInstanceId: instanceId }); | ||
| 985 | } | ||
| 986 | |||
| 987 | const transcriptCandidates: Array<TranscriptCandidate> = []; | ||
| 988 | const baseOperationBudget = Math.floor( | ||
| 989 | MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), | ||
| 990 | ); | ||
| 991 | const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); | ||
| 992 | for (const [index, home] of homes.entries()) { | ||
| 993 | const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); | ||
| 994 | if (operationBudget === 0) { | ||
| 995 | truncated = true; | ||
| 996 | continue; | ||
| 997 | } | ||
| 998 | const discovered = yield* source === "claudeAgent" | ||
| 999 | ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) | ||
| 1000 | : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); | ||
| 1001 | truncated ||= discovered.truncated; | ||
| 1002 | transcriptCandidates.push(...discovered.transcripts); | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | transcriptCandidates.sort( | ||
| 1006 | (left, right) => | ||
| 1007 | right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), | ||
| 1008 | ); | ||
| 1009 | if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { | ||
| 1010 | truncated = true; | ||
| 1011 | } | ||
| 1012 | // Give each account a turn before taking another file from the same home. | ||
| 1013 | const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); | ||
| 1014 | const metadataBudget: MetadataReadBudget = { | ||
| 1015 | bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, | ||
| 1016 | operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, | ||
| 1017 | recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, | ||
| 1018 | truncated: false, | ||
| 1019 | }; | ||
| 1020 | raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); | ||
| 1021 | truncated ||= metadataBudget.truncated; | ||
| 1022 | } | ||
| 1023 | |||
| 1024 | return { candidates: raw, truncated }; | ||
| 1025 | }); | ||
| 1026 | |||
| 1027 | let cachedCandidates: ReadonlyArray<RawCandidate> | null = null; | ||
| 1028 | |||
| 1029 | const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { | ||
| 1030 | const { candidates: raw, truncated } = yield* collectCandidates(); | ||
| 1031 | cachedCandidates = raw; | ||
| 1032 | |||
| 1033 | // Filesystem identity merges symlinks and case aliases without collapsing | ||
| 1034 | // distinct case-sensitive directories. | ||
| 1035 | const merged = new Map< | ||
| 1036 | string, | ||
| 1037 | { | ||
| 1038 | path: string; | ||
| 1039 | sources: Array<AgentSessionSource>; | ||
| 1040 | threadCount: number; | ||
| 1041 | lastActiveAtMs: number | null; | ||
| 1042 | } | ||
| 1043 | >(); | ||
| 1044 | const directoryKeys = new Map<string, string>(); | ||
| 1045 | |||
| 1046 | for (const candidate of raw) { | ||
| 1047 | const expanded = expandHomePath(candidate.cwd.trim()); | ||
| 1048 | if (!path.isAbsolute(expanded)) continue; | ||
| 1049 | const resolved = path.resolve(expanded); | ||
| 1050 | if (isExcludedProjectPath(resolved)) continue; | ||
| 1051 | let key = directoryKeys.get(resolved); | ||
| 1052 | if (key === undefined) { | ||
| 1053 | const stats = yield* statOption(resolved); | ||
| 1054 | // Directories that no longer exist can't be imported. | ||
| 1055 | if (Option.isNone(stats) || stats.value.type !== "Directory") { | ||
| 1056 | directoryKeys.set(resolved, ""); | ||
| 1057 | continue; | ||
| 1058 | } | ||
| 1059 | const realPath = yield* fileSystem | ||
| 1060 | .realPath(resolved) | ||
| 1061 | .pipe(Effect.orElseSucceed(() => resolved)); | ||
| 1062 | // A symlink can point into the worktrees directory even when its own | ||
| 1063 | // spelling doesn't; check again with links resolved. | ||
| 1064 | if (isExcludedProjectPath(realPath)) { | ||
| 1065 | key = ""; | ||
| 1066 | } else { | ||
| 1067 | key = yield* directoryIdentity(resolved, stats.value); | ||
| 1068 | } | ||
| 1069 | directoryKeys.set(resolved, key); | ||
| 1070 | } | ||
| 1071 | if (key === "") continue; | ||
| 1072 | |||
| 1073 | const existing = merged.get(key); | ||
| 1074 | if (!existing) { | ||
| 1075 | merged.set(key, { | ||
| 1076 | path: resolved, | ||
| 1077 | sources: [candidate.source], | ||
| 1078 | threadCount: candidate.threadCount, | ||
| 1079 | lastActiveAtMs: candidate.lastActiveAtMs, | ||
| 1080 | }); | ||
| 1081 | continue; | ||
| 1082 | } | ||
| 1083 | if (!existing.sources.includes(candidate.source)) { | ||
| 1084 | existing.sources.push(candidate.source); | ||
| 1085 | } | ||
| 1086 | existing.threadCount += candidate.threadCount; | ||
| 1087 | existing.lastActiveAtMs = | ||
| 1088 | existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null | ||
| 1089 | ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) | ||
| 1090 | : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | // Resolve persisted roots too. A project and a transcript can name | ||
| 1094 | // different symlinks to the same directory. | ||
| 1095 | const shellSnapshot = yield* projectionSnapshotQuery | ||
| 1096 | .getShellSnapshot() | ||
| 1097 | .pipe( | ||
| 1098 | Effect.mapError( | ||
| 1099 | (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), | ||
| 1100 | ), | ||
| 1101 | ); | ||
| 1102 | const importedProjectsByRoot = new Map<string, (typeof shellSnapshot.projects)[number]>(); | ||
| 1103 | for (const project of shellSnapshot.projects) { | ||
| 1104 | const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); | ||
| 1105 | importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); | ||
| 1106 | importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); | ||
| 1107 | } | ||
| 1108 | |||
| 1109 | const candidates: Array<AgentSessionProjectCandidate> = []; | ||
| 1110 | for (const [key, entry] of merged.entries()) { | ||
| 1111 | // Keep the path key for missing roots and use filesystem identity for | ||
| 1112 | // aliases that resolve to the same directory. | ||
| 1113 | const importedProject = | ||
| 1114 | importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? | ||
| 1115 | importedProjectsByRoot.get(key); | ||
| 1116 | const candidatePath = importedProject?.workspaceRoot ?? entry.path; | ||
| 1117 | candidates.push({ | ||
| 1118 | path: candidatePath, | ||
| 1119 | title: path.basename(candidatePath) || candidatePath, | ||
| 1120 | ...(importedProject === undefined ? {} : { projectId: importedProject.id }), | ||
| 1121 | sources: entry.sources, | ||
| 1122 | threadCount: entry.threadCount, | ||
| 1123 | lastActiveAt: | ||
| 1124 | entry.lastActiveAtMs === null | ||
| 1125 | ? null | ||
| 1126 | : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), | ||
| 1127 | alreadyImported: importedProject !== undefined, | ||
| 1128 | }); | ||
| 1129 | } | ||
| 1130 | |||
| 1131 | // Newest first, undated candidates last. | ||
| 1132 | candidates.sort((left, right) => { | ||
| 1133 | if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); | ||
| 1134 | if (left.lastActiveAt === null) return 1; | ||
| 1135 | if (right.lastActiveAt === null) return -1; | ||
| 1136 | return right.lastActiveAt.localeCompare(left.lastActiveAt); | ||
| 1137 | }); | ||
| 1138 | |||
| 1139 | return { | ||
| 1140 | candidates, | ||
| 1141 | scannedAt: DateTime.formatIso(yield* DateTime.now), | ||
| 1142 | ...(truncated ? { truncated: true } : {}), | ||
| 1143 | }; | ||
| 1144 | }); | ||
| 1145 | |||
| 1146 | const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( | ||
| 1147 | workspaceRoot: string, | ||
| 1148 | completedSources: ReadonlyArray<AgentSessionImportSource>, | ||
| 1149 | ) { | ||
| 1150 | const root = path.resolve(expandHomePath(workspaceRoot)); | ||
| 1151 | const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); | ||
| 1152 | if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; | ||
| 1153 | const rootIdentity = yield* directoryIdentity(root); | ||
| 1154 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 1155 | const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; | ||
| 1156 | |||
| 1157 | const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; | ||
| 1158 | cachedCandidates = candidates; | ||
| 1159 | |||
| 1160 | const eligibleTranscripts: Array<{ | ||
| 1161 | readonly candidate: RawCandidate; | ||
| 1162 | readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; | ||
| 1163 | }> = []; | ||
| 1164 | for (const candidate of candidates) { | ||
| 1165 | const expanded = expandHomePath(candidate.cwd.trim()); | ||
| 1166 | if (!path.isAbsolute(expanded)) continue; | ||
| 1167 | const resolved = path.resolve(expanded); | ||
| 1168 | if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; | ||
| 1169 | |||
| 1170 | for (const transcript of candidate.transcripts) { | ||
| 1171 | if ( | ||
| 1172 | transcript.mtimeMs === null || | ||
| 1173 | transcript.mtimeMs < cutoffMs || | ||
| 1174 | transcript.mtimeMs > nowMs | ||
| 1175 | ) { | ||
| 1176 | continue; | ||
| 1177 | } | ||
| 1178 | eligibleTranscripts.push({ | ||
| 1179 | candidate, | ||
| 1180 | transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, | ||
| 1181 | }); | ||
| 1182 | } | ||
| 1183 | } | ||
| 1184 | |||
| 1185 | eligibleTranscripts.sort((left, right) => { | ||
| 1186 | if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { | ||
| 1187 | return right.transcript.mtimeMs - left.transcript.mtimeMs; | ||
| 1188 | } | ||
| 1189 | return left.transcript.filePath.localeCompare(right.transcript.filePath); | ||
| 1190 | }); | ||
| 1191 | |||
| 1192 | const completedByFile = Map.groupBy( | ||
| 1193 | completedSources, | ||
| 1194 | (source) => `${source.providerInstanceId}\0${source.filePath}`, | ||
| 1195 | ); | ||
| 1196 | const importedSessions = new Set<string>(); | ||
| 1197 | let bytesRemaining = MAX_IMPORT_BYTES; | ||
| 1198 | let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; | ||
| 1199 | let recordsRemaining = MAX_IMPORT_RECORDS; | ||
| 1200 | return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( | ||
| 1201 | Stream.mapEffect(({ candidate, transcript }) => | ||
| 1202 | Effect.gen(function* () { | ||
| 1203 | const completed = completedByFile.get( | ||
| 1204 | `${candidate.providerInstanceId}\0${transcript.filePath}`, | ||
| 1205 | ); | ||
| 1206 | if ( | ||
| 1207 | completed === undefined && | ||
| 1208 | (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) | ||
| 1209 | ) { | ||
| 1210 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1211 | } | ||
| 1212 | const stats = yield* statOption(transcript.filePath); | ||
| 1213 | if (Option.isNone(stats) || stats.value.type !== "File") { | ||
| 1214 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1215 | } | ||
| 1216 | const identity = transcriptIdentity(transcript.filePath, stats.value); | ||
| 1217 | const completedSource = completed?.find( | ||
| 1218 | (source) => | ||
| 1219 | source.provider === candidate.source && sameTranscriptIdentity(source, identity), | ||
| 1220 | ); | ||
| 1221 | if (completedSource !== undefined) { | ||
| 1222 | const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; | ||
| 1223 | if (importedSessions.has(sessionKey)) return Option.none<AgentSessionRecentThread>(); | ||
| 1224 | importedSessions.add(sessionKey); | ||
| 1225 | return Option.some<AgentSessionRecentThread>({ | ||
| 1226 | _tag: "AlreadyImported", | ||
| 1227 | source: completedSource, | ||
| 1228 | }); | ||
| 1229 | } | ||
| 1230 | if ( | ||
| 1231 | transcriptsRemaining === 0 || | ||
| 1232 | recordsRemaining === 0 || | ||
| 1233 | identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || | ||
| 1234 | identity.size > bytesRemaining | ||
| 1235 | ) { | ||
| 1236 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1237 | } | ||
| 1238 | // Reserve the whole file even if its read or parse fails. | ||
| 1239 | transcriptsRemaining -= 1; | ||
| 1240 | bytesRemaining -= identity.size; | ||
| 1241 | const contents = yield* readTranscript(transcript.filePath, identity); | ||
| 1242 | if (contents === null) { | ||
| 1243 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1244 | } | ||
| 1245 | const lines = splitTranscriptRecords(contents, recordsRemaining + 1); | ||
| 1246 | if (lines.length > recordsRemaining) { | ||
| 1247 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1248 | } | ||
| 1249 | recordsRemaining -= lines.length; | ||
| 1250 | |||
| 1251 | // A stable replacement file can belong to a different project than the cached candidate. | ||
| 1252 | let snapshotCwd: string | null = null; | ||
| 1253 | for (const line of lines) { | ||
| 1254 | snapshotCwd = extractCwd(line); | ||
| 1255 | if (snapshotCwd !== null) break; | ||
| 1256 | } | ||
| 1257 | if (snapshotCwd === null) { | ||
| 1258 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1259 | } | ||
| 1260 | const expandedCwd = expandHomePath(snapshotCwd.trim()); | ||
| 1261 | if ( | ||
| 1262 | !path.isAbsolute(expandedCwd) || | ||
| 1263 | (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity | ||
| 1264 | ) { | ||
| 1265 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1266 | } | ||
| 1267 | |||
| 1268 | const parsedThread = parseAgentSessionTranscript( | ||
| 1269 | { | ||
| 1270 | contents, | ||
| 1271 | source: candidate.source, | ||
| 1272 | providerInstanceId: candidate.providerInstanceId, | ||
| 1273 | fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), | ||
| 1274 | lastActiveAtMs: transcript.mtimeMs, | ||
| 1275 | }, | ||
| 1276 | lines, | ||
| 1277 | ); | ||
| 1278 | if (parsedThread === null) { | ||
| 1279 | return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" }); | ||
| 1280 | } | ||
| 1281 | |||
| 1282 | const source: AgentSessionImportSource = { | ||
| 1283 | ...identity, | ||
| 1284 | provider: parsedThread.source, | ||
| 1285 | providerInstanceId: parsedThread.providerInstanceId, | ||
| 1286 | providerSessionId: parsedThread.providerSessionId, | ||
| 1287 | }; | ||
| 1288 | const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; | ||
| 1289 | if (importedSessions.has(sessionKey)) { | ||
| 1290 | return Option.some<AgentSessionRecentThread>({ _tag: "Duplicate", source }); | ||
| 1291 | } | ||
| 1292 | importedSessions.add(sessionKey); | ||
| 1293 | return Option.some<AgentSessionRecentThread>({ | ||
| 1294 | _tag: "Importable", | ||
| 1295 | thread: parsedThread, | ||
| 1296 | source, | ||
| 1297 | }); | ||
| 1298 | }), | ||
| 1299 | ), | ||
| 1300 | Stream.map(Option.toArray), | ||
| 1301 | Stream.flattenIterable, | ||
| 1302 | ); | ||
| 1303 | }); | ||
| 1304 | |||
| 1305 | const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( | ||
| 1306 | workspaceRoot, | ||
| 1307 | completedSources = [], | ||
| 1308 | ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); | ||
| 1309 | |||
| 1310 | return AgentSessionScanner.of({ scan, recentThreads }); | ||
| 1311 | }); | ||
| 1312 | |||
| 1313 | export const layer = Layer.effect(AgentSessionScanner, make); | ||
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1| ... | @@ -41,6 +41,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => | ... | @@ -41,6 +41,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 41 | getProjectShellById: (projectId) => | 41 | getProjectShellById: (projectId) => |
| 42 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), | 42 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), |
| 43 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | 43 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
| 44 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 44 | getThreadCheckpointContext: () => Effect.die("unused"), | 45 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 45 | getFullThreadDiffContext: () => Effect.die("unused"), | 46 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 46 | getThreadRuntimeContext: () => Effect.die("unused"), | 47 | getThreadRuntimeContext: () => Effect.die("unused"), |
apps/server/src/provider/Drivers/ClaudeDriver.ts+6-1| ... | @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 26 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; | 26 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| 27 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 27 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 28 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 29 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 29 | import { ServerSettingsService } from "../../serverSettings.ts"; | 30 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 30 | import { ProviderDriverError } from "../Errors.ts"; | 31 | import { ProviderDriverError } from "../Errors.ts"; |
| 31 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; | 32 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; |
| ... | @@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { | ... | @@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { |
| 119 | driverKind: DRIVER_KIND, | 120 | driverKind: DRIVER_KIND, |
| 120 | instanceId, | 121 | instanceId, |
| 121 | }); | 122 | }); |
| 122 | const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; | 123 | const effectiveConfig = { |
| 124 | ...config, | ||
| 125 | enabled, | ||
| 126 | binaryPath: expandHomePath(config.binaryPath), | ||
| 127 | } satisfies ClaudeSettings; | ||
| 123 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( | 128 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( |
| 124 | resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | 129 | resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { |
| 125 | binaryPath: effectiveConfig.binaryPath, | 130 | binaryPath: effectiveConfig.binaryPath, |
apps/server/src/provider/Drivers/CodexDriver.ts+2| ... | @@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 33 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; | 33 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| 34 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 34 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 35 | import { ServerConfig } from "../../config.ts"; | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 36 | import { ServerSettingsService } from "../../serverSettings.ts"; | 37 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 37 | import { ProviderDriverError } from "../Errors.ts"; | 38 | import { ProviderDriverError } from "../Errors.ts"; |
| 38 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | 39 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; |
| ... | @@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | ... | @@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { |
| 157 | const effectiveConfig = { | 158 | const effectiveConfig = { |
| 158 | ...config, | 159 | ...config, |
| 159 | enabled, | 160 | enabled, |
| 161 | binaryPath: expandHomePath(config.binaryPath), | ||
| 160 | homePath: homeLayout.effectiveHomePath ?? "", | 162 | homePath: homeLayout.effectiveHomePath ?? "", |
| 161 | } satisfies CodexSettings; | 163 | } satisfies CodexSettings; |
| 162 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( | 164 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( |
apps/server/src/provider/Layers/CodexAdapter.test.ts+1| ... | @@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea | ... | @@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea |
| 222 | 222 | ||
| 223 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { | 223 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { |
| 224 | upsert: () => Effect.void, | 224 | upsert: () => Effect.void, |
| 225 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 225 | getProvider: () => | 226 | getProvider: () => |
| 226 | Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), | 227 | Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), |
| 227 | getBinding: () => Effect.succeed(Option.none()), | 228 | getBinding: () => Effect.succeed(Option.none()), |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+1| ... | @@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ... | @@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 516 | 516 | ||
| 517 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { | 517 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { |
| 518 | upsert: () => Effect.void, | 518 | upsert: () => Effect.void, |
| 519 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 519 | getProvider: () => | 520 | getProvider: () => |
| 520 | Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), | 521 | Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), |
| 521 | getBinding: () => Effect.succeed(Option.none()), | 522 | getBinding: () => Effect.succeed(Option.none()), |
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+132-1| ... | @@ -24,7 +24,6 @@ | ... | @@ -24,7 +24,6 @@ |
| 24 | */ | 24 | */ |
| 25 | import { describe, expect, it } from "@effect/vitest"; | 25 | import { describe, expect, it } from "@effect/vitest"; |
| 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 27 | import * as Path from "effect/Path"; | ||
| 28 | import { | 27 | import { |
| 29 | type ClaudeSettings, | 28 | type ClaudeSettings, |
| 30 | type CodexSettings, | 29 | type CodexSettings, |
| ... | @@ -35,9 +34,12 @@ import { | ... | @@ -35,9 +34,12 @@ import { |
| 35 | type ProviderInstanceConfigMap, | 34 | type ProviderInstanceConfigMap, |
| 36 | ProviderInstanceId, | 35 | ProviderInstanceId, |
| 37 | } from "@t3tools/contracts"; | 36 | } from "@t3tools/contracts"; |
| 37 | import { isHostWindows } from "@t3tools/shared/hostProcess"; | ||
| 38 | import * as DateTime from "effect/DateTime"; | 38 | import * as DateTime from "effect/DateTime"; |
| 39 | import * as Effect from "effect/Effect"; | 39 | import * as Effect from "effect/Effect"; |
| 40 | import * as FileSystem from "effect/FileSystem"; | ||
| 40 | import * as Layer from "effect/Layer"; | 41 | import * as Layer from "effect/Layer"; |
| 42 | import * as Path from "effect/Path"; | ||
| 41 | import * as Stream from "effect/Stream"; | 43 | import * as Stream from "effect/Stream"; |
| 42 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 44 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 43 | 45 | ||
| ... | @@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ... | @@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 45 | import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; | 47 | import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; |
| 46 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; | 48 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; |
| 47 | import { ServerConfig } from "../../config.ts"; | 49 | import { ServerConfig } from "../../config.ts"; |
| 50 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 48 | import { ServerSettingsService } from "../../serverSettings.ts"; | 51 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 49 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | 52 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; |
| 50 | import { CodexDriver } from "../Drivers/CodexDriver.ts"; | 53 | import { CodexDriver } from "../Drivers/CodexDriver.ts"; |
| ... | @@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSetti | ... | @@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSetti |
| 139 | ...overrides, | 142 | ...overrides, |
| 140 | }); | 143 | }); |
| 141 | 144 | ||
| 145 | const makeTildeProviderFixtures = Effect.fn( | ||
| 146 | "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", | ||
| 147 | )(function* () { | ||
| 148 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 149 | const path = yield* Path.Path; | ||
| 150 | const homePath = expandHomePath("~"); | ||
| 151 | const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 152 | directory: homePath, | ||
| 153 | prefix: ".t3-provider-path-test-", | ||
| 154 | }); | ||
| 155 | const codexPath = path.join(fixtureDir, "codex"); | ||
| 156 | const claudePath = path.join(fixtureDir, "claude"); | ||
| 157 | const claudeHomePath = path.join(fixtureDir, "claude-home"); | ||
| 158 | const codexScriptPath = path.join(fixtureDir, "codex-script.json"); | ||
| 159 | const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); | ||
| 160 | |||
| 161 | yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); | ||
| 162 | yield* fileSystem.copyFile( | ||
| 163 | path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), | ||
| 164 | path.join(fixtureDir, "codexCollabMockPeer.mjs"), | ||
| 165 | ); | ||
| 166 | yield* fileSystem.copyFile( | ||
| 167 | path.join(codexFixtureDir, "codexMultiAgentWire.json"), | ||
| 168 | path.join(fixtureDir, "codexMultiAgentWire.json"), | ||
| 169 | ); | ||
| 170 | yield* fileSystem.writeFileString( | ||
| 171 | codexScriptPath, | ||
| 172 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. | ||
| 173 | JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), | ||
| 174 | ); | ||
| 175 | yield* fileSystem.chmod(codexPath, 0o755); | ||
| 176 | |||
| 177 | yield* fileSystem.writeFileString( | ||
| 178 | claudePath, | ||
| 179 | [ | ||
| 180 | "#!/usr/bin/env node", | ||
| 181 | 'import * as NodeReadline from "node:readline";', | ||
| 182 | 'if (process.argv.includes("--version")) {', | ||
| 183 | ' process.stdout.write("claude 2.1.219\\n");', | ||
| 184 | " process.exit(0);", | ||
| 185 | "}", | ||
| 186 | "const lines = NodeReadline.createInterface({ input: process.stdin });", | ||
| 187 | 'lines.on("line", (line) => {', | ||
| 188 | " const message = JSON.parse(line);", | ||
| 189 | ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', | ||
| 190 | " process.stdout.write(JSON.stringify({", | ||
| 191 | ' type: "control_response",', | ||
| 192 | " response: {", | ||
| 193 | ' subtype: "success",', | ||
| 194 | " request_id: message.request_id,", | ||
| 195 | " response: {", | ||
| 196 | " commands: [], agents: [], models: [],", | ||
| 197 | ' output_style: "default", available_output_styles: ["default"],', | ||
| 198 | ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', | ||
| 199 | " },", | ||
| 200 | " },", | ||
| 201 | ' }) + "\\n");', | ||
| 202 | "});", | ||
| 203 | "setInterval(() => {}, 1_000);", | ||
| 204 | "", | ||
| 205 | ].join("\n"), | ||
| 206 | ); | ||
| 207 | yield* fileSystem.chmod(claudePath, 0o755); | ||
| 208 | yield* fileSystem.makeDirectory(claudeHomePath); | ||
| 209 | |||
| 210 | const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; | ||
| 211 | return { | ||
| 212 | codexBinaryPath: asTildePath(codexPath), | ||
| 213 | claudeBinaryPath: asTildePath(claudePath), | ||
| 214 | claudeHomePath, | ||
| 215 | codexScriptPath, | ||
| 216 | }; | ||
| 217 | }); | ||
| 218 | |||
| 142 | describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | 219 | describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 143 | // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch | 220 | // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch |
| 144 | // directory. `Layer.merge` just unions requirements, so we have to push | 221 | // directory. `Layer.merge` just unions requirements, so we have to push |
| ... | @@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ... | @@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { |
| 261 | }).pipe(Effect.provide(testLayer)), | 338 | }).pipe(Effect.provide(testLayer)), |
| 262 | ); | 339 | ); |
| 263 | 340 | ||
| 341 | it.live("runs Codex and Claude readiness probes from configured tilde paths", () => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | if (yield* isHostWindows) return; | ||
| 344 | |||
| 345 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 346 | |||
| 347 | const codexId = ProviderInstanceId.make("codex_tilde"); | ||
| 348 | const claudeId = ProviderInstanceId.make("claude_tilde"); | ||
| 349 | const configMap: ProviderInstanceConfigMap = { | ||
| 350 | [codexId]: { | ||
| 351 | driver: ProviderDriverKind.make("codex"), | ||
| 352 | enabled: true, | ||
| 353 | environment: [ | ||
| 354 | { | ||
| 355 | name: "T3_CODEX_COLLAB_SCRIPT", | ||
| 356 | value: fixtures.codexScriptPath, | ||
| 357 | sensitive: false, | ||
| 358 | }, | ||
| 359 | ], | ||
| 360 | config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), | ||
| 361 | }, | ||
| 362 | [claudeId]: { | ||
| 363 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 364 | enabled: true, | ||
| 365 | config: makeClaudeConfig({ | ||
| 366 | enabled: true, | ||
| 367 | binaryPath: fixtures.claudeBinaryPath, | ||
| 368 | homePath: fixtures.claudeHomePath, | ||
| 369 | }), | ||
| 370 | }, | ||
| 371 | }; | ||
| 372 | |||
| 373 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 374 | drivers: [CodexDriver, ClaudeDriver], | ||
| 375 | configMap, | ||
| 376 | }); | ||
| 377 | const codex = yield* registry.getInstance(codexId); | ||
| 378 | const claude = yield* registry.getInstance(claudeId); | ||
| 379 | expect(codex).toBeDefined(); | ||
| 380 | expect(claude).toBeDefined(); | ||
| 381 | |||
| 382 | const [codexSnapshot, claudeSnapshot] = yield* Effect.all( | ||
| 383 | [codex!.snapshot.refresh, claude!.snapshot.refresh], | ||
| 384 | { concurrency: "unbounded" }, | ||
| 385 | ); | ||
| 386 | expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); | ||
| 387 | expect(claudeSnapshot).toMatchObject({ | ||
| 388 | status: "ready", | ||
| 389 | installed: true, | ||
| 390 | version: "2.1.219", | ||
| 391 | }); | ||
| 392 | }).pipe(Effect.provide(testLayer)), | ||
| 393 | ); | ||
| 394 | |||
| 264 | it.live( | 395 | it.live( |
| 265 | "shadows instances whose driver is not registered in this build without failing boot", | 396 | "shadows instances whose driver is not registered in this build without failing boot", |
| 266 | () => | 397 | () => |
apps/server/src/provider/Layers/ProviderService.test.ts+1| ... | @@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) => | ... | @@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) => |
| 4271 | const boundedListing = makeProviderServiceLayer({ | 4271 | const boundedListing = makeProviderServiceLayer({ |
| 4272 | directory: { | 4272 | directory: { |
| 4273 | upsert: () => Effect.void, | 4273 | upsert: () => Effect.void, |
| 4274 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 4274 | getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), | 4275 | getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), |
| 4275 | getBinding, | 4276 | getBinding, |
| 4276 | listThreadIds, | 4277 | listThreadIds, |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+224-42| ... | @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; | ... | @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; | 4 | import * as NodePath from "node:path"; |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | 7 | import { |
| 8 | import { it, assert } from "@effect/vitest"; | 8 | ProviderDriverKind, |
| 9 | import { assertSome } from "@effect/vitest/utils"; | 9 | ProviderInstanceId, |
| 10 | ThreadId, | ||
| 11 | type AgentSessionImportSource, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { assert, expect, it } from "@effect/vitest"; | ||
| 10 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 12 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| ... | @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim | ... | @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim |
| 20 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 24 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 21 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | 25 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 22 | 26 | ||
| 27 | const importedSource = { | ||
| 28 | provider: "codex", | ||
| 29 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 30 | providerSessionId: "provider-session", | ||
| 31 | filePath: "/tmp/provider-session.jsonl", | ||
| 32 | size: 100, | ||
| 33 | mtimeMs: 1_000, | ||
| 34 | device: 1, | ||
| 35 | inode: 123, | ||
| 36 | birthtimeMs: 500, | ||
| 37 | } satisfies AgentSessionImportSource; | ||
| 38 | |||
| 23 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { | 39 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { |
| 24 | const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); | 40 | const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); |
| 25 | return Layer.mergeAll( | 41 | return Layer.mergeAll( |
| 42 | persistenceLayer, | ||
| 26 | runtimeRepositoryLayer, | 43 | runtimeRepositoryLayer, |
| 27 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), | 44 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), |
| 28 | NodeServices.layer, | 45 | NodeServices.layer, |
| ... | @@ -30,7 +47,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli | ... | @@ -30,7 +47,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli |
| 30 | } | 47 | } |
| 31 | 48 | ||
| 32 | it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { | 49 | it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { |
| 33 | it("upserts and reads thread bindings", () => | 50 | it.effect("upserts and reads thread bindings", () => |
| 34 | Effect.gen(function* () { | 51 | Effect.gen(function* () { |
| 35 | const directory = yield* ProviderSessionDirectory; | 52 | const directory = yield* ProviderSessionDirectory; |
| 36 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | 53 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; |
| ... | @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 39 | 56 | ||
| 40 | yield* directory.upsert({ | 57 | yield* directory.upsert({ |
| 41 | provider: ProviderDriverKind.make("codex"), | 58 | provider: ProviderDriverKind.make("codex"), |
| 59 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 42 | threadId: initialThreadId, | 60 | threadId: initialThreadId, |
| 43 | }); | 61 | }); |
| 44 | 62 | ||
| 45 | const provider = yield* directory.getProvider(initialThreadId); | 63 | const provider = yield* directory.getProvider(initialThreadId); |
| 46 | assert.equal(provider, "codex"); | 64 | assert.equal(provider, "codex"); |
| 47 | const resolvedBinding = yield* directory.getBinding(initialThreadId); | 65 | const resolvedBinding = yield* directory.getBinding(initialThreadId); |
| 48 | assertSome(resolvedBinding, { | 66 | expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ |
| 49 | threadId: initialThreadId, | 67 | threadId: initialThreadId, |
| 50 | provider: ProviderDriverKind.make("codex"), | 68 | provider: ProviderDriverKind.make("codex"), |
| 51 | }); | 69 | }); |
| ... | @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 57 | 75 | ||
| 58 | yield* directory.upsert({ | 76 | yield* directory.upsert({ |
| 59 | provider: ProviderDriverKind.make("codex"), | 77 | provider: ProviderDriverKind.make("codex"), |
| 78 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 60 | threadId: nextThreadId, | 79 | threadId: nextThreadId, |
| 61 | }); | 80 | }); |
| 62 | const updatedBinding = yield* directory.getBinding(nextThreadId); | 81 | const updatedBinding = yield* directory.getBinding(nextThreadId); |
| ... | @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 74 | } | 93 | } |
| 75 | 94 | ||
| 76 | const threadIds = yield* directory.listThreadIds(); | 95 | const threadIds = yield* directory.listThreadIds(); |
| 77 | assert.deepEqual(threadIds, [nextThreadId]); | 96 | expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId])); |
| 78 | })); | 97 | }), |
| 98 | ); | ||
| 79 | 99 | ||
| 80 | it("persists runtime fields and merges payload updates", () => | 100 | it.effect("persists runtime fields and merges payload updates", () => |
| 81 | Effect.gen(function* () { | 101 | Effect.gen(function* () { |
| 82 | const directory = yield* ProviderSessionDirectory; | 102 | const directory = yield* ProviderSessionDirectory; |
| 83 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | 103 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; |
| ... | @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 86 | 106 | ||
| 87 | yield* directory.upsert({ | 107 | yield* directory.upsert({ |
| 88 | provider: ProviderDriverKind.make("codex"), | 108 | provider: ProviderDriverKind.make("codex"), |
| 109 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 89 | threadId, | 110 | threadId, |
| 90 | status: "starting", | 111 | status: "starting", |
| 91 | resumeCursor: { | 112 | resumeCursor: { |
| ... | @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 99 | 120 | ||
| 100 | yield* directory.upsert({ | 121 | yield* directory.upsert({ |
| 101 | provider: ProviderDriverKind.make("codex"), | 122 | provider: ProviderDriverKind.make("codex"), |
| 123 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 102 | threadId, | 124 | threadId, |
| 103 | status: "running", | 125 | status: "running", |
| 104 | runtimePayload: { | 126 | runtimePayload: { |
| ... | @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 120 | activeTurnId: "turn-1", | 142 | activeTurnId: "turn-1", |
| 121 | }); | 143 | }); |
| 122 | } | 144 | } |
| 123 | })); | 145 | }), |
| 146 | ); | ||
| 147 | |||
| 148 | it.effect("keeps the existing binding when an insert conflicts", () => | ||
| 149 | Effect.gen(function* () { | ||
| 150 | const directory = yield* ProviderSessionDirectory; | ||
| 151 | const threadId = ThreadId.make("thread-insert-conflict"); | ||
| 152 | |||
| 153 | yield* directory.upsert({ | ||
| 154 | provider: ProviderDriverKind.make("codex"), | ||
| 155 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 156 | threadId, | ||
| 157 | status: "running", | ||
| 158 | resumeCursor: { threadId: "active-provider-thread" }, | ||
| 159 | }); | ||
| 160 | |||
| 161 | yield* directory.upsert( | ||
| 162 | { | ||
| 163 | provider: ProviderDriverKind.make("codex"), | ||
| 164 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 165 | threadId, | ||
| 166 | status: "stopped", | ||
| 167 | resumeCursor: { threadId: "stale-provider-thread" }, | ||
| 168 | }, | ||
| 169 | { onConflict: "ignore" }, | ||
| 170 | ); | ||
| 171 | |||
| 172 | const binding = yield* directory.getBinding(threadId); | ||
| 173 | expect(Option.getOrThrow(binding)).toMatchObject({ | ||
| 174 | threadId, | ||
| 175 | status: "running", | ||
| 176 | resumeCursor: { threadId: "active-provider-thread" }, | ||
| 177 | }); | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | |||
| 181 | it.effect("records source files without replacing the current provider session", () => | ||
| 182 | Effect.gen(function* () { | ||
| 183 | const directory = yield* ProviderSessionDirectory; | ||
| 184 | const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | ||
| 185 | const source = { ...importedSource, providerSessionId: "record-source" }; | ||
| 186 | const threadId = ThreadId.make( | ||
| 187 | `import:${source.providerInstanceId}:${source.providerSessionId}`, | ||
| 188 | ); | ||
| 189 | const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" }; | ||
| 190 | yield* directory.upsert({ | ||
| 191 | threadId, | ||
| 192 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 193 | providerInstanceId: ProviderInstanceId.make("claude-current"), | ||
| 194 | status: "running", | ||
| 195 | resumeCursor: { resume: "current-native-session" }, | ||
| 196 | runtimePayload, | ||
| 197 | }); | ||
| 198 | const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); | ||
| 199 | |||
| 200 | yield* directory.recordImportedTranscript({ threadId, source }); | ||
| 201 | const replacement = { ...source, size: 200, mtimeMs: 2_000 }; | ||
| 202 | yield* directory.recordImportedTranscript({ threadId, source: replacement }); | ||
| 203 | const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" }; | ||
| 204 | yield* directory.recordImportedTranscript({ threadId, source: secondFile }); | ||
| 205 | |||
| 206 | expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ | ||
| 207 | ...before, | ||
| 208 | runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] }, | ||
| 209 | }); | ||
| 210 | }), | ||
| 211 | ); | ||
| 212 | |||
| 213 | it.effect("does not create a binding when recording an imported transcript", () => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const directory = yield* ProviderSessionDirectory; | ||
| 216 | const threadId = ThreadId.make("import:codex:missing-source-binding"); | ||
| 217 | |||
| 218 | yield* directory.recordImportedTranscript({ threadId, source: importedSource }); | ||
| 219 | |||
| 220 | expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true); | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | |||
| 224 | it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const directory = yield* ProviderSessionDirectory; | ||
| 227 | const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | ||
| 228 | const firstSource = { ...importedSource, providerSessionId: "stale-source" }; | ||
| 229 | const threadId = ThreadId.make( | ||
| 230 | `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`, | ||
| 231 | ); | ||
| 232 | yield* directory.upsert({ | ||
| 233 | threadId, | ||
| 234 | provider: ProviderDriverKind.make("codex"), | ||
| 235 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 236 | status: "stopped", | ||
| 237 | resumeCursor: { threadId: "original-native-session" }, | ||
| 238 | runtimePayload: { cwd: "/tmp/stale-source-project" }, | ||
| 239 | }); | ||
| 240 | yield* directory.recordImportedTranscript({ threadId, source: firstSource }); | ||
| 241 | const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); | ||
| 242 | const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" }; | ||
| 243 | yield* directory.recordImportedTranscript({ threadId, source: secondSource }); | ||
| 244 | |||
| 245 | yield* repository.upsert({ | ||
| 246 | ...stale, | ||
| 247 | status: "running", | ||
| 248 | resumeCursor: { threadId: "new-native-session" }, | ||
| 249 | lastSeenAt: "2026-08-24T10:00:00.000Z", | ||
| 250 | }); | ||
| 251 | |||
| 252 | expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ | ||
| 253 | ...stale, | ||
| 254 | status: "running", | ||
| 255 | resumeCursor: { threadId: "new-native-session" }, | ||
| 256 | lastSeenAt: "2026-08-24T10:00:00.000Z", | ||
| 257 | runtimePayload: { | ||
| 258 | cwd: "/tmp/stale-source-project", | ||
| 259 | importedTranscripts: [firstSource, secondSource], | ||
| 260 | }, | ||
| 261 | }); | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | |||
| 265 | it.effect("reserves imported source records for the atomic recording method", () => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const directory = yield* ProviderSessionDirectory; | ||
| 268 | for (const onConflict of ["update", "ignore"] as const) { | ||
| 269 | const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` }; | ||
| 270 | const threadId = ThreadId.make( | ||
| 271 | `import:${source.providerInstanceId}:${source.providerSessionId}`, | ||
| 272 | ); | ||
| 273 | const binding = { | ||
| 274 | threadId, | ||
| 275 | provider: ProviderDriverKind.make("codex"), | ||
| 276 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 277 | }; | ||
| 278 | yield* directory.upsert( | ||
| 279 | { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } }, | ||
| 280 | { onConflict }, | ||
| 281 | ); | ||
| 282 | expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ | ||
| 283 | cwd: "/tmp/project", | ||
| 284 | }); | ||
| 285 | |||
| 286 | yield* directory.recordImportedTranscript({ threadId, source }); | ||
| 287 | yield* directory.upsert({ ...binding, runtimePayload: null }); | ||
| 288 | |||
| 289 | expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ | ||
| 290 | importedTranscripts: [source], | ||
| 291 | }); | ||
| 292 | } | ||
| 293 | }), | ||
| 294 | ); | ||
| 124 | 295 | ||
| 125 | it("lists persisted bindings with metadata in oldest-first order", () => | 296 | it.effect("lists persisted bindings with metadata in oldest-first order", () => |
| 126 | Effect.gen(function* () { | 297 | Effect.gen(function* () { |
| 127 | const directory = yield* ProviderSessionDirectory; | 298 | const directory = yield* ProviderSessionDirectory; |
| 128 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | 299 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; |
| ... | @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 162 | }, | 333 | }, |
| 163 | }); | 334 | }); |
| 164 | 335 | ||
| 165 | const bindings = yield* directory.listBindings(); | 336 | const bindings = (yield* directory.listBindings()).filter( |
| 337 | (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId, | ||
| 338 | ); | ||
| 166 | 339 | ||
| 167 | assert.deepEqual(bindings, [ | 340 | assert.deepEqual(bindings, [ |
| 168 | { | 341 | { |
| 169 | threadId: olderThreadId, | 342 | threadId: olderThreadId, |
| 170 | provider: ProviderDriverKind.make("claudeAgent"), | 343 | provider: ProviderDriverKind.make("claudeAgent"), |
| 344 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 171 | adapterKey: "claudeAgent", | 345 | adapterKey: "claudeAgent", |
| 172 | runtimeMode: "approval-required", | 346 | runtimeMode: "approval-required", |
| 173 | status: "starting", | 347 | status: "starting", |
| ... | @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 182 | { | 356 | { |
| 183 | threadId: newerThreadId, | 357 | threadId: newerThreadId, |
| 184 | provider: ProviderDriverKind.make("codex"), | 358 | provider: ProviderDriverKind.make("codex"), |
| 359 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 185 | adapterKey: "codex", | 360 | adapterKey: "codex", |
| 186 | runtimeMode: "full-access", | 361 | runtimeMode: "full-access", |
| 187 | status: "running", | 362 | status: "running", |
| ... | @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 194 | }, | 369 | }, |
| 195 | }, | 370 | }, |
| 196 | ]); | 371 | ]); |
| 197 | })); | 372 | }), |
| 373 | ); | ||
| 198 | 374 | ||
| 199 | it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => | 375 | it.effect( |
| 200 | Effect.gen(function* () { | 376 | "resets adapterKey to the new provider when provider changes without an explicit adapter key", |
| 201 | const directory = yield* ProviderSessionDirectory; | 377 | () => |
| 202 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | 378 | Effect.gen(function* () { |
| 203 | const threadId = ThreadId.make("thread-provider-change"); | 379 | const directory = yield* ProviderSessionDirectory; |
| 380 | const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; | ||
| 381 | const threadId = ThreadId.make("thread-provider-change"); | ||
| 204 | 382 | ||
| 205 | yield* runtimeRepository.upsert({ | 383 | yield* runtimeRepository.upsert({ |
| 206 | threadId, | 384 | threadId, |
| 207 | providerName: "claudeAgent", | 385 | providerName: "claudeAgent", |
| 208 | providerInstanceId: null, | 386 | providerInstanceId: null, |
| 209 | adapterKey: "claudeAgent", | 387 | adapterKey: "claudeAgent", |
| 210 | runtimeMode: "full-access", | 388 | runtimeMode: "full-access", |
| 211 | status: "running", | 389 | status: "running", |
| 212 | lastSeenAt: "2026-01-01T00:00:00.000Z", | 390 | lastSeenAt: "2026-01-01T00:00:00.000Z", |
| 213 | resumeCursor: null, | 391 | resumeCursor: null, |
| 214 | runtimePayload: null, | 392 | runtimePayload: null, |
| 215 | }); | 393 | }); |
| 216 | 394 | ||
| 217 | yield* directory.upsert({ | 395 | yield* directory.upsert({ |
| 218 | provider: ProviderDriverKind.make("codex"), | 396 | provider: ProviderDriverKind.make("codex"), |
| 219 | threadId, | 397 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 220 | }); | 398 | threadId, |
| 399 | }); | ||
| 221 | 400 | ||
| 222 | const runtime = yield* runtimeRepository.getByThreadId({ threadId }); | 401 | const runtime = yield* runtimeRepository.getByThreadId({ threadId }); |
| 223 | assert.equal(Option.isSome(runtime), true); | 402 | assert.equal(Option.isSome(runtime), true); |
| 224 | if (Option.isSome(runtime)) { | 403 | if (Option.isSome(runtime)) { |
| 225 | assert.equal(runtime.value.providerName, "codex"); | 404 | assert.equal(runtime.value.providerName, "codex"); |
| 226 | assert.equal(runtime.value.adapterKey, "codex"); | 405 | assert.equal(runtime.value.adapterKey, "codex"); |
| 227 | } | 406 | } |
| 228 | })); | 407 | }), |
| 408 | ); | ||
| 229 | 409 | ||
| 230 | it("rehydrates persisted mappings across layer restart", () => | 410 | it.effect("rehydrates persisted mappings across layer restart", () => |
| 231 | Effect.gen(function* () { | 411 | Effect.gen(function* () { |
| 232 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); | 412 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); |
| 233 | const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); | 413 | const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); |
| ... | @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 239 | const directory = yield* ProviderSessionDirectory; | 419 | const directory = yield* ProviderSessionDirectory; |
| 240 | yield* directory.upsert({ | 420 | yield* directory.upsert({ |
| 241 | provider: ProviderDriverKind.make("codex"), | 421 | provider: ProviderDriverKind.make("codex"), |
| 422 | providerInstanceId: ProviderInstanceId.make("codex"), | ||
| 242 | threadId, | 423 | threadId, |
| 243 | }); | 424 | }); |
| 244 | }).pipe(Effect.provide(directoryLayer)); | 425 | }).pipe(Effect.provide(directoryLayer)); |
| ... | @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 250 | assert.equal(provider, "codex"); | 431 | assert.equal(provider, "codex"); |
| 251 | 432 | ||
| 252 | const resolvedBinding = yield* directory.getBinding(threadId); | 433 | const resolvedBinding = yield* directory.getBinding(threadId); |
| 253 | assertSome(resolvedBinding, { | 434 | expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ |
| 254 | threadId, | 435 | threadId, |
| 255 | provider: ProviderDriverKind.make("codex"), | 436 | provider: ProviderDriverKind.make("codex"), |
| 256 | }); | 437 | }); |
| ... | @@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 267 | }).pipe(Effect.provide(directoryLayer)); | 448 | }).pipe(Effect.provide(directoryLayer)); |
| 268 | 449 | ||
| 269 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | 450 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); |
| 270 | })); | 451 | }), |
| 452 | ); | ||
| 271 | }); | 453 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+35-20| ... | @@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 100 | ), | 100 | ), |
| 101 | ); | 101 | ); |
| 102 | 102 | ||
| 103 | const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { | 103 | const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) { |
| 104 | const existing = yield* repository | 104 | const existing = yield* repository |
| 105 | .getByThreadId({ threadId: binding.threadId }) | 105 | .getByThreadId({ threadId: binding.threadId }) |
| 106 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); | 106 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); |
| ... | @@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 126 | }); | 126 | }); |
| 127 | } | 127 | } |
| 128 | yield* repository | 128 | yield* repository |
| 129 | .upsert({ | 129 | .upsert( |
| 130 | threadId: resolvedThreadId, | 130 | { |
| 131 | providerName: binding.provider, | 131 | threadId: resolvedThreadId, |
| 132 | providerInstanceId, | 132 | providerName: binding.provider, |
| 133 | adapterKey: | 133 | providerInstanceId, |
| 134 | binding.adapterKey ?? | 134 | adapterKey: |
| 135 | (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), | 135 | binding.adapterKey ?? |
| 136 | runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", | 136 | (providerChanged |
| 137 | status: binding.status ?? existingRuntime?.status ?? "running", | 137 | ? binding.provider |
| 138 | lastSeenAt: now, | 138 | : (existingRuntime?.adapterKey ?? binding.provider)), |
| 139 | resumeCursor: | 139 | runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", |
| 140 | binding.resumeCursor !== undefined | 140 | status: binding.status ?? existingRuntime?.status ?? "running", |
| 141 | ? binding.resumeCursor | 141 | lastSeenAt: now, |
| 142 | : (existingRuntime?.resumeCursor ?? null), | 142 | resumeCursor: |
| 143 | runtimePayload: mergeRuntimePayload( | 143 | binding.resumeCursor !== undefined |
| 144 | existingRuntime?.runtimePayload ?? null, | 144 | ? binding.resumeCursor |
| 145 | binding.runtimePayload, | 145 | : (existingRuntime?.resumeCursor ?? null), |
| 146 | ), | 146 | runtimePayload: mergeRuntimePayload( |
| 147 | }) | 147 | existingRuntime?.runtimePayload ?? null, |
| 148 | binding.runtimePayload, | ||
| 149 | ), | ||
| 150 | }, | ||
| 151 | options, | ||
| 152 | ) | ||
| 148 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); | 153 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); |
| 149 | }); | 154 | }); |
| 150 | 155 | ||
| ... | @@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 164 | ), | 169 | ), |
| 165 | ); | 170 | ); |
| 166 | 171 | ||
| 172 | const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = ( | ||
| 173 | input, | ||
| 174 | ) => | ||
| 175 | repository | ||
| 176 | .recordImportedTranscript(input) | ||
| 177 | .pipe( | ||
| 178 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), | ||
| 179 | ); | ||
| 180 | |||
| 167 | const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => | 181 | const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => |
| 168 | repository.list().pipe( | 182 | repository.list().pipe( |
| 169 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), | 183 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), |
| ... | @@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 184 | 198 | ||
| 185 | return { | 199 | return { |
| 186 | upsert, | 200 | upsert, |
| 201 | recordImportedTranscript, | ||
| 187 | getProvider, | 202 | getProvider, |
| 188 | getBinding, | 203 | getBinding, |
| 189 | listThreadIds, | 204 | listThreadIds, |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | @@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => { | ... | @@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => { |
| 217 | getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), | 217 | getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), |
| 218 | getProjectShellById: () => Effect.die("unused"), | 218 | getProjectShellById: () => Effect.die("unused"), |
| 219 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | 219 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
| 220 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 220 | getThreadCheckpointContext: () => Effect.die("unused"), | 221 | getThreadCheckpointContext: () => Effect.die("unused"), |
| 221 | getFullThreadDiffContext: () => Effect.die("unused"), | 222 | getFullThreadDiffContext: () => Effect.die("unused"), |
| 222 | getThreadRuntimeContext: () => Effect.die("unused"), | 223 | getThreadRuntimeContext: () => Effect.die("unused"), |
apps/server/src/provider/ProviderInstanceEnvironment.test.ts+48-1| ... | @@ -1,8 +1,55 @@ | ... | @@ -1,8 +1,55 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import * as NodeOS from "node:os"; |
| 2 | |||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 2 | 7 | ||
| 3 | import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; | 8 | import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; |
| 4 | 9 | ||
| 5 | describe("mergeProviderInstanceEnvironment", () => { | 10 | describe("mergeProviderInstanceEnvironment", () => { |
| 11 | it.effect.each([ | ||
| 12 | { value: "~/.account", tail: ".account" }, | ||
| 13 | { value: "~\\.account\\work", tail: ".account\\work" }, | ||
| 14 | ])("expands configured provider homes set to $value", ({ value, tail }) => | ||
| 15 | Effect.gen(function* () { | ||
| 16 | const path = yield* Path.Path; | ||
| 17 | const baseEnv = { | ||
| 18 | CODEX_HOME: "~/.inherited-codex", | ||
| 19 | CLAUDE_CONFIG_DIR: "~/.inherited-claude", | ||
| 20 | }; | ||
| 21 | const environment = mergeProviderInstanceEnvironment( | ||
| 22 | [ | ||
| 23 | { name: "CODEX_HOME", value, sensitive: false }, | ||
| 24 | { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, | ||
| 25 | { name: "CUSTOM_VALUE", value, sensitive: false }, | ||
| 26 | ], | ||
| 27 | baseEnv, | ||
| 28 | ); | ||
| 29 | |||
| 30 | expect(environment).toEqual({ | ||
| 31 | CODEX_HOME: path.join(NodeOS.homedir(), tail), | ||
| 32 | CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), | ||
| 33 | CUSTOM_VALUE: value, | ||
| 34 | }); | ||
| 35 | expect(baseEnv).toEqual({ | ||
| 36 | CODEX_HOME: "~/.inherited-codex", | ||
| 37 | CLAUDE_CONFIG_DIR: "~/.inherited-claude", | ||
| 38 | }); | ||
| 39 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 40 | ); | ||
| 41 | |||
| 42 | it("leaves inherited provider homes unchanged", () => { | ||
| 43 | const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; | ||
| 44 | |||
| 45 | expect( | ||
| 46 | mergeProviderInstanceEnvironment( | ||
| 47 | [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], | ||
| 48 | baseEnv, | ||
| 49 | ), | ||
| 50 | ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); | ||
| 51 | }); | ||
| 52 | |||
| 6 | it("overrides inherited environment values and preserves empty strings", () => { | 53 | it("overrides inherited environment values and preserves empty strings", () => { |
| 7 | expect( | 54 | expect( |
| 8 | mergeProviderInstanceEnvironment( | 55 | mergeProviderInstanceEnvironment( |
apps/server/src/provider/ProviderInstanceEnvironment.ts+7-1| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; | 1 | import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 4 | |||
| 3 | export function mergeProviderInstanceEnvironment( | 5 | export function mergeProviderInstanceEnvironment( |
| 4 | environment: ProviderInstanceEnvironment | undefined, | 6 | environment: ProviderInstanceEnvironment | undefined, |
| 5 | baseEnv: NodeJS.ProcessEnv = process.env, | 7 | baseEnv: NodeJS.ProcessEnv = process.env, |
| ... | @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( | ... | @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( |
| 10 | 12 | ||
| 11 | const next: NodeJS.ProcessEnv = { ...baseEnv }; | 13 | const next: NodeJS.ProcessEnv = { ...baseEnv }; |
| 12 | for (const variable of environment) { | 14 | for (const variable of environment) { |
| 13 | next[variable.name] = variable.value; | 15 | // Child processes do not apply shell expansion to environment values. |
| 16 | next[variable.name] = | ||
| 17 | variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" | ||
| 18 | ? expandHomePath(variable.value) | ||
| 19 | : variable.value; | ||
| 14 | } | 20 | } |
| 15 | return next; | 21 | return next; |
| 16 | } | 22 | } |
apps/server/src/provider/Services/ProviderSessionDirectory.ts+12| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | AgentSessionImportSource, | ||
| 2 | ProviderInstanceId, | 3 | ProviderInstanceId, |
| 3 | ProviderDriverKind, | 4 | ProviderDriverKind, |
| 4 | ProviderSessionRuntimeStatus, | 5 | ProviderSessionRuntimeStatus, |
| ... | @@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError = | ... | @@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError = |
| 40 | | ProviderValidationError | 41 | | ProviderValidationError |
| 41 | | ProviderSessionDirectoryPersistenceError; | 42 | | ProviderSessionDirectoryPersistenceError; |
| 42 | 43 | ||
| 44 | export interface ProviderSessionDirectoryUpsertOptions { | ||
| 45 | readonly onConflict?: "update" | "ignore"; | ||
| 46 | } | ||
| 47 | |||
| 43 | export interface ProviderSessionDirectoryShape { | 48 | export interface ProviderSessionDirectoryShape { |
| 44 | readonly upsert: ( | 49 | readonly upsert: ( |
| 45 | binding: ProviderRuntimeBinding, | 50 | binding: ProviderRuntimeBinding, |
| 51 | options?: ProviderSessionDirectoryUpsertOptions, | ||
| 46 | ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>; | 52 | ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>; |
| 47 | 53 | ||
| 54 | /** Record an imported file without changing the current provider session. */ | ||
| 55 | readonly recordImportedTranscript: (input: { | ||
| 56 | readonly threadId: ThreadId; | ||
| 57 | readonly source: AgentSessionImportSource; | ||
| 58 | }) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; | ||
| 59 | |||
| 48 | readonly getProvider: ( | 60 | readonly getProvider: ( |
| 49 | threadId: ThreadId, | 61 | threadId: ThreadId, |
| 50 | ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>; | 62 | ) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>; |
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+8| ... | @@ -57,6 +57,14 @@ rl.on("line", (line) => { | ... | @@ -57,6 +57,14 @@ rl.on("line", (line) => { |
| 57 | }); | 57 | }); |
| 58 | return; | 58 | return; |
| 59 | } | 59 | } |
| 60 | if (method === "account/read") { | ||
| 61 | write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); | ||
| 62 | return; | ||
| 63 | } | ||
| 64 | if (method === "skills/list" || method === "model/list") { | ||
| 65 | write({ id, result: { data: [] } }); | ||
| 66 | return; | ||
| 67 | } | ||
| 60 | if (method === "thread/start") { | 68 | if (method === "thread/start") { |
| 61 | write({ id, result: fixture.responses.threadStart }); | 69 | write({ id, result: fixture.responses.threadStart }); |
| 62 | return; | 70 | return; |
apps/server/src/relay/AgentAwarenessRelay.test.ts+87-11| ... | @@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 138 | expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); | 138 | expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); |
| 139 | }); | 139 | }); |
| 140 | 140 | ||
| 141 | it("does not publish start intents, streaming content, or non-awareness activity events", () => { | 141 | it("does not publish imported, start-intent, streaming, or non-awareness events", () => { |
| 142 | const now = "2026-05-25T00:00:00.000Z"; | 142 | const now = "2026-05-25T00:00:00.000Z"; |
| 143 | const base = { | 143 | const base = { |
| 144 | sequence: 1, | 144 | sequence: 1, |
| ... | @@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 147 | aggregateKind: "thread", | 147 | aggregateKind: "thread", |
| 148 | aggregateId: "thread-1" as ThreadId, | 148 | aggregateId: "thread-1" as ThreadId, |
| 149 | occurredAt: now, | 149 | occurredAt: now, |
| 150 | metadata: {}, | ||
| 150 | }; | 151 | }; |
| 151 | 152 | ||
| 152 | expect( | 153 | expect( |
| ... | @@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 202 | }, | 203 | }, |
| 203 | } as unknown as OrchestrationEvent), | 204 | } as unknown as OrchestrationEvent), |
| 204 | ).toBe(false); | 205 | ).toBe(false); |
| 206 | expect( | ||
| 207 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 208 | ...base, | ||
| 209 | type: "thread.created", | ||
| 210 | metadata: { historyImport: true }, | ||
| 211 | payload: { threadId: "thread-1" as ThreadId }, | ||
| 212 | } as unknown as OrchestrationEvent), | ||
| 213 | ).toBe(false); | ||
| 214 | expect( | ||
| 215 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 216 | ...base, | ||
| 217 | type: "thread.settled", | ||
| 218 | metadata: { historyImport: true }, | ||
| 219 | payload: { threadId: "thread-1" as ThreadId }, | ||
| 220 | } as unknown as OrchestrationEvent), | ||
| 221 | ).toBe(false); | ||
| 222 | expect( | ||
| 223 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 224 | ...base, | ||
| 225 | type: "thread.created", | ||
| 226 | payload: { threadId: "thread-1" as ThreadId }, | ||
| 227 | } as unknown as OrchestrationEvent), | ||
| 228 | ).toBe(true); | ||
| 229 | expect( | ||
| 230 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 231 | ...base, | ||
| 232 | type: "thread.settled", | ||
| 233 | payload: { threadId: "thread-1" as ThreadId }, | ||
| 234 | } as unknown as OrchestrationEvent), | ||
| 235 | ).toBe(true); | ||
| 205 | }); | 236 | }); |
| 206 | 237 | ||
| 207 | it("deduplicates awareness state updates whose only change is their event timestamp", () => { | 238 | it("deduplicates awareness state updates whose only change is their event timestamp", () => { |
| ... | @@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 400 | }), | 431 | }), |
| 401 | ); | 432 | ); |
| 402 | 433 | ||
| 403 | it.effect("keeps the orchestration listener armed until relay config is installed", () => | 434 | it.effect("keeps the listener armed and skips imported thread work", () => |
| 404 | Effect.scoped( | 435 | Effect.scoped( |
| 405 | Effect.gen(function* () { | 436 | Effect.gen(function* () { |
| 406 | const events = yield* Queue.unbounded<OrchestrationEvent>(); | 437 | const events = yield* Queue.unbounded<OrchestrationEvent>(); |
| 407 | const threadShellRequested = yield* Deferred.make<void>(); | 438 | const threadShellRequested = yield* Deferred.make<void>(); |
| 439 | const releaseThreadShell = yield* Deferred.make<void>(); | ||
| 440 | const threadShellRequests: Array<ThreadId> = []; | ||
| 441 | let fetchCallCount = 0; | ||
| 408 | const secrets = makeMemorySecretStore(); | 442 | const secrets = makeMemorySecretStore(); |
| 409 | const now = "2026-05-25T00:00:00.000Z"; | 443 | const now = "2026-05-25T00:00:00.000Z"; |
| 410 | const projectId = "project-1" as ProjectId; | 444 | const projectId = "project-1" as ProjectId; |
| 411 | const threadId = "thread-1" as ThreadId; | 445 | const threadId = "thread-1" as ThreadId; |
| 446 | const importedThreadId = "import:codex:session-1" as ThreadId; | ||
| 412 | const environmentId = "env-1" as EnvironmentId; | 447 | const environmentId = "env-1" as EnvironmentId; |
| 413 | 448 | ||
| 449 | const originalFetch = globalThis.fetch; | ||
| 450 | globalThis.fetch = (() => { | ||
| 451 | fetchCallCount += 1; | ||
| 452 | return Promise.resolve(Response.json({ ok: true, deliveries: [] })); | ||
| 453 | }) as unknown as typeof fetch; | ||
| 454 | yield* Effect.addFinalizer(() => | ||
| 455 | Effect.sync(() => { | ||
| 456 | globalThis.fetch = originalFetch; | ||
| 457 | }), | ||
| 458 | ); | ||
| 459 | |||
| 414 | const project = { | 460 | const project = { |
| 415 | id: projectId, | 461 | id: projectId, |
| 416 | title: "T3 Code", | 462 | title: "T3 Code", |
| ... | @@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 473 | getShellSnapshot: () => | 519 | getShellSnapshot: () => |
| 474 | Effect.succeed({ | 520 | Effect.succeed({ |
| 475 | snapshotSequence: 1, | 521 | snapshotSequence: 1, |
| 476 | projects: [project], | 522 | projects: [], |
| 477 | threads: [thread], | 523 | threads: [], |
| 478 | updatedAt: now, | 524 | updatedAt: now, |
| 479 | } satisfies OrchestrationShellSnapshot), | 525 | } satisfies OrchestrationShellSnapshot), |
| 480 | getThreadShellById: () => | 526 | getThreadShellById: (requestedThreadId: ThreadId) => |
| 481 | Deferred.succeed(threadShellRequested, undefined).pipe( | 527 | Effect.gen(function* () { |
| 482 | Effect.ignore, | 528 | threadShellRequests.push(requestedThreadId); |
| 483 | Effect.as(Option.some(thread)), | 529 | if (requestedThreadId !== threadId) return Option.none(); |
| 484 | ), | 530 | yield* Deferred.succeed(threadShellRequested, undefined); |
| 531 | yield* Deferred.await(releaseThreadShell); | ||
| 532 | return Option.some(thread); | ||
| 533 | }), | ||
| 485 | getProjectShellById: () => Effect.succeed(Option.some(project)), | 534 | getProjectShellById: () => Effect.succeed(Option.some(project)), |
| 486 | } as unknown as ProjectionSnapshotQueryShape; | 535 | } as unknown as ProjectionSnapshotQueryShape; |
| 487 | 536 | ||
| ... | @@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 511 | yield* Effect.gen(function* () { | 560 | yield* Effect.gen(function* () { |
| 512 | const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; | 561 | const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; |
| 513 | yield* relay.start(); | 562 | yield* relay.start(); |
| 514 | yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); | 563 | yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); |
| 515 | yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); | 564 | yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); |
| 516 | yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); | 565 | yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); |
| 517 | yield* Queue.offer(events, { | 566 | yield* Queue.offer(events, { |
| 518 | type: "thread.activity-appended", | 567 | type: "thread.created", |
| 519 | sequence: 1, | 568 | sequence: 1, |
| 569 | eventId: "evt-import-created", | ||
| 570 | commandId: CommandId.make("cmd-import-created"), | ||
| 571 | aggregateKind: "thread", | ||
| 572 | aggregateId: importedThreadId, | ||
| 573 | metadata: { historyImport: true }, | ||
| 574 | payload: { threadId: importedThreadId }, | ||
| 575 | occurredAt: now, | ||
| 576 | } as unknown as OrchestrationEvent); | ||
| 577 | yield* Queue.offer(events, { | ||
| 578 | type: "thread.settled", | ||
| 579 | sequence: 2, | ||
| 580 | eventId: "evt-import-settled", | ||
| 581 | commandId: CommandId.make("cmd-import-settled"), | ||
| 582 | aggregateKind: "thread", | ||
| 583 | aggregateId: importedThreadId, | ||
| 584 | metadata: { historyImport: true }, | ||
| 585 | payload: { threadId: importedThreadId }, | ||
| 586 | occurredAt: now, | ||
| 587 | } as unknown as OrchestrationEvent); | ||
| 588 | yield* Queue.offer(events, { | ||
| 589 | type: "thread.activity-appended", | ||
| 590 | sequence: 3, | ||
| 520 | eventId: "evt-1", | 591 | eventId: "evt-1", |
| 521 | commandId: CommandId.make("cmd-1"), | 592 | commandId: CommandId.make("cmd-1"), |
| 522 | aggregateKind: "thread", | 593 | aggregateKind: "thread", |
| 523 | aggregateId: threadId, | 594 | aggregateId: threadId, |
| 524 | actor: { kind: "server" }, | 595 | actor: { kind: "server" }, |
| 596 | metadata: {}, | ||
| 525 | payload: { | 597 | payload: { |
| 526 | threadId, | 598 | threadId, |
| 527 | activity: { | 599 | activity: { |
| ... | @@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 532 | } as unknown as OrchestrationEvent); | 604 | } as unknown as OrchestrationEvent); |
| 533 | 605 | ||
| 534 | yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); | 606 | yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); |
| 607 | expect(threadShellRequests).toEqual([threadId]); | ||
| 608 | expect(fetchCallCount).toBe(0); | ||
| 609 | yield* Deferred.succeed(releaseThreadShell, undefined); | ||
| 535 | }).pipe( | 610 | }).pipe( |
| 536 | Effect.provide( | 611 | Effect.provide( |
| 537 | AgentAwarenessRelay.layer.pipe( | 612 | AgentAwarenessRelay.layer.pipe( |
| ... | @@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 692 | aggregateKind: "thread", | 767 | aggregateKind: "thread", |
| 693 | aggregateId: threadId, | 768 | aggregateId: threadId, |
| 694 | actor: { kind: "server" }, | 769 | actor: { kind: "server" }, |
| 770 | metadata: {}, | ||
| 695 | payload: { | 771 | payload: { |
| 696 | threadId, | 772 | threadId, |
| 697 | activity: { | 773 | activity: { |
apps/server/src/relay/AgentAwarenessRelay.ts+3| ... | @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { | ... | @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { | 69 | export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { |
| 70 | if (event.metadata.historyImport === true) { | ||
| 71 | return false; | ||
| 72 | } | ||
| 70 | switch (event.type) { | 73 | switch (event.type) { |
| 71 | case "thread.message-sent": | 74 | case "thread.message-sent": |
| 72 | case "thread.turn-start-requested": | 75 | case "thread.turn-start-requested": |
apps/server/src/server.test.ts+203| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | type ProviderInstallState, | 36 | type ProviderInstallState, |
| 37 | ProviderSetupError, | 37 | ProviderSetupError, |
| 38 | ResolvedKeybindingRule, | 38 | ResolvedKeybindingRule, |
| 39 | type ServerLifecycleStreamEvent, | ||
| 39 | ThreadId, | 40 | ThreadId, |
| 40 | TurnId, | 41 | TurnId, |
| 41 | WS_METHODS, | 42 | WS_METHODS, |
| ... | @@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( | ... | @@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( |
| 91 | const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( | 92 | const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( |
| 92 | Schema.fromJsonString(OrchestrationShellSnapshot), | 93 | Schema.fromJsonString(OrchestrationShellSnapshot), |
| 93 | ); | 94 | ); |
| 95 | const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 94 | 96 | ||
| 95 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | 97 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 96 | import * as ServerConfig from "./config.ts"; | 98 | import * as ServerConfig from "./config.ts"; |
| ... | @@ -127,6 +129,7 @@ import { | ... | @@ -127,6 +129,7 @@ import { |
| 127 | AntigravityInstallationError, | 129 | AntigravityInstallationError, |
| 128 | } from "./provider/AntigravityInstallation.ts"; | 130 | } from "./provider/AntigravityInstallation.ts"; |
| 129 | import type { ProviderInstance } from "./provider/ProviderDriver.ts"; | 131 | import type { ProviderInstance } from "./provider/ProviderDriver.ts"; |
| 132 | import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; | ||
| 130 | import { ProviderAdapterRequestError } from "./provider/Errors.ts"; | 133 | import { ProviderAdapterRequestError } from "./provider/Errors.ts"; |
| 131 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; | 134 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; |
| 132 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 135 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| ... | @@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: { | ... | @@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: { |
| 513 | projectSetupScriptRunner?: Partial< | 516 | projectSetupScriptRunner?: Partial< |
| 514 | ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] | 517 | ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] |
| 515 | >; | 518 | >; |
| 519 | providerSessionDirectory?: Partial< | ||
| 520 | ProviderSessionDirectory.ProviderSessionDirectory["Service"] | ||
| 521 | >; | ||
| 516 | terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>; | 522 | terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>; |
| 517 | orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>; | 523 | orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>; |
| 518 | threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>; | 524 | threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>; |
| ... | @@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: { | ... | @@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: { |
| 785 | managedDirectory: "unused-test-antigravity-runtime", | 791 | managedDirectory: "unused-test-antigravity-runtime", |
| 786 | ...options?.layers?.antigravityInstallation, | 792 | ...options?.layers?.antigravityInstallation, |
| 787 | }), | 793 | }), |
| 794 | Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ | ||
| 795 | upsert: () => Effect.void, | ||
| 796 | getBinding: () => Effect.succeed(Option.none()), | ||
| 797 | listThreadIds: () => Effect.succeed([]), | ||
| 798 | listBindings: () => Effect.succeed([]), | ||
| 799 | ...options?.layers?.providerSessionDirectory, | ||
| 800 | }), | ||
| 788 | ), | 801 | ), |
| 789 | ), | 802 | ), |
| 790 | Layer.provide( | 803 | Layer.provide( |
| ... | @@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: { |
| 973 | }), | 986 | }), |
| 974 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 987 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 975 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 988 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 989 | getImportedAgentSessionSources: () => Effect.succeed([]), | ||
| 976 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 990 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 977 | ...options?.layers?.projectionSnapshotQuery, | 991 | ...options?.layers?.projectionSnapshotQuery, |
| 978 | }), | 992 | }), |
| ... | @@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5332 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 5346 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5333 | ); | 5347 | ); |
| 5334 | 5348 | ||
| 5349 | it.effect("keeps agent session import project failures structured over websocket rpc", () => | ||
| 5350 | Effect.gen(function* () { | ||
| 5351 | yield* buildAppUnderTest(); | ||
| 5352 | |||
| 5353 | const projectId = ProjectId.make("missing-import-project"); | ||
| 5354 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 5355 | const error = yield* Effect.scoped( | ||
| 5356 | withWsRpcClient(wsUrl, (client) => | ||
| 5357 | client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip), | ||
| 5358 | ), | ||
| 5359 | ); | ||
| 5360 | |||
| 5361 | assert.equal(error._tag, "AgentSessionImportProjectNotFoundError"); | ||
| 5362 | if (error._tag === "AgentSessionImportProjectNotFoundError") { | ||
| 5363 | assert.equal(error.projectId, projectId); | ||
| 5364 | } | ||
| 5365 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 5366 | ); | ||
| 5367 | |||
| 5368 | it.effect("returns scanner skip counts over websocket rpc", () => | ||
| 5369 | Effect.gen(function* () { | ||
| 5370 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 5371 | const path = yield* Path.Path; | ||
| 5372 | const codexHome = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 5373 | prefix: "t3-agent-import-rpc-codex-", | ||
| 5374 | }); | ||
| 5375 | const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 5376 | prefix: "t3-agent-import-rpc-workspace-", | ||
| 5377 | }); | ||
| 5378 | const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31"); | ||
| 5379 | const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl"); | ||
| 5380 | yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true }); | ||
| 5381 | yield* fileSystem.writeFileString( | ||
| 5382 | transcriptPath, | ||
| 5383 | encodeTestJson({ | ||
| 5384 | timestamp: "2026-08-31T12:00:00.000Z", | ||
| 5385 | type: "session_meta", | ||
| 5386 | payload: { id: "rpc-skipped-session", cwd: workspaceRoot }, | ||
| 5387 | }), | ||
| 5388 | ); | ||
| 5389 | yield* fileSystem.utimes(transcriptPath, 0, 0); | ||
| 5390 | |||
| 5391 | const projectId = ProjectId.make("agent-import-rpc-project"); | ||
| 5392 | const project = { | ||
| 5393 | id: projectId, | ||
| 5394 | title: "Agent import RPC", | ||
| 5395 | workspaceRoot, | ||
| 5396 | defaultModelSelection: null, | ||
| 5397 | scripts: [], | ||
| 5398 | createdAt: "2026-08-31T12:00:00.000Z", | ||
| 5399 | updatedAt: "2026-08-31T12:00:00.000Z", | ||
| 5400 | } as const; | ||
| 5401 | yield* buildAppUnderTest({ | ||
| 5402 | layers: { | ||
| 5403 | serverSettings: { | ||
| 5404 | getSettings: Effect.succeed({ | ||
| 5405 | ...DEFAULT_SERVER_SETTINGS, | ||
| 5406 | providerInstances: { | ||
| 5407 | [ProviderInstanceId.make("codex")]: { | ||
| 5408 | driver: ProviderDriverKind.make("codex"), | ||
| 5409 | config: { homePath: codexHome }, | ||
| 5410 | }, | ||
| 5411 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 5412 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 5413 | enabled: false, | ||
| 5414 | config: {}, | ||
| 5415 | }, | ||
| 5416 | }, | ||
| 5417 | }), | ||
| 5418 | }, | ||
| 5419 | projectionSnapshotQuery: { | ||
| 5420 | getProjectShellById: (requestedProjectId) => | ||
| 5421 | Effect.succeed( | ||
| 5422 | requestedProjectId === projectId ? Option.some(project) : Option.none(), | ||
| 5423 | ), | ||
| 5424 | }, | ||
| 5425 | }, | ||
| 5426 | }); | ||
| 5427 | |||
| 5428 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 5429 | const result = yield* Effect.scoped( | ||
| 5430 | withWsRpcClient(wsUrl, (client) => | ||
| 5431 | Effect.gen(function* () { | ||
| 5432 | const scan = yield* client[WS_METHODS.agentSessionsScan]({}); | ||
| 5433 | assert.deepEqual( | ||
| 5434 | scan.candidates.map((candidate) => candidate.path), | ||
| 5435 | [workspaceRoot], | ||
| 5436 | ); | ||
| 5437 | return yield* client[WS_METHODS.agentSessionsImport]({ projectId }); | ||
| 5438 | }), | ||
| 5439 | ), | ||
| 5440 | ); | ||
| 5441 | |||
| 5442 | assert.deepEqual(result, { importedCount: 0, skippedCount: 1 }); | ||
| 5443 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 5444 | ); | ||
| 5445 | |||
| 5335 | it.effect("uploads Codex thread feedback through websocket rpc", () => | 5446 | it.effect("uploads Codex thread feedback through websocket rpc", () => |
| 5336 | Effect.gen(function* () { | 5447 | Effect.gen(function* () { |
| 5337 | const input = { | 5448 | const input = { |
| ... | @@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6239 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 6350 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 6240 | ); | 6351 | ); |
| 6241 | 6352 | ||
| 6353 | it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () => | ||
| 6354 | Effect.gen(function* () { | ||
| 6355 | const pubsub = yield* PubSub.unbounded<ServerLifecycleStreamEvent>(); | ||
| 6356 | const streamSubscribed = yield* Deferred.make<void>(); | ||
| 6357 | const snapshotPublished = yield* Deferred.make<void>(); | ||
| 6358 | const bootstrapProjectId = ProjectId.make("project-bootstrap"); | ||
| 6359 | const bootstrapThreadId = ThreadId.make("thread-bootstrap"); | ||
| 6360 | const snapshotEvent = { | ||
| 6361 | version: 1 as const, | ||
| 6362 | sequence: 1, | ||
| 6363 | type: "welcome" as const, | ||
| 6364 | payload: { | ||
| 6365 | environment: testEnvironmentDescriptor, | ||
| 6366 | cwd: "/tmp/project", | ||
| 6367 | projectName: "project", | ||
| 6368 | bootstrapStatus: "pending" as const, | ||
| 6369 | }, | ||
| 6370 | }; | ||
| 6371 | const gapEvent = { | ||
| 6372 | version: 1 as const, | ||
| 6373 | sequence: 2, | ||
| 6374 | type: "welcome" as const, | ||
| 6375 | payload: { | ||
| 6376 | environment: testEnvironmentDescriptor, | ||
| 6377 | cwd: "/tmp/project", | ||
| 6378 | projectName: "project", | ||
| 6379 | bootstrapStatus: "complete" as const, | ||
| 6380 | bootstrapProjectId, | ||
| 6381 | bootstrapThreadId, | ||
| 6382 | bootstrapProjectCreated: true, | ||
| 6383 | bootstrapThreadCreated: true, | ||
| 6384 | }, | ||
| 6385 | }; | ||
| 6386 | const sentinelEvent = { | ||
| 6387 | version: 1 as const, | ||
| 6388 | sequence: 3, | ||
| 6389 | type: "ready" as const, | ||
| 6390 | payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor }, | ||
| 6391 | }; | ||
| 6392 | const liveStream = Stream.unwrap( | ||
| 6393 | Effect.gen(function* () { | ||
| 6394 | const subscription = yield* PubSub.subscribe(pubsub); | ||
| 6395 | yield* Deferred.succeed(streamSubscribed, undefined); | ||
| 6396 | return Stream.fromSubscription(subscription); | ||
| 6397 | }), | ||
| 6398 | ); | ||
| 6399 | |||
| 6400 | yield* buildAppUnderTest({ | ||
| 6401 | layers: { | ||
| 6402 | serverLifecycleEvents: { | ||
| 6403 | snapshot: PubSub.publish(pubsub, gapEvent).pipe( | ||
| 6404 | Effect.andThen(Deferred.succeed(snapshotPublished, undefined)), | ||
| 6405 | Effect.as({ sequence: 1, events: [snapshotEvent] }), | ||
| 6406 | ), | ||
| 6407 | stream: liveStream, | ||
| 6408 | }, | ||
| 6409 | }, | ||
| 6410 | }); | ||
| 6411 | |||
| 6412 | yield* Effect.gen(function* () { | ||
| 6413 | yield* Deferred.await(snapshotPublished); | ||
| 6414 | yield* Deferred.await(streamSubscribed); | ||
| 6415 | yield* PubSub.publish(pubsub, sentinelEvent); | ||
| 6416 | }).pipe(Effect.forkScoped); | ||
| 6417 | |||
| 6418 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 6419 | const events = yield* Effect.scoped( | ||
| 6420 | withWsRpcClient(wsUrl, (client) => | ||
| 6421 | client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), | ||
| 6422 | ), | ||
| 6423 | ); | ||
| 6424 | |||
| 6425 | const [first, second] = Array.from(events); | ||
| 6426 | assert.equal(first?.type, "welcome"); | ||
| 6427 | assert.equal(first?.sequence, 1); | ||
| 6428 | if (first?.type !== "welcome") { | ||
| 6429 | assert.fail("expected the pending bootstrap event"); | ||
| 6430 | } | ||
| 6431 | assert.equal(first.payload.bootstrapStatus, "pending"); | ||
| 6432 | assert.equal(second?.type, "welcome"); | ||
| 6433 | assert.equal(second?.sequence, 2); | ||
| 6434 | if (second?.type !== "welcome") { | ||
| 6435 | assert.fail("expected the bootstrap completion event"); | ||
| 6436 | } | ||
| 6437 | assert.equal(second.payload.bootstrapStatus, "complete"); | ||
| 6438 | assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId); | ||
| 6439 | assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId); | ||
| 6440 | assert.equal(second.payload.bootstrapProjectCreated, true); | ||
| 6441 | assert.equal(second.payload.bootstrapThreadCreated, true); | ||
| 6442 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 6443 | ); | ||
| 6444 | |||
| 6242 | it.effect("routes websocket rpc projects.searchEntries", () => | 6445 | it.effect("routes websocket rpc projects.searchEntries", () => |
| 6243 | Effect.gen(function* () { | 6446 | Effect.gen(function* () { |
| 6244 | const fs = yield* FileSystem.FileSystem; | 6447 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+11| ... | @@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () = | ... | @@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () = |
| 151 | ), | 151 | ), |
| 152 | ), | 152 | ), |
| 153 | upsert: (binding) => Effect.sync(() => upserts.push(binding)), | 153 | upsert: (binding) => Effect.sync(() => upserts.push(binding)), |
| 154 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 154 | getProvider: () => Effect.die("unused"), | 155 | getProvider: () => Effect.die("unused"), |
| 155 | listThreadIds: () => Effect.die("unused"), | 156 | listThreadIds: () => Effect.die("unused"), |
| 156 | listBindings: () => Effect.succeed([]), | 157 | listBindings: () => Effect.succeed([]), |
| ... | @@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)( | ... | @@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)( |
| 265 | firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, | 266 | firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, |
| 266 | ), | 267 | ), |
| 267 | ), | 268 | ), |
| 269 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 268 | getProvider: () => Effect.die("unused"), | 270 | getProvider: () => Effect.die("unused"), |
| 269 | listThreadIds: () => Effect.die("unused"), | 271 | listThreadIds: () => Effect.die("unused"), |
| 270 | listBindings: () => Effect.succeed([]), | 272 | listBindings: () => Effect.succeed([]), |
| ... | @@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { | ... | @@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { |
| 391 | ); | 393 | ); |
| 392 | }, | 394 | }, |
| 393 | upsert: () => Effect.void, | 395 | upsert: () => Effect.void, |
| 396 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 394 | getProvider: () => Effect.die("unused"), | 397 | getProvider: () => Effect.die("unused"), |
| 395 | listThreadIds: () => Effect.die("unused"), | 398 | listThreadIds: () => Effect.die("unused"), |
| 396 | listBindings: () => Effect.succeed([]), | 399 | listBindings: () => Effect.succeed([]), |
| ... | @@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure | ... | @@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure |
| 446 | }), | 449 | }), |
| 447 | ), | 450 | ), |
| 448 | upsert: () => Effect.void, | 451 | upsert: () => Effect.void, |
| 452 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 449 | getProvider: () => Effect.die("unused"), | 453 | getProvider: () => Effect.die("unused"), |
| 450 | listThreadIds: () => Effect.die("unused"), | 454 | listThreadIds: () => Effect.die("unused"), |
| 451 | listBindings: () => Effect.succeed([]), | 455 | listBindings: () => Effect.succeed([]), |
| ... | @@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio | ... | @@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio |
| 517 | ), | 521 | ), |
| 518 | ), | 522 | ), |
| 519 | upsert: (binding) => Effect.sync(() => upserts.push(binding)), | 523 | upsert: (binding) => Effect.sync(() => upserts.push(binding)), |
| 524 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 520 | getProvider: () => Effect.die("unused"), | 525 | getProvider: () => Effect.die("unused"), |
| 521 | listThreadIds: () => Effect.die("unused"), | 526 | listThreadIds: () => Effect.die("unused"), |
| 522 | listBindings: () => Effect.succeed([]), | 527 | listBindings: () => Effect.succeed([]), |
| ... | @@ -596,6 +601,7 @@ it.effect( | ... | @@ -596,6 +601,7 @@ it.effect( |
| 596 | }), | 601 | }), |
| 597 | ), | 602 | ), |
| 598 | upsert: () => Effect.fail(writeFailure), | 603 | upsert: () => Effect.fail(writeFailure), |
| 604 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 599 | getProvider: () => Effect.die("unused"), | 605 | getProvider: () => Effect.die("unused"), |
| 600 | listThreadIds: () => Effect.die("unused"), | 606 | listThreadIds: () => Effect.die("unused"), |
| 601 | listBindings: () => Effect.succeed([]), | 607 | listBindings: () => Effect.succeed([]), |
| ... | @@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure", | ... | @@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure", |
| 633 | directory: { | 639 | directory: { |
| 634 | getBinding: () => Effect.succeed(Option.none()), | 640 | getBinding: () => Effect.succeed(Option.none()), |
| 635 | upsert: () => Effect.void, | 641 | upsert: () => Effect.void, |
| 642 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 636 | getProvider: () => Effect.die("unused"), | 643 | getProvider: () => Effect.die("unused"), |
| 637 | listThreadIds: () => Effect.die("unused"), | 644 | listThreadIds: () => Effect.die("unused"), |
| 638 | listBindings: () => Effect.succeed([]), | 645 | listBindings: () => Effect.succeed([]), |
| ... | @@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot | ... | @@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot |
| 682 | Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { | 689 | Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { |
| 683 | getBinding: () => Effect.die("unused"), | 690 | getBinding: () => Effect.die("unused"), |
| 684 | upsert: () => Effect.die("unused"), | 691 | upsert: () => Effect.die("unused"), |
| 692 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 685 | getProvider: () => Effect.die("unused"), | 693 | getProvider: () => Effect.die("unused"), |
| 686 | listThreadIds: () => Effect.die("unused"), | 694 | listThreadIds: () => Effect.die("unused"), |
| 687 | listBindings: () => Effect.succeed([]), | 695 | listBindings: () => Effect.succeed([]), |
| ... | @@ -754,6 +762,7 @@ for (const scenario of [ | ... | @@ -754,6 +762,7 @@ for (const scenario of [ |
| 754 | Effect.sync(() => { | 762 | Effect.sync(() => { |
| 755 | upserts.push(binding); | 763 | upserts.push(binding); |
| 756 | }), | 764 | }), |
| 765 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 757 | getProvider: () => Effect.die("unused"), | 766 | getProvider: () => Effect.die("unused"), |
| 758 | listThreadIds: () => Effect.die("unused"), | 767 | listThreadIds: () => Effect.die("unused"), |
| 759 | listBindings: () => Effect.succeed([]), | 768 | listBindings: () => Effect.succeed([]), |
| ... | @@ -827,6 +836,7 @@ for (const preparedStatus of [ | ... | @@ -827,6 +836,7 @@ for (const preparedStatus of [ |
| 827 | if (binding.status !== "starting" || sends.length === 0) return; | 836 | if (binding.status !== "starting" || sends.length === 0) return; |
| 828 | yield* Deferred.succeed(cleared, undefined); | 837 | yield* Deferred.succeed(cleared, undefined); |
| 829 | }), | 838 | }), |
| 839 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 830 | getProvider: () => Effect.die("unused"), | 840 | getProvider: () => Effect.die("unused"), |
| 831 | listThreadIds: () => Effect.die("unused"), | 841 | listThreadIds: () => Effect.die("unused"), |
| 832 | listBindings: () => | 842 | listBindings: () => |
| ... | @@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( | ... | @@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( |
| 932 | Effect.sync(() => { | 942 | Effect.sync(() => { |
| 933 | binding = next; | 943 | binding = next; |
| 934 | }), | 944 | }), |
| 945 | recordImportedTranscript: () => Effect.die("unused"), | ||
| 935 | getProvider: () => Effect.die("unused"), | 946 | getProvider: () => Effect.die("unused"), |
| 936 | listThreadIds: () => Effect.die("unused"), | 947 | listThreadIds: () => Effect.die("unused"), |
| 937 | listBindings: () => Effect.succeed([]), | 948 | listBindings: () => Effect.succeed([]), |
apps/server/src/serverRuntimeStartup.test.ts+89| ... | @@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa | ... | @@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 155 | ), | 155 | ), |
| 156 | getProjectShellById: () => Effect.die("unused"), | 156 | getProjectShellById: () => Effect.die("unused"), |
| 157 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), | 157 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), |
| 158 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 158 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 159 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 159 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 160 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 160 | getThreadRuntimeContext: () => Effect.die("unused"), | 161 | getThreadRuntimeContext: () => Effect.die("unused"), |
| ... | @@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa | ... | @@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 181 | assert.deepStrictEqual(targets, { | 182 | assert.deepStrictEqual(targets, { |
| 182 | bootstrapProjectId, | 183 | bootstrapProjectId, |
| 183 | bootstrapThreadId, | 184 | bootstrapThreadId, |
| 185 | bootstrapProjectCreated: false, | ||
| 186 | bootstrapThreadCreated: false, | ||
| 184 | }); | 187 | }); |
| 185 | assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); | 188 | assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); |
| 186 | }); | 189 | }); |
| ... | @@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when | ... | @@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 212 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 215 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 213 | getProjectShellById: () => Effect.die("unused"), | 216 | getProjectShellById: () => Effect.die("unused"), |
| 214 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 217 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 218 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 215 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 219 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 216 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 220 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 217 | getThreadRuntimeContext: () => Effect.die("unused"), | 221 | getThreadRuntimeContext: () => Effect.die("unused"), |
| ... | @@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when | ... | @@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 237 | 241 | ||
| 238 | assert.equal(typeof targets.bootstrapProjectId, "string"); | 242 | assert.equal(typeof targets.bootstrapProjectId, "string"); |
| 239 | assert.equal(typeof targets.bootstrapThreadId, "string"); | 243 | assert.equal(typeof targets.bootstrapThreadId, "string"); |
| 244 | assert.equal(targets.bootstrapProjectCreated, true); | ||
| 245 | assert.equal(targets.bootstrapThreadCreated, true); | ||
| 240 | const commands = yield* Ref.get(dispatchCalls); | 246 | const commands = yield* Ref.get(dispatchCalls); |
| 241 | assert.deepStrictEqual( | 247 | assert.deepStrictEqual( |
| 242 | commands.map((command) => command.type), | 248 | commands.map((command) => command.type), |
| ... | @@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when | ... | @@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 250 | }), | 256 | }), |
| 251 | ); | 257 | ); |
| 252 | 258 | ||
| 259 | it.effect( | ||
| 260 | "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", | ||
| 261 | () => | ||
| 262 | Effect.gen(function* () { | ||
| 263 | const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 264 | const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( | ||
| 265 | Effect.provideService(ServerConfig.ServerConfig, { | ||
| 266 | cwd: "/tmp/startup-project", | ||
| 267 | autoBootstrapProjectFromCwd: true, | ||
| 268 | } as never), | ||
| 269 | Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | ||
| 270 | getUserInputActivity: () => Effect.die("unused"), | ||
| 271 | getCommandReadModel: () => Effect.die("unused"), | ||
| 272 | getSnapshot: () => Effect.die("unused"), | ||
| 273 | getShellSnapshot: () => Effect.die("unused"), | ||
| 274 | getArchivedShellSnapshot: () => Effect.die("unused"), | ||
| 275 | getSnapshotSequence: () => Effect.die("unused"), | ||
| 276 | getCounts: () => Effect.die("unused"), | ||
| 277 | getEventReplayStats: () => Effect.die("unused"), | ||
| 278 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | ||
| 279 | getProjectShellById: () => Effect.die("unused"), | ||
| 280 | getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), | ||
| 281 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 282 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | ||
| 283 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | ||
| 284 | getThreadRuntimeContext: () => Effect.die("unused"), | ||
| 285 | getThreadShellById: () => Effect.die("unused"), | ||
| 286 | getThreadDetailById: () => Effect.die("unused"), | ||
| 287 | getThreadDetailSnapshot: () => Effect.die("unused"), | ||
| 288 | searchThreads: () => Effect.succeed({ matches: [] }), | ||
| 289 | }), | ||
| 290 | Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { | ||
| 291 | readEvents: () => Stream.empty, | ||
| 292 | readThreadEvents: () => Stream.empty, | ||
| 293 | getThreadReplayStats: () => Effect.die("unused thread replay stats"), | ||
| 294 | dispatch: (command) => | ||
| 295 | Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( | ||
| 296 | Effect.as({ sequence: 1 }), | ||
| 297 | ), | ||
| 298 | streamDomainEvents: Stream.empty, | ||
| 299 | subscribeDomainEvents: Effect.succeed(Stream.empty), | ||
| 300 | latestSequence: Effect.succeed(0), | ||
| 301 | } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), | ||
| 302 | Effect.provide(NodeServices.layer), | ||
| 303 | ); | ||
| 304 | |||
| 305 | assert.equal(typeof targets.bootstrapProjectId, "string"); | ||
| 306 | assert.equal(targets.bootstrapProjectCreated, true); | ||
| 307 | assert.equal(targets.bootstrapThreadId, undefined); | ||
| 308 | assert.equal(targets.bootstrapThreadCreated, undefined); | ||
| 309 | assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]); | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | |||
| 253 | it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => | 313 | it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => |
| 254 | Effect.gen(function* () { | 314 | Effect.gen(function* () { |
| 255 | const crypto = yield* Crypto.Crypto; | 315 | const crypto = yield* Crypto.Crypto; |
| ... | @@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa | ... | @@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 278 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 338 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 279 | getProjectShellById: () => Effect.die("unused"), | 339 | getProjectShellById: () => Effect.die("unused"), |
| 280 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 340 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 341 | getImportedAgentSessionSources: () => Effect.die("unused"), | ||
| 281 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 342 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 282 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), | 343 | getFullThreadDiffContext: () => Effect.succeed(Option.none()), |
| 283 | getThreadRuntimeContext: () => Effect.die("unused"), | 344 | getThreadRuntimeContext: () => Effect.die("unused"), |
| ... | @@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa | ... | @@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 309 | assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); | 370 | assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); |
| 310 | }).pipe(Effect.provide(NodeServices.layer)), | 371 | }).pipe(Effect.provide(NodeServices.layer)), |
| 311 | ); | 372 | ); |
| 373 | |||
| 374 | it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () => | ||
| 375 | Effect.gen(function* () { | ||
| 376 | const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( | ||
| 377 | Effect.fail("bootstrap failed"), | ||
| 378 | ); | ||
| 379 | |||
| 380 | assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); | ||
| 381 | }), | ||
| 382 | ); | ||
| 383 | |||
| 384 | it.effect("completeAutoBootstrapWelcome settles unexpected defects", () => | ||
| 385 | Effect.gen(function* () { | ||
| 386 | const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( | ||
| 387 | Effect.die("bootstrap defect"), | ||
| 388 | ); | ||
| 389 | |||
| 390 | assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); | ||
| 391 | }), | ||
| 392 | ); | ||
| 393 | |||
| 394 | it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () => | ||
| 395 | Effect.gen(function* () { | ||
| 396 | const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({})); | ||
| 397 | |||
| 398 | assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); | ||
| 399 | }), | ||
| 400 | ); |
apps/server/src/serverRuntimeStartup.ts+81-46| ... | @@ -194,6 +194,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { | ... | @@ -194,6 +194,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 194 | 194 | ||
| 195 | let bootstrapProjectId: ProjectId | undefined; | 195 | let bootstrapProjectId: ProjectId | undefined; |
| 196 | let bootstrapThreadId: ThreadId | undefined; | 196 | let bootstrapThreadId: ThreadId | undefined; |
| 197 | let bootstrapProjectCreated = false; | ||
| 198 | let bootstrapThreadCreated = false; | ||
| 197 | 199 | ||
| 198 | if (serverConfig.autoBootstrapProjectFromCwd) { | 200 | if (serverConfig.autoBootstrapProjectFromCwd) { |
| 199 | yield* Effect.gen(function* () { | 201 | yield* Effect.gen(function* () { |
| ... | @@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { | ... | @@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 216 | workspaceRoot: serverConfig.cwd, | 218 | workspaceRoot: serverConfig.cwd, |
| 217 | createdAt, | 219 | createdAt, |
| 218 | }); | 220 | }); |
| 221 | bootstrapProjectId = nextProjectId; | ||
| 222 | bootstrapProjectCreated = true; | ||
| 219 | } else { | 223 | } else { |
| 220 | nextProjectId = existingProject.value.id; | 224 | nextProjectId = existingProject.value.id; |
| 225 | bootstrapProjectId = nextProjectId; | ||
| 221 | nextThreadModelSelection = | 226 | nextThreadModelSelection = |
| 222 | existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); | 227 | existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); |
| 223 | } | 228 | } |
| 224 | 229 | ||
| 225 | const existingThreadId = | 230 | yield* Effect.gen(function* () { |
| 226 | yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); | 231 | const existingThreadId = |
| 227 | if (Option.isNone(existingThreadId)) { | 232 | yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); |
| 228 | const createdAt = DateTime.formatIso(yield* DateTime.now); | 233 | if (Option.isNone(existingThreadId)) { |
| 229 | const createdThreadId = ThreadId.make(yield* randomUUID); | 234 | const createdAt = DateTime.formatIso(yield* DateTime.now); |
| 230 | yield* orchestrationEngine.dispatch({ | 235 | const createdThreadId = ThreadId.make(yield* randomUUID); |
| 231 | type: "thread.create", | 236 | yield* orchestrationEngine.dispatch({ |
| 232 | commandId: CommandId.make(yield* randomUUID), | 237 | type: "thread.create", |
| 233 | threadId: createdThreadId, | 238 | commandId: CommandId.make(yield* randomUUID), |
| 234 | projectId: nextProjectId, | 239 | threadId: createdThreadId, |
| 235 | title: "New thread", | 240 | projectId: nextProjectId, |
| 236 | modelSelection: nextThreadModelSelection, | 241 | title: "New thread", |
| 237 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 242 | modelSelection: nextThreadModelSelection, |
| 238 | runtimeMode: "full-access", | 243 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 239 | branch: null, | 244 | runtimeMode: "full-access", |
| 240 | worktreePath: null, | 245 | branch: null, |
| 241 | createdAt, | 246 | worktreePath: null, |
| 242 | }); | 247 | createdAt, |
| 243 | bootstrapProjectId = nextProjectId; | 248 | }); |
| 244 | bootstrapThreadId = createdThreadId; | 249 | bootstrapThreadId = createdThreadId; |
| 245 | } else { | 250 | bootstrapThreadCreated = true; |
| 246 | bootstrapProjectId = nextProjectId; | 251 | } else { |
| 247 | bootstrapThreadId = existingThreadId.value; | 252 | bootstrapThreadId = existingThreadId.value; |
| 248 | } | 253 | } |
| 254 | }).pipe( | ||
| 255 | Effect.catchCause((cause) => | ||
| 256 | Cause.hasInterrupts(cause) | ||
| 257 | ? Effect.failCause(cause) | ||
| 258 | : Effect.logWarning("startup thread auto-bootstrap failed", { | ||
| 259 | bootstrapProjectId: nextProjectId, | ||
| 260 | cause, | ||
| 261 | }), | ||
| 262 | ), | ||
| 263 | ); | ||
| 249 | }); | 264 | }); |
| 250 | } | 265 | } |
| 251 | 266 | ||
| 252 | return { | 267 | return { |
| 253 | ...(bootstrapProjectId ? { bootstrapProjectId } : {}), | 268 | ...(bootstrapProjectId ? { bootstrapProjectId } : {}), |
| 254 | ...(bootstrapThreadId ? { bootstrapThreadId } : {}), | 269 | ...(bootstrapThreadId ? { bootstrapThreadId } : {}), |
| 270 | ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), | ||
| 271 | ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), | ||
| 255 | } as const; | 272 | } as const; |
| 256 | }); | 273 | }); |
| 257 | 274 | ||
| 275 | export const completeAutoBootstrapWelcome = <A extends object, E, R>( | ||
| 276 | bootstrap: Effect.Effect<A, E, R>, | ||
| 277 | ) => | ||
| 278 | bootstrap.pipe( | ||
| 279 | Effect.matchCauseEffect({ | ||
| 280 | onFailure: (cause) => | ||
| 281 | Cause.hasInterrupts(cause) | ||
| 282 | ? Effect.failCause(cause) | ||
| 283 | : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( | ||
| 284 | Effect.as({ bootstrapStatus: "complete" as const }), | ||
| 285 | ), | ||
| 286 | onSuccess: (targets) => | ||
| 287 | Effect.succeed({ | ||
| 288 | ...targets, | ||
| 289 | bootstrapStatus: "complete" as const, | ||
| 290 | }), | ||
| 291 | }), | ||
| 292 | ); | ||
| 293 | |||
| 258 | const resolveStartupBrowserTarget = Effect.gen(function* () { | 294 | const resolveStartupBrowserTarget = Effect.gen(function* () { |
| 259 | const serverConfig = yield* ServerConfig.ServerConfig; | 295 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 260 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 296 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| ... | @@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) => | ... | @@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) => |
| 839 | runStartupPhase( | 875 | runStartupPhase( |
| 840 | "welcome.autobootstrap", | 876 | "welcome.autobootstrap", |
| 841 | Effect.gen(function* () { | 877 | Effect.gen(function* () { |
| 842 | const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( | 878 | const bootstrapCompletion = yield* completeAutoBootstrapWelcome( |
| 843 | Effect.provideService(Crypto.Crypto, crypto), | 879 | resolveAutoBootstrapWelcomeTargets.pipe( |
| 880 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 881 | ), | ||
| 882 | ); | ||
| 883 | |||
| 884 | yield* Effect.logDebug( | ||
| 885 | "startup phase: publishing completed bootstrap welcome event", | ||
| 886 | { | ||
| 887 | environmentId: environment.environmentId, | ||
| 888 | cwd: welcomeBase.cwd, | ||
| 889 | projectName: welcomeBase.projectName, | ||
| 890 | ...bootstrapCompletion, | ||
| 891 | }, | ||
| 844 | ); | 892 | ); |
| 845 | if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { | ||
| 846 | return; | ||
| 847 | } | ||
| 848 | |||
| 849 | yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { | ||
| 850 | environmentId: environment.environmentId, | ||
| 851 | cwd: welcomeBase.cwd, | ||
| 852 | projectName: welcomeBase.projectName, | ||
| 853 | bootstrapProjectId: bootstrapTargets.bootstrapProjectId, | ||
| 854 | bootstrapThreadId: bootstrapTargets.bootstrapThreadId, | ||
| 855 | }); | ||
| 856 | yield* lifecycleEvents.publish({ | 893 | yield* lifecycleEvents.publish({ |
| 857 | version: 1, | 894 | version: 1, |
| 858 | type: "welcome", | 895 | type: "welcome", |
| 859 | payload: { | 896 | payload: { |
| 860 | environment, | 897 | environment, |
| 861 | ...welcomeBase, | 898 | ...welcomeBase, |
| 862 | ...bootstrapTargets, | 899 | ...bootstrapCompletion, |
| 863 | }, | 900 | }, |
| 864 | }); | 901 | }); |
| 865 | }).pipe( | 902 | }).pipe(Effect.ignoreCause({ log: true })), |
| 866 | Effect.catch((cause) => | ||
| 867 | Effect.logWarning("startup auto-bootstrap welcome failed", { | ||
| 868 | cause, | ||
| 869 | }), | ||
| 870 | ), | ||
| 871 | ), | ||
| 872 | ), | 903 | ), |
| 873 | ); | 904 | ); |
| 874 | } | 905 | } |
| ... | @@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) => | ... | @@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) => |
| 914 | lifecycleEvents.publish({ | 945 | lifecycleEvents.publish({ |
| 915 | version: 1, | 946 | version: 1, |
| 916 | type: "welcome", | 947 | type: "welcome", |
| 917 | payload: { environment, ...welcomeBase }, | 948 | payload: { |
| 949 | environment, | ||
| 950 | ...welcomeBase, | ||
| 951 | bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", | ||
| 952 | }, | ||
| 918 | }), | 953 | }), |
| 919 | ); | 954 | ); |
| 920 | yield* options?.activate ?? Effect.void; | 955 | yield* options?.activate ?? Effect.void; |
apps/server/src/serverSettings.test.ts+37| ... | @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; | ... | @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; |
| 14 | import * as FileSystem from "effect/FileSystem"; | 14 | import * as FileSystem from "effect/FileSystem"; |
| 15 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 17 | import * as Path from "effect/Path"; | ||
| 17 | import * as PlatformError from "effect/PlatformError"; | 18 | import * as PlatformError from "effect/PlatformError"; |
| 18 | import * as Schema from "effect/Schema"; | 19 | import * as Schema from "effect/Schema"; |
| 19 | import * as Stream from "effect/Stream"; | 20 | import * as Stream from "effect/Stream"; |
| ... | @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | ... | @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 22 | import * as ServerConfig from "./config.ts"; | 23 | import * as ServerConfig from "./config.ts"; |
| 23 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | 24 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 24 | import * as ServerSettingsModule from "./serverSettings.ts"; | 25 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 26 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; | ||
| 25 | 27 | ||
| 26 | const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); | 28 | const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); |
| 27 | const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); | 29 | const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); |
| ... | @@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1102 | ); | 1104 | ); |
| 1103 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 1105 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 1104 | ); | 1106 | ); |
| 1107 | |||
| 1108 | it.effect("materializes provider secrets for terminal environment resolution", () => | ||
| 1109 | Effect.gen(function* () { | ||
| 1110 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | ||
| 1111 | const serverConfig = yield* ServerConfig.ServerConfig; | ||
| 1112 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1113 | const path = yield* Path.Path; | ||
| 1114 | const instanceId = ProviderInstanceId.make("codex_terminal"); | ||
| 1115 | |||
| 1116 | yield* serverSettings.updateSettings({ | ||
| 1117 | providerInstances: { | ||
| 1118 | [instanceId]: { | ||
| 1119 | driver: ProviderDriverKind.make("codex"), | ||
| 1120 | environment: [ | ||
| 1121 | { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, | ||
| 1122 | ], | ||
| 1123 | config: { homePath: "~/.codex-terminal" }, | ||
| 1124 | }, | ||
| 1125 | }, | ||
| 1126 | }); | ||
| 1127 | |||
| 1128 | const environment = yield* resolveProviderInstanceTerminalEnvironment({ | ||
| 1129 | serverSettings, | ||
| 1130 | path, | ||
| 1131 | rawProviderInstanceId: instanceId, | ||
| 1132 | env: undefined, | ||
| 1133 | }); | ||
| 1134 | const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); | ||
| 1135 | |||
| 1136 | assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); | ||
| 1137 | assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); | ||
| 1138 | assert.notInclude(persisted, "sk-terminal-secret"); | ||
| 1139 | assert.include(persisted, '"valueRedacted": true'); | ||
| 1140 | }).pipe(Effect.provide(makeServerSettingsLayer())), | ||
| 1141 | ); | ||
| 1105 | }); | 1142 | }); |
apps/server/src/terminal/Manager.test.ts+412| ... | @@ -7,9 +7,14 @@ import { | ... | @@ -7,9 +7,14 @@ import { |
| 7 | type TerminalMetadataStreamEvent, | 7 | type TerminalMetadataStreamEvent, |
| 8 | type TerminalOpenInput, | 8 | type TerminalOpenInput, |
| 9 | type TerminalRestartInput, | 9 | type TerminalRestartInput, |
| 10 | ProviderDriverKind, | ||
| 11 | ProviderInstanceId, | ||
| 12 | ServerSettingsError, | ||
| 13 | TerminalProviderInstanceNotFoundError, | ||
| 10 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 11 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 12 | import * as Data from "effect/Data"; | 16 | import * as Data from "effect/Data"; |
| 17 | import * as Deferred from "effect/Deferred"; | ||
| 13 | import * as Duration from "effect/Duration"; | 18 | import * as Duration from "effect/Duration"; |
| 14 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 15 | import * as Encoding from "effect/Encoding"; | 20 | import * as Encoding from "effect/Encoding"; |
| ... | @@ -23,11 +28,16 @@ import * as Path from "effect/Path"; | ... | @@ -23,11 +28,16 @@ import * as Path from "effect/Path"; |
| 23 | import * as Ref from "effect/Ref"; | 28 | import * as Ref from "effect/Ref"; |
| 24 | import * as Schedule from "effect/Schedule"; | 29 | import * as Schedule from "effect/Schedule"; |
| 25 | import * as Scope from "effect/Scope"; | 30 | import * as Scope from "effect/Scope"; |
| 31 | import * as Stream from "effect/Stream"; | ||
| 26 | import * as TestClock from "effect/testing/TestClock"; | 32 | import * as TestClock from "effect/testing/TestClock"; |
| 27 | import { ChildProcessSpawner } from "effect/unstable/process"; | 33 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 28 | import { expect } from "vite-plus/test"; | 34 | import { expect } from "vite-plus/test"; |
| 29 | 35 | ||
| 36 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 37 | import * as ServerConfig from "../config.ts"; | ||
| 38 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | ||
| 30 | import * as ProcessRunner from "../processRunner.ts"; | 39 | import * as ProcessRunner from "../processRunner.ts"; |
| 40 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 31 | import * as TerminalManager from "./Manager.ts"; | 41 | import * as TerminalManager from "./Manager.ts"; |
| 32 | import * as PtyAdapter from "./PtyAdapter.ts"; | 42 | import * as PtyAdapter from "./PtyAdapter.ts"; |
| 33 | 43 | ||
| ... | @@ -215,6 +225,9 @@ interface CreateManagerOptions { | ... | @@ -215,6 +225,9 @@ interface CreateManagerOptions { |
| 215 | maxRetainedInactiveSessions?: number; | 225 | maxRetainedInactiveSessions?: number; |
| 216 | historyByteLimit?: number; | 226 | historyByteLimit?: number; |
| 217 | ptyAdapter?: FakePtyAdapter; | 227 | ptyAdapter?: FakePtyAdapter; |
| 228 | resolveProviderInstanceEnvironment?: Parameters< | ||
| 229 | typeof TerminalManager.makeWithOptions | ||
| 230 | >[0]["resolveProviderInstanceEnvironment"]; | ||
| 218 | } | 231 | } |
| 219 | 232 | ||
| 220 | interface ManagerFixture { | 233 | interface ManagerFixture { |
| ... | @@ -259,6 +272,9 @@ const createManager = ( | ... | @@ -259,6 +272,9 @@ const createManager = ( |
| 259 | ...(options.maxRetainedInactiveSessions !== undefined | 272 | ...(options.maxRetainedInactiveSessions !== undefined |
| 260 | ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } | 273 | ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } |
| 261 | : {}), | 274 | : {}), |
| 275 | ...(options.resolveProviderInstanceEnvironment !== undefined | ||
| 276 | ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } | ||
| 277 | : {}), | ||
| 262 | }); | 278 | }); |
| 263 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | 279 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); |
| 264 | const unsubscribe = yield* manager.subscribe((event) => | 280 | const unsubscribe = yield* manager.subscribe((event) => |
| ... | @@ -1736,6 +1752,26 @@ it.layer( | ... | @@ -1736,6 +1752,26 @@ it.layer( |
| 1736 | }), | 1752 | }), |
| 1737 | ); | 1753 | ); |
| 1738 | 1754 | ||
| 1755 | it.effect("expands provider home paths passed to setup terminals", () => | ||
| 1756 | Effect.gen(function* () { | ||
| 1757 | const { manager, ptyAdapter } = yield* createManager(5); | ||
| 1758 | |||
| 1759 | yield* manager.open({ | ||
| 1760 | ...openInput(), | ||
| 1761 | env: { | ||
| 1762 | CODEX_HOME: "~/.codex-work", | ||
| 1763 | CLAUDE_CONFIG_DIR: "~/.claude-work", | ||
| 1764 | CUSTOM_ACCOUNT: "~/leave-this-value-alone", | ||
| 1765 | }, | ||
| 1766 | }); | ||
| 1767 | |||
| 1768 | const environment = ptyAdapter.spawnInputs[0]?.env; | ||
| 1769 | expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); | ||
| 1770 | expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); | ||
| 1771 | expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); | ||
| 1772 | }), | ||
| 1773 | ); | ||
| 1774 | |||
| 1739 | it.effect("strips AppImage runtime env from terminal sessions", () => | 1775 | it.effect("strips AppImage runtime env from terminal sessions", () => |
| 1740 | Effect.gen(function* () { | 1776 | Effect.gen(function* () { |
| 1741 | const appDir = "/tmp/.mount_T3Codeabc123"; | 1777 | const appDir = "/tmp/.mount_T3Codeabc123"; |
| ... | @@ -1822,6 +1858,382 @@ it.layer( | ... | @@ -1822,6 +1858,382 @@ it.layer( |
| 1822 | }), | 1858 | }), |
| 1823 | ); | 1859 | ); |
| 1824 | 1860 | ||
| 1861 | it.effect("resolves a provider instance environment before spawning", () => | ||
| 1862 | Effect.gen(function* () { | ||
| 1863 | const providerInstanceId = ProviderInstanceId.make("codex_work"); | ||
| 1864 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1865 | env: { T3CODE_SECRET: "server-only" }, | ||
| 1866 | resolveProviderInstanceEnvironment: (requestedId, env) => | ||
| 1867 | Effect.succeed({ | ||
| 1868 | ...env, | ||
| 1869 | PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", | ||
| 1870 | CODEX_HOME: "/accounts/codex-work", | ||
| 1871 | }), | ||
| 1872 | }); | ||
| 1873 | |||
| 1874 | const snapshot = yield* manager.open( | ||
| 1875 | openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), | ||
| 1876 | ); | ||
| 1877 | |||
| 1878 | expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); | ||
| 1879 | expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); | ||
| 1880 | expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); | ||
| 1881 | expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); | ||
| 1882 | expect(snapshot).not.toHaveProperty("env"); | ||
| 1883 | expect(snapshot).not.toHaveProperty("providerInstanceId"); | ||
| 1884 | }), | ||
| 1885 | ); | ||
| 1886 | |||
| 1887 | it.effect("fails closed when a provider instance is missing", () => | ||
| 1888 | Effect.gen(function* () { | ||
| 1889 | const providerInstanceId = ProviderInstanceId.make("deleted_instance"); | ||
| 1890 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1891 | resolveProviderInstanceEnvironment: (requestedId) => | ||
| 1892 | Effect.fail( | ||
| 1893 | new TerminalProviderInstanceNotFoundError({ | ||
| 1894 | providerInstanceId: ProviderInstanceId.make(requestedId), | ||
| 1895 | }), | ||
| 1896 | ), | ||
| 1897 | }); | ||
| 1898 | |||
| 1899 | const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); | ||
| 1900 | |||
| 1901 | assert.deepStrictEqual( | ||
| 1902 | error, | ||
| 1903 | new TerminalProviderInstanceNotFoundError({ providerInstanceId }), | ||
| 1904 | ); | ||
| 1905 | expect(ptyAdapter.spawnInputs).toHaveLength(0); | ||
| 1906 | }), | ||
| 1907 | ); | ||
| 1908 | |||
| 1909 | it.effect("preserves the settings failure when provider environment resolution fails", () => | ||
| 1910 | Effect.gen(function* () { | ||
| 1911 | const path = yield* Path.Path; | ||
| 1912 | const providerInstanceId = ProviderInstanceId.make("codex_work"); | ||
| 1913 | const settingsCause = new Error("secret store read failed"); | ||
| 1914 | const settingsError = new ServerSettingsError({ | ||
| 1915 | settingsPath: "/test/settings.json", | ||
| 1916 | operation: "read-secret", | ||
| 1917 | providerInstanceId, | ||
| 1918 | environmentVariable: "OPENROUTER_API_KEY", | ||
| 1919 | cause: settingsCause, | ||
| 1920 | }); | ||
| 1921 | const serverSettings = ServerSettings.ServerSettingsService.of({ | ||
| 1922 | start: Effect.void, | ||
| 1923 | ready: Effect.void, | ||
| 1924 | getSettings: Effect.fail(settingsError), | ||
| 1925 | updateSettings: () => Effect.fail(settingsError), | ||
| 1926 | streamChanges: Stream.empty, | ||
| 1927 | subscribeChanges: Effect.succeed(Stream.empty), | ||
| 1928 | }); | ||
| 1929 | |||
| 1930 | const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 1931 | serverSettings, | ||
| 1932 | path, | ||
| 1933 | rawProviderInstanceId: providerInstanceId, | ||
| 1934 | env: undefined, | ||
| 1935 | }).pipe(Effect.flip); | ||
| 1936 | |||
| 1937 | expect(error).toMatchObject({ | ||
| 1938 | _tag: "TerminalProviderEnvironmentError", | ||
| 1939 | providerInstanceId, | ||
| 1940 | }); | ||
| 1941 | expect(error.cause).toBe(settingsError); | ||
| 1942 | expect(error.message).not.toContain(settingsError.message); | ||
| 1943 | expect(error.message).not.toContain("OPENROUTER_API_KEY"); | ||
| 1944 | }), | ||
| 1945 | ); | ||
| 1946 | |||
| 1947 | it.effect.each([ | ||
| 1948 | { | ||
| 1949 | name: "Codex home", | ||
| 1950 | driver: "codex", | ||
| 1951 | variable: "CODEX_HOME", | ||
| 1952 | config: { homePath: "/configured/codex" }, | ||
| 1953 | expectedHome: "/configured/codex", | ||
| 1954 | }, | ||
| 1955 | { | ||
| 1956 | name: "Codex shadow home", | ||
| 1957 | driver: "codex", | ||
| 1958 | variable: "CODEX_HOME", | ||
| 1959 | config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, | ||
| 1960 | expectedHome: "/configured/codex-shadow", | ||
| 1961 | }, | ||
| 1962 | { | ||
| 1963 | name: "Claude home", | ||
| 1964 | driver: "claudeAgent", | ||
| 1965 | variable: "CLAUDE_CONFIG_DIR", | ||
| 1966 | config: { homePath: "/configured/claude" }, | ||
| 1967 | expectedHome: "/configured/claude", | ||
| 1968 | }, | ||
| 1969 | ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => | ||
| 1970 | Effect.gen(function* () { | ||
| 1971 | const path = yield* Path.Path; | ||
| 1972 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 1973 | const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 1974 | serverSettings, | ||
| 1975 | path, | ||
| 1976 | rawProviderInstanceId: "configured_home", | ||
| 1977 | env: undefined, | ||
| 1978 | }); | ||
| 1979 | |||
| 1980 | expect(environment[variable]).toBe(path.resolve(expectedHome)); | ||
| 1981 | }).pipe( | ||
| 1982 | Effect.provide( | ||
| 1983 | ServerSettings.layerTest({ | ||
| 1984 | providerInstances: { | ||
| 1985 | [ProviderInstanceId.make("configured_home")]: { | ||
| 1986 | driver: ProviderDriverKind.make(driver), | ||
| 1987 | environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], | ||
| 1988 | config, | ||
| 1989 | }, | ||
| 1990 | }, | ||
| 1991 | }), | ||
| 1992 | ), | ||
| 1993 | ), | ||
| 1994 | ); | ||
| 1995 | |||
| 1996 | it.effect("resolves the legacy Codex default instance", () => | ||
| 1997 | Effect.gen(function* () { | ||
| 1998 | const path = yield* Path.Path; | ||
| 1999 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 2000 | const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 2001 | serverSettings, | ||
| 2002 | path, | ||
| 2003 | rawProviderInstanceId: "codex", | ||
| 2004 | env: undefined, | ||
| 2005 | }); | ||
| 2006 | |||
| 2007 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); | ||
| 2008 | }).pipe( | ||
| 2009 | Effect.provide( | ||
| 2010 | ServerSettings.ServerSettingsService.layerTest({ | ||
| 2011 | providerInstances: {}, | ||
| 2012 | providers: { codex: { homePath: "~/.codex-legacy" } }, | ||
| 2013 | }), | ||
| 2014 | ), | ||
| 2015 | ), | ||
| 2016 | ); | ||
| 2017 | |||
| 2018 | it.effect("resolves the legacy Claude default instance", () => | ||
| 2019 | Effect.gen(function* () { | ||
| 2020 | const path = yield* Path.Path; | ||
| 2021 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 2022 | const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 2023 | serverSettings, | ||
| 2024 | path, | ||
| 2025 | rawProviderInstanceId: "claudeAgent", | ||
| 2026 | env: undefined, | ||
| 2027 | }); | ||
| 2028 | |||
| 2029 | expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); | ||
| 2030 | }).pipe( | ||
| 2031 | Effect.provide( | ||
| 2032 | ServerSettings.ServerSettingsService.layerTest({ | ||
| 2033 | providerInstances: {}, | ||
| 2034 | providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, | ||
| 2035 | }), | ||
| 2036 | ), | ||
| 2037 | ), | ||
| 2038 | ); | ||
| 2039 | |||
| 2040 | it.effect("prefers an explicit default instance over legacy provider settings", () => | ||
| 2041 | Effect.gen(function* () { | ||
| 2042 | const path = yield* Path.Path; | ||
| 2043 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 2044 | const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 2045 | serverSettings, | ||
| 2046 | path, | ||
| 2047 | rawProviderInstanceId: "codex", | ||
| 2048 | env: undefined, | ||
| 2049 | }); | ||
| 2050 | |||
| 2051 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); | ||
| 2052 | }).pipe( | ||
| 2053 | Effect.provide( | ||
| 2054 | ServerSettings.ServerSettingsService.layerTest({ | ||
| 2055 | providers: { codex: { homePath: "~/.codex-legacy" } }, | ||
| 2056 | providerInstances: { | ||
| 2057 | [ProviderInstanceId.make("codex")]: { | ||
| 2058 | driver: "codex", | ||
| 2059 | config: { homePath: "~/.codex-explicit" }, | ||
| 2060 | }, | ||
| 2061 | }, | ||
| 2062 | }), | ||
| 2063 | ), | ||
| 2064 | ), | ||
| 2065 | ); | ||
| 2066 | |||
| 2067 | it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => | ||
| 2068 | Effect.gen(function* () { | ||
| 2069 | const path = yield* Path.Path; | ||
| 2070 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 2071 | const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 2072 | serverSettings, | ||
| 2073 | path, | ||
| 2074 | rawProviderInstanceId: "codex_unknown", | ||
| 2075 | env: undefined, | ||
| 2076 | }).pipe(Effect.flip); | ||
| 2077 | |||
| 2078 | expect(error).toMatchObject({ | ||
| 2079 | _tag: "TerminalProviderInstanceNotFoundError", | ||
| 2080 | providerInstanceId: "codex_unknown", | ||
| 2081 | }); | ||
| 2082 | }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), | ||
| 2083 | ); | ||
| 2084 | |||
| 2085 | it.effect("restarts a running terminal when the resolved provider environment changes", () => | ||
| 2086 | Effect.gen(function* () { | ||
| 2087 | const providerInstanceId = ProviderInstanceId.make("codex_work"); | ||
| 2088 | let providerSecret = "first-secret"; | ||
| 2089 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 2090 | resolveProviderInstanceEnvironment: () => | ||
| 2091 | Effect.succeed({ PROVIDER_SECRET: providerSecret }), | ||
| 2092 | }); | ||
| 2093 | |||
| 2094 | yield* manager.open(openInput({ providerInstanceId })); | ||
| 2095 | providerSecret = "second-secret"; | ||
| 2096 | yield* manager.open(openInput({ providerInstanceId })); | ||
| 2097 | |||
| 2098 | expect(ptyAdapter.processes[0]?.killed).toBe(true); | ||
| 2099 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 2100 | expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); | ||
| 2101 | }), | ||
| 2102 | ); | ||
| 2103 | |||
| 2104 | it.effect("restarts with current provider secrets and clears bounded history", () => | ||
| 2105 | Effect.gen(function* () { | ||
| 2106 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 2107 | const path = yield* Path.Path; | ||
| 2108 | const providerInstanceId = ProviderInstanceId.make("codex_restart"); | ||
| 2109 | const { manager, ptyAdapter, logsDir } = yield* createManager(2, { | ||
| 2110 | historyByteLimit: 8, | ||
| 2111 | resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => | ||
| 2112 | TerminalManager.resolveProviderInstanceTerminalEnvironment({ | ||
| 2113 | serverSettings, | ||
| 2114 | path, | ||
| 2115 | rawProviderInstanceId, | ||
| 2116 | env, | ||
| 2117 | }), | ||
| 2118 | }); | ||
| 2119 | const homePath = path.join(logsDir, "codex"); | ||
| 2120 | const updateSecret = (value: string) => | ||
| 2121 | serverSettings.updateSettings({ | ||
| 2122 | providerInstances: { | ||
| 2123 | [providerInstanceId]: { | ||
| 2124 | driver: ProviderDriverKind.make("codex"), | ||
| 2125 | config: { homePath }, | ||
| 2126 | environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], | ||
| 2127 | }, | ||
| 2128 | }, | ||
| 2129 | }); | ||
| 2130 | const input = { | ||
| 2131 | providerInstanceId, | ||
| 2132 | env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, | ||
| 2133 | }; | ||
| 2134 | const outputProcessed = yield* Deferred.make<void>(); | ||
| 2135 | const unsubscribe = yield* manager.subscribe((event) => | ||
| 2136 | event.type === "output" | ||
| 2137 | ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) | ||
| 2138 | : Effect.void, | ||
| 2139 | ); | ||
| 2140 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 2141 | |||
| 2142 | yield* updateSecret("first-secret"); | ||
| 2143 | yield* manager.restart(restartInput(input)); | ||
| 2144 | const firstProcess = ptyAdapter.processes[0]!; | ||
| 2145 | expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); | ||
| 2146 | firstProcess.emitData("discarded\nold-one\nold-two\n"); | ||
| 2147 | yield* Deferred.await(outputProcessed); | ||
| 2148 | expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); | ||
| 2149 | |||
| 2150 | yield* updateSecret("second-secret"); | ||
| 2151 | const restarted = yield* manager.restart(restartInput(input)); | ||
| 2152 | |||
| 2153 | expect(firstProcess.killed).toBe(true); | ||
| 2154 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 2155 | expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ | ||
| 2156 | PROVIDER_SECRET: "second-secret", | ||
| 2157 | CODEX_HOME: homePath, | ||
| 2158 | CLIENT_FLAG: "1", | ||
| 2159 | }); | ||
| 2160 | expect(restarted.history).toBe(""); | ||
| 2161 | expect(restarted.status).toBe("running"); | ||
| 2162 | expect(restarted).not.toHaveProperty("env"); | ||
| 2163 | expect(restarted).not.toHaveProperty("providerInstanceId"); | ||
| 2164 | const logPath = yield* historyLogPath(logsDir); | ||
| 2165 | expect(yield* readFileString(logPath)).toBe(""); | ||
| 2166 | |||
| 2167 | ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); | ||
| 2168 | yield* manager.close({ threadId: "thread-1" }); | ||
| 2169 | expect(yield* readFileString(logPath)).toBe("new-two\n"); | ||
| 2170 | }).pipe( | ||
| 2171 | Effect.provide( | ||
| 2172 | ServerSettings.layer.pipe( | ||
| 2173 | Layer.provide(ServerSecretStore.layer), | ||
| 2174 | Layer.provide(SqlitePersistenceMemory), | ||
| 2175 | Layer.provide( | ||
| 2176 | ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), | ||
| 2177 | ), | ||
| 2178 | ), | ||
| 2179 | ), | ||
| 2180 | ), | ||
| 2181 | ); | ||
| 2182 | |||
| 2183 | it.effect("attaches to a running provider terminal without resolving the provider again", () => | ||
| 2184 | Effect.gen(function* () { | ||
| 2185 | const providerInstanceId = ProviderInstanceId.make("codex_work"); | ||
| 2186 | let providerAvailable = true; | ||
| 2187 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 2188 | resolveProviderInstanceEnvironment: (requestedId) => | ||
| 2189 | providerAvailable | ||
| 2190 | ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) | ||
| 2191 | : Effect.fail( | ||
| 2192 | new TerminalProviderInstanceNotFoundError({ | ||
| 2193 | providerInstanceId: ProviderInstanceId.make(requestedId), | ||
| 2194 | }), | ||
| 2195 | ), | ||
| 2196 | }); | ||
| 2197 | yield* manager.open(openInput({ providerInstanceId })); | ||
| 2198 | providerAvailable = false; | ||
| 2199 | const events: TerminalAttachStreamEvent[] = []; | ||
| 2200 | |||
| 2201 | const unsubscribe = yield* manager.attachStream( | ||
| 2202 | { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, | ||
| 2203 | (event) => Effect.sync(() => events.push(event)), | ||
| 2204 | ); | ||
| 2205 | unsubscribe(); | ||
| 2206 | |||
| 2207 | expect(events[0]?.type).toBe("snapshot"); | ||
| 2208 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 2209 | expect(ptyAdapter.processes[0]?.killed).toBe(false); | ||
| 2210 | }), | ||
| 2211 | ); | ||
| 2212 | |||
| 2213 | it.effect("fails closed when attaching would create a missing provider terminal", () => | ||
| 2214 | Effect.gen(function* () { | ||
| 2215 | const providerInstanceId = ProviderInstanceId.make("deleted_instance"); | ||
| 2216 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 2217 | resolveProviderInstanceEnvironment: (requestedId) => | ||
| 2218 | Effect.fail( | ||
| 2219 | new TerminalProviderInstanceNotFoundError({ | ||
| 2220 | providerInstanceId: ProviderInstanceId.make(requestedId), | ||
| 2221 | }), | ||
| 2222 | ), | ||
| 2223 | }); | ||
| 2224 | |||
| 2225 | const error = yield* manager | ||
| 2226 | .attachStream(openInput({ providerInstanceId }), () => Effect.void) | ||
| 2227 | .pipe(Effect.flip); | ||
| 2228 | |||
| 2229 | assert.deepStrictEqual( | ||
| 2230 | error, | ||
| 2231 | new TerminalProviderInstanceNotFoundError({ providerInstanceId }), | ||
| 2232 | ); | ||
| 2233 | expect(ptyAdapter.spawnInputs).toHaveLength(0); | ||
| 2234 | }), | ||
| 2235 | ); | ||
| 2236 | |||
| 1825 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => | 2237 | it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => |
| 1826 | Effect.gen(function* () { | 2238 | Effect.gen(function* () { |
| 1827 | if ((yield* HostProcessPlatform) === "win32") return; | 2239 | if ((yield* HostProcessPlatform) === "win32") return; |
apps/server/src/terminal/Manager.ts+182-79| ... | @@ -15,6 +15,8 @@ import { | ... | @@ -15,6 +15,8 @@ import { |
| 15 | TerminalError, | 15 | TerminalError, |
| 16 | TerminalHistoryError, | 16 | TerminalHistoryError, |
| 17 | TerminalNotRunningError, | 17 | TerminalNotRunningError, |
| 18 | TerminalProviderInstanceNotFoundError, | ||
| 19 | TerminalProviderEnvironmentError, | ||
| 18 | TerminalResizeError, | 20 | TerminalResizeError, |
| 19 | TerminalSessionLookupError, | 21 | TerminalSessionLookupError, |
| 20 | TerminalWriteError, | 22 | TerminalWriteError, |
| ... | @@ -31,6 +33,9 @@ import { | ... | @@ -31,6 +33,9 @@ import { |
| 31 | type TerminalSessionStatus, | 33 | type TerminalSessionStatus, |
| 32 | type TerminalSummary, | 34 | type TerminalSummary, |
| 33 | type TerminalWriteInput, | 35 | type TerminalWriteInput, |
| 36 | ClaudeSettings, | ||
| 37 | CodexSettings, | ||
| 38 | ProviderInstanceId, | ||
| 34 | } from "@t3tools/contracts"; | 39 | } from "@t3tools/contracts"; |
| 35 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | 40 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 36 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 41 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| ... | @@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore"; |
| 52 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 57 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 53 | 58 | ||
| 54 | import * as ServerConfig from "../config.ts"; | 59 | import * as ServerConfig from "../config.ts"; |
| 60 | import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; | ||
| 61 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | ||
| 62 | import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; | ||
| 63 | import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; | ||
| 64 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 55 | import { | 65 | import { |
| 56 | increment, | 66 | increment, |
| 57 | terminalRestartsTotal, | 67 | terminalRestartsTotal, |
| 58 | terminalSessionsTotal, | 68 | terminalSessionsTotal, |
| 59 | } from "../observability/Metrics.ts"; | 69 | } from "../observability/Metrics.ts"; |
| 70 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 60 | import * as ProcessRunner from "../processRunner.ts"; | 71 | import * as ProcessRunner from "../processRunner.ts"; |
| 61 | import * as PortScanner from "../preview/PortScanner.ts"; | 72 | import * as PortScanner from "../preview/PortScanner.ts"; |
| 62 | import * as PtyAdapter from "./PtyAdapter.ts"; | 73 | import * as PtyAdapter from "./PtyAdapter.ts"; |
| ... | @@ -69,6 +80,8 @@ export { | ... | @@ -69,6 +80,8 @@ export { |
| 69 | TerminalError, | 80 | TerminalError, |
| 70 | TerminalHistoryError, | 81 | TerminalHistoryError, |
| 71 | TerminalNotRunningError, | 82 | TerminalNotRunningError, |
| 83 | TerminalProviderInstanceNotFoundError, | ||
| 84 | TerminalProviderEnvironmentError, | ||
| 72 | TerminalResizeError, | 85 | TerminalResizeError, |
| 73 | TerminalSessionLookupError, | 86 | TerminalSessionLookupError, |
| 74 | TerminalWriteError, | 87 | TerminalWriteError, |
| ... | @@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30; | ... | @@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30; |
| 86 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | 99 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| 87 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 100 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 88 | const MAX_TERMINAL_LABEL_LENGTH = 128; | 101 | const MAX_TERMINAL_LABEL_LENGTH = 128; |
| 102 | const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); | ||
| 103 | const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); | ||
| 89 | 104 | ||
| 90 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( | 105 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( |
| 91 | "TerminalSubprocessCheckError", | 106 | "TerminalSubprocessCheckError", |
| ... | @@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv( | ... | @@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv( |
| 1267 | } | 1282 | } |
| 1268 | if (runtimeEnv) { | 1283 | if (runtimeEnv) { |
| 1269 | for (const [key, value] of Object.entries(runtimeEnv)) { | 1284 | for (const [key, value] of Object.entries(runtimeEnv)) { |
| 1270 | spawnEnv[key] = value; | 1285 | spawnEnv[key] = |
| 1286 | key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; | ||
| 1271 | } | 1287 | } |
| 1272 | } | 1288 | } |
| 1273 | // Both PTY backends feed truecolor-capable terminal clients. | 1289 | // Both PTY backends feed truecolor-capable terminal clients. |
| ... | @@ -1306,17 +1322,78 @@ interface TerminalManagerOptions { | ... | @@ -1306,17 +1322,78 @@ interface TerminalManagerOptions { |
| 1306 | readonly threadId: string; | 1322 | readonly threadId: string; |
| 1307 | readonly terminalId: string; | 1323 | readonly terminalId: string; |
| 1308 | }) => Effect.Effect<void>; | 1324 | }) => Effect.Effect<void>; |
| 1325 | resolveProviderInstanceEnvironment?: ( | ||
| 1326 | providerInstanceId: string, | ||
| 1327 | env: Record<string, string> | undefined, | ||
| 1328 | ) => Effect.Effect< | ||
| 1329 | Record<string, string>, | ||
| 1330 | TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError | ||
| 1331 | >; | ||
| 1309 | } | 1332 | } |
| 1310 | 1333 | ||
| 1334 | export const resolveProviderInstanceTerminalEnvironment = Effect.fn( | ||
| 1335 | "terminal.resolveProviderInstanceTerminalEnvironment", | ||
| 1336 | )(function* (input: { | ||
| 1337 | readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; | ||
| 1338 | readonly path: Path.Path; | ||
| 1339 | readonly rawProviderInstanceId: string; | ||
| 1340 | readonly env: Record<string, string> | undefined; | ||
| 1341 | }) { | ||
| 1342 | const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); | ||
| 1343 | const settings = yield* input.serverSettings.getSettings.pipe( | ||
| 1344 | Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), | ||
| 1345 | ); | ||
| 1346 | const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; | ||
| 1347 | if (instance === undefined) { | ||
| 1348 | return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); | ||
| 1349 | } | ||
| 1350 | |||
| 1351 | let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); | ||
| 1352 | if (instance.driver === "codex") { | ||
| 1353 | const config = decodeCodexSettings(instance.config ?? {}); | ||
| 1354 | if (Option.isSome(config)) { | ||
| 1355 | const layout = yield* resolveCodexHomeLayout(config.value).pipe( | ||
| 1356 | Effect.provideService(Path.Path, input.path), | ||
| 1357 | ); | ||
| 1358 | if (layout.effectiveHomePath) | ||
| 1359 | resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; | ||
| 1360 | } | ||
| 1361 | } else if (instance.driver === "claudeAgent") { | ||
| 1362 | const config = decodeClaudeSettings(instance.config ?? {}); | ||
| 1363 | if (Option.isSome(config)) { | ||
| 1364 | resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( | ||
| 1365 | Effect.provideService(Path.Path, input.path), | ||
| 1366 | ); | ||
| 1367 | } | ||
| 1368 | } | ||
| 1369 | |||
| 1370 | return Object.fromEntries( | ||
| 1371 | Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), | ||
| 1372 | ); | ||
| 1373 | }); | ||
| 1374 | |||
| 1311 | export const make = Effect.fn("TerminalManager.make")(function* () { | 1375 | export const make = Effect.fn("TerminalManager.make")(function* () { |
| 1312 | const { terminalLogsDir } = yield* ServerConfig.ServerConfig; | 1376 | const { terminalLogsDir } = yield* ServerConfig.ServerConfig; |
| 1313 | const ptyAdapter = yield* PtyAdapter.PtyAdapter; | 1377 | const ptyAdapter = yield* PtyAdapter.PtyAdapter; |
| 1314 | const portDiscovery = yield* PortScanner.PortDiscovery; | 1378 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 1379 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 1380 | const path = yield* Path.Path; | ||
| 1381 | const resolveProviderInstanceEnvironment = Effect.fn( | ||
| 1382 | "terminal.resolveProviderInstanceEnvironment", | ||
| 1383 | )((rawProviderInstanceId: string, env: Record<string, string> | undefined) => | ||
| 1384 | resolveProviderInstanceTerminalEnvironment({ | ||
| 1385 | serverSettings, | ||
| 1386 | path, | ||
| 1387 | rawProviderInstanceId, | ||
| 1388 | env, | ||
| 1389 | }), | ||
| 1390 | ); | ||
| 1315 | return yield* makeWithOptions({ | 1391 | return yield* makeWithOptions({ |
| 1316 | logsDir: terminalLogsDir, | 1392 | logsDir: terminalLogsDir, |
| 1317 | ptyAdapter, | 1393 | ptyAdapter, |
| 1318 | registerTerminalProcesses: portDiscovery.registerTerminalProcesses, | 1394 | registerTerminalProcesses: portDiscovery.registerTerminalProcesses, |
| 1319 | unregisterTerminal: portDiscovery.unregisterTerminal, | 1395 | unregisterTerminal: portDiscovery.unregisterTerminal, |
| 1396 | resolveProviderInstanceEnvironment, | ||
| 1320 | }); | 1397 | }); |
| 1321 | }); | 1398 | }); |
| 1322 | 1399 | ||
| ... | @@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1339 | const baseEnv = options.env ?? process.env; | 1416 | const baseEnv = options.env ?? process.env; |
| 1340 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | 1417 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 1341 | const processRunner = yield* ProcessRunner.ProcessRunner; | 1418 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 1419 | const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( | ||
| 1420 | function* <Input extends TerminalOpenInput | TerminalAttachInput | TerminalRestartInput>( | ||
| 1421 | input: Input, | ||
| 1422 | ): Effect.fn.Return< | ||
| 1423 | Input, | ||
| 1424 | TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError | ||
| 1425 | > { | ||
| 1426 | if (input.providerInstanceId === undefined) return input; | ||
| 1427 | const resolver = options.resolveProviderInstanceEnvironment; | ||
| 1428 | if (resolver === undefined) { | ||
| 1429 | return yield* new TerminalProviderInstanceNotFoundError({ | ||
| 1430 | providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), | ||
| 1431 | }); | ||
| 1432 | } | ||
| 1433 | const env = yield* resolver(input.providerInstanceId, input.env); | ||
| 1434 | return { ...input, env }; | ||
| 1435 | }, | ||
| 1436 | ); | ||
| 1342 | // One process-table snapshot per poll tick, shared across every terminal. | 1437 | // One process-table snapshot per poll tick, shared across every terminal. |
| 1343 | // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and | 1438 | // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and |
| 1344 | // can exhaust the PID space on hosts with many sessions (#6332). | 1439 | // can exhaust the PID space on hosts with many sessions (#6332). |
| ... | @@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2468 | }); | 2563 | }); |
| 2469 | 2564 | ||
| 2470 | const open: TerminalManager["Service"]["open"] = (input) => | 2565 | const open: TerminalManager["Service"]["open"] = (input) => |
| 2471 | withThreadLock(input.threadId, openLocked(input)); | 2566 | withThreadLock( |
| 2567 | input.threadId, | ||
| 2568 | resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), | ||
| 2569 | ); | ||
| 2472 | 2570 | ||
| 2473 | const openOrAttachForStream = (input: TerminalAttachInput) => | 2571 | const openOrAttachForStream = (input: TerminalAttachInput) => |
| 2474 | withThreadLock( | 2572 | withThreadLock( |
| ... | @@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2485 | }); | 2583 | }); |
| 2486 | } | 2584 | } |
| 2487 | 2585 | ||
| 2488 | return yield* openLocked({ | 2586 | const resolvedInput = yield* resolveLaunchInputEnvironment({ |
| 2489 | ...input, | 2587 | ...input, |
| 2490 | terminalId, | 2588 | terminalId, |
| 2491 | cwd: input.cwd, | 2589 | cwd: input.cwd, |
| 2492 | }); | 2590 | }); |
| 2591 | return yield* openLocked(resolvedInput); | ||
| 2493 | } | 2592 | } |
| 2494 | 2593 | ||
| 2495 | const session = existing.value; | 2594 | const session = existing.value; |
| ... | @@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2497 | const targetRows = input.rows ?? session.rows; | 2596 | const targetRows = input.rows ?? session.rows; |
| 2498 | 2597 | ||
| 2499 | if (!session.process && input.cwd && input.restartIfNotRunning === true) { | 2598 | if (!session.process && input.cwd && input.restartIfNotRunning === true) { |
| 2500 | return yield* openLocked({ | 2599 | const resolvedInput = yield* resolveLaunchInputEnvironment({ |
| 2501 | ...input, | 2600 | ...input, |
| 2502 | terminalId, | 2601 | terminalId, |
| 2503 | cwd: input.cwd, | 2602 | cwd: input.cwd, |
| 2504 | }); | 2603 | }); |
| 2604 | return yield* openLocked(resolvedInput); | ||
| 2505 | } | 2605 | } |
| 2506 | 2606 | ||
| 2507 | if ( | 2607 | if ( |
| ... | @@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2753 | }), | 2853 | }), |
| 2754 | ); | 2854 | ); |
| 2755 | 2855 | ||
| 2856 | const restartResolved = (input: TerminalRestartInput) => | ||
| 2857 | Effect.gen(function* () { | ||
| 2858 | yield* increment(terminalRestartsTotal, { scope: "thread" }); | ||
| 2859 | const terminalId = input.terminalId; | ||
| 2860 | yield* assertValidCwd(input.cwd); | ||
| 2861 | |||
| 2862 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 2863 | const existingSession = yield* getSession(input.threadId, terminalId); | ||
| 2864 | let session: TerminalSessionState; | ||
| 2865 | if (Option.isNone(existingSession)) { | ||
| 2866 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 2867 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 2868 | session = { | ||
| 2869 | threadId: input.threadId, | ||
| 2870 | terminalId, | ||
| 2871 | cwd: input.cwd, | ||
| 2872 | worktreePath: input.worktreePath ?? null, | ||
| 2873 | status: "starting", | ||
| 2874 | pid: null, | ||
| 2875 | history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), | ||
| 2876 | pendingHistoryControlSequence: "", | ||
| 2877 | pendingProcessEvents: [], | ||
| 2878 | pendingProcessEventIndex: 0, | ||
| 2879 | processEventDrainRunning: false, | ||
| 2880 | exitCode: null, | ||
| 2881 | exitSignal: null, | ||
| 2882 | updatedAt: yield* nowIso, | ||
| 2883 | eventSequence: 0, | ||
| 2884 | cols, | ||
| 2885 | rows, | ||
| 2886 | process: null, | ||
| 2887 | unsubscribeData: null, | ||
| 2888 | unsubscribeExit: null, | ||
| 2889 | hasRunningSubprocess: false, | ||
| 2890 | childCommandLabel: null, | ||
| 2891 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 2892 | }; | ||
| 2893 | const createdSession = session; | ||
| 2894 | yield* modifyManagerState((state) => { | ||
| 2895 | const sessions = new Map(state.sessions); | ||
| 2896 | sessions.set(sessionKey, createdSession); | ||
| 2897 | return [undefined, { ...state, sessions }] as const; | ||
| 2898 | }); | ||
| 2899 | yield* evictInactiveSessionsIfNeeded(); | ||
| 2900 | } else { | ||
| 2901 | session = existingSession.value; | ||
| 2902 | yield* stopProcess(session); | ||
| 2903 | session.cwd = input.cwd; | ||
| 2904 | session.worktreePath = input.worktreePath ?? null; | ||
| 2905 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | ||
| 2906 | } | ||
| 2907 | |||
| 2908 | const cols = input.cols ?? session.cols; | ||
| 2909 | const rows = input.rows ?? session.rows; | ||
| 2910 | |||
| 2911 | session.history.clear(); | ||
| 2912 | session.pendingHistoryControlSequence = ""; | ||
| 2913 | session.pendingProcessEvents = []; | ||
| 2914 | session.pendingProcessEventIndex = 0; | ||
| 2915 | session.processEventDrainRunning = false; | ||
| 2916 | yield* persistHistory(input.threadId, terminalId, session.history); | ||
| 2917 | yield* startSession( | ||
| 2918 | session, | ||
| 2919 | { | ||
| 2920 | threadId: input.threadId, | ||
| 2921 | terminalId, | ||
| 2922 | cwd: input.cwd, | ||
| 2923 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 2924 | cols, | ||
| 2925 | rows, | ||
| 2926 | ...(input.env ? { env: input.env } : {}), | ||
| 2927 | }, | ||
| 2928 | "restarted", | ||
| 2929 | ); | ||
| 2930 | return snapshot(session); | ||
| 2931 | }); | ||
| 2932 | |||
| 2756 | const restart: TerminalManager["Service"]["restart"] = (input) => | 2933 | const restart: TerminalManager["Service"]["restart"] = (input) => |
| 2757 | withThreadLock( | 2934 | withThreadLock( |
| 2758 | input.threadId, | 2935 | input.threadId, |
| 2759 | Effect.gen(function* () { | 2936 | resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), |
| 2760 | yield* increment(terminalRestartsTotal, { scope: "thread" }); | ||
| 2761 | const terminalId = input.terminalId; | ||
| 2762 | yield* assertValidCwd(input.cwd); | ||
| 2763 | |||
| 2764 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 2765 | const existingSession = yield* getSession(input.threadId, terminalId); | ||
| 2766 | let session: TerminalSessionState; | ||
| 2767 | if (Option.isNone(existingSession)) { | ||
| 2768 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 2769 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 2770 | session = { | ||
| 2771 | threadId: input.threadId, | ||
| 2772 | terminalId, | ||
| 2773 | cwd: input.cwd, | ||
| 2774 | worktreePath: input.worktreePath ?? null, | ||
| 2775 | status: "starting", | ||
| 2776 | pid: null, | ||
| 2777 | history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), | ||
| 2778 | pendingHistoryControlSequence: "", | ||
| 2779 | pendingProcessEvents: [], | ||
| 2780 | pendingProcessEventIndex: 0, | ||
| 2781 | processEventDrainRunning: false, | ||
| 2782 | exitCode: null, | ||
| 2783 | exitSignal: null, | ||
| 2784 | updatedAt: yield* nowIso, | ||
| 2785 | eventSequence: 0, | ||
| 2786 | cols, | ||
| 2787 | rows, | ||
| 2788 | process: null, | ||
| 2789 | unsubscribeData: null, | ||
| 2790 | unsubscribeExit: null, | ||
| 2791 | hasRunningSubprocess: false, | ||
| 2792 | childCommandLabel: null, | ||
| 2793 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 2794 | }; | ||
| 2795 | const createdSession = session; | ||
| 2796 | yield* modifyManagerState((state) => { | ||
| 2797 | const sessions = new Map(state.sessions); | ||
| 2798 | sessions.set(sessionKey, createdSession); | ||
| 2799 | return [undefined, { ...state, sessions }] as const; | ||
| 2800 | }); | ||
| 2801 | yield* evictInactiveSessionsIfNeeded(); | ||
| 2802 | } else { | ||
| 2803 | session = existingSession.value; | ||
| 2804 | yield* stopProcess(session); | ||
| 2805 | session.cwd = input.cwd; | ||
| 2806 | session.worktreePath = input.worktreePath ?? null; | ||
| 2807 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | ||
| 2808 | } | ||
| 2809 | |||
| 2810 | const cols = input.cols ?? session.cols; | ||
| 2811 | const rows = input.rows ?? session.rows; | ||
| 2812 | |||
| 2813 | session.history.clear(); | ||
| 2814 | session.pendingHistoryControlSequence = ""; | ||
| 2815 | session.pendingProcessEvents = []; | ||
| 2816 | session.pendingProcessEventIndex = 0; | ||
| 2817 | session.processEventDrainRunning = false; | ||
| 2818 | yield* persistHistory(input.threadId, terminalId, session.history); | ||
| 2819 | yield* startSession( | ||
| 2820 | session, | ||
| 2821 | { | ||
| 2822 | threadId: input.threadId, | ||
| 2823 | terminalId, | ||
| 2824 | cwd: input.cwd, | ||
| 2825 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 2826 | cols, | ||
| 2827 | rows, | ||
| 2828 | ...(input.env ? { env: input.env } : {}), | ||
| 2829 | }, | ||
| 2830 | "restarted", | ||
| 2831 | ); | ||
| 2832 | return snapshot(session); | ||
| 2833 | }), | ||
| 2834 | ); | 2937 | ); |
| 2835 | 2938 | ||
| 2836 | const close: TerminalManager["Service"]["close"] = (input) => | 2939 | const close: TerminalManager["Service"]["close"] = (input) => |
apps/server/src/ws.ts+40-1| ... | @@ -53,6 +53,7 @@ import { | ... | @@ -53,6 +53,7 @@ import { |
| 53 | type RelayClientInstallProgressEvent, | 53 | type RelayClientInstallProgressEvent, |
| 54 | ServerSelfUpdateError, | 54 | ServerSelfUpdateError, |
| 55 | type ServerSelfUpdateProgressEvent, | 55 | type ServerSelfUpdateProgressEvent, |
| 56 | type ServerLifecycleStreamEvent, | ||
| 56 | type FilesystemBrowseFailure, | 57 | type FilesystemBrowseFailure, |
| 57 | FilesystemBrowseError, | 58 | FilesystemBrowseError, |
| 58 | AssetWorkspaceContextNotFoundError, | 59 | AssetWorkspaceContextNotFoundError, |
| ... | @@ -96,6 +97,7 @@ import { | ... | @@ -96,6 +97,7 @@ import { |
| 96 | } from "./observability/RpcInstrumentation.ts"; | 97 | } from "./observability/RpcInstrumentation.ts"; |
| 97 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; | 98 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 98 | import * as ProviderService from "./provider/Services/ProviderService.ts"; | 99 | import * as ProviderService from "./provider/Services/ProviderService.ts"; |
| 100 | import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; | ||
| 99 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | 101 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 100 | import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; | 102 | import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; |
| 101 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; | 103 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; |
| ... | @@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | ... | @@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 119 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 121 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 120 | import * as ReviewService from "./review/ReviewService.ts"; | 122 | import * as ReviewService from "./review/ReviewService.ts"; |
| 121 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; | 123 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 124 | import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; | ||
| 125 | import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; | ||
| 122 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 126 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 123 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; | 127 | import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; |
| 124 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; | 128 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| ... | @@ -512,6 +516,7 @@ const makeWsRpcLayer = ( | ... | @@ -512,6 +516,7 @@ const makeWsRpcLayer = ( |
| 512 | const portDiscovery = yield* PortScanner.PortDiscovery; | 516 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 513 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; | 517 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 514 | const providerService = yield* ProviderService.ProviderService; | 518 | const providerService = yield* ProviderService.ProviderService; |
| 519 | const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | ||
| 515 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | 520 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 516 | const providerAuth = yield* ProviderAuthService; | 521 | const providerAuth = yield* ProviderAuthService; |
| 517 | const providerInstances = yield* ProviderInstanceRegistry; | 522 | const providerInstances = yield* ProviderInstanceRegistry; |
| ... | @@ -560,6 +565,7 @@ const makeWsRpcLayer = ( | ... | @@ -560,6 +565,7 @@ const makeWsRpcLayer = ( |
| 560 | return true; | 565 | return true; |
| 561 | }); | 566 | }); |
| 562 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | 567 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 568 | const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; | ||
| 563 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | 569 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 564 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | 570 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| 565 | const rpcClientIds = yield* Ref.make(new Set<RpcClientId>()); | 571 | const rpcClientIds = yield* Ref.make(new Set<RpcClientId>()); |
| ... | @@ -2332,6 +2338,31 @@ const makeWsRpcLayer = ( | ... | @@ -2332,6 +2338,31 @@ const makeWsRpcLayer = ( |
| 2332 | deletePendingAttachment(input.attachmentId), | 2338 | deletePendingAttachment(input.attachmentId), |
| 2333 | { "rpc.aggregate": "workspace" }, | 2339 | { "rpc.aggregate": "workspace" }, |
| 2334 | ), | 2340 | ), |
| 2341 | [WS_METHODS.agentSessionsScan]: () => | ||
| 2342 | observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { | ||
| 2343 | "rpc.aggregate": "workspace", | ||
| 2344 | }), | ||
| 2345 | [WS_METHODS.agentSessionsImport]: (input) => | ||
| 2346 | observeRpcEffect( | ||
| 2347 | WS_METHODS.agentSessionsImport, | ||
| 2348 | importRecentAgentThreads(input).pipe( | ||
| 2349 | Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), | ||
| 2350 | Effect.provideService( | ||
| 2351 | OrchestrationEngine.OrchestrationEngineService, | ||
| 2352 | orchestrationEngine, | ||
| 2353 | ), | ||
| 2354 | Effect.provideService( | ||
| 2355 | ProjectionSnapshotQuery.ProjectionSnapshotQuery, | ||
| 2356 | projectionSnapshotQuery, | ||
| 2357 | ), | ||
| 2358 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 2359 | Effect.provideService( | ||
| 2360 | ProviderSessionDirectory.ProviderSessionDirectory, | ||
| 2361 | providerSessionDirectory, | ||
| 2362 | ), | ||
| 2363 | ), | ||
| 2364 | { "rpc.aggregate": "workspace" }, | ||
| 2365 | ), | ||
| 2335 | [WS_METHODS.assetsCreateUrl]: (input) => | 2366 | [WS_METHODS.assetsCreateUrl]: (input) => |
| 2336 | observeRpcEffect( | 2367 | observeRpcEffect( |
| 2337 | WS_METHODS.assetsCreateUrl, | 2368 | WS_METHODS.assetsCreateUrl, |
| ... | @@ -2746,11 +2777,18 @@ const makeWsRpcLayer = ( | ... | @@ -2746,11 +2777,18 @@ const makeWsRpcLayer = ( |
| 2746 | observeRpcStreamEffect( | 2777 | observeRpcStreamEffect( |
| 2747 | WS_METHODS.subscribeServerLifecycle, | 2778 | WS_METHODS.subscribeServerLifecycle, |
| 2748 | Effect.gen(function* () { | 2779 | Effect.gen(function* () { |
| 2780 | const liveBuffer = yield* Queue.unbounded<ServerLifecycleStreamEvent>(); | ||
| 2781 | yield* Effect.forkScoped( | ||
| 2782 | lifecycleEvents.stream.pipe( | ||
| 2783 | Stream.runForEach((event) => Queue.offer(liveBuffer, event)), | ||
| 2784 | ), | ||
| 2785 | { startImmediately: true }, | ||
| 2786 | ); | ||
| 2749 | const snapshot = yield* lifecycleEvents.snapshot; | 2787 | const snapshot = yield* lifecycleEvents.snapshot; |
| 2750 | const snapshotEvents = Array.from(snapshot.events).toSorted( | 2788 | const snapshotEvents = Array.from(snapshot.events).toSorted( |
| 2751 | (left, right) => left.sequence - right.sequence, | 2789 | (left, right) => left.sequence - right.sequence, |
| 2752 | ); | 2790 | ); |
| 2753 | const liveEvents = lifecycleEvents.stream.pipe( | 2791 | const liveEvents = Stream.fromQueue(liveBuffer).pipe( |
| 2754 | Stream.filter((event) => event.sequence > snapshot.sequence), | 2792 | Stream.filter((event) => event.sequence > snapshot.sequence), |
| 2755 | ); | 2793 | ); |
| 2756 | return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); | 2794 | return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); |
| ... | @@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2877 | previewAutomationBroker, | 2915 | previewAutomationBroker, |
| 2878 | ).pipe( | 2916 | ).pipe( |
| 2879 | Layer.provideMerge(RpcSerialization.layerJson), | 2917 | Layer.provideMerge(RpcSerialization.layerJson), |
| 2918 | Layer.provide(AgentSessionScanner.layer), | ||
| 2880 | Layer.provide(ProviderMaintenanceRunner.layer), | 2919 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 2881 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), | 2920 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), |
| 2882 | // One server-lifetime service means clients share the same PR caches, and a WS | 2921 | // One server-lifetime service means clients share the same PR caches, and a WS |
apps/web/src/authBootstrap.test.ts+57| ... | @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { |
| 310 | expect(testApi.calls.session).toBe(2); | 310 | expect(testApi.calls.session).toBe(2); |
| 311 | }); | 311 | }); |
| 312 | 312 | ||
| 313 | it("keeps manual token submission pending until the session is authenticated", async () => { | ||
| 314 | vi.useFakeTimers(); | ||
| 315 | let authenticated = false; | ||
| 316 | let settled = false; | ||
| 317 | try { | ||
| 318 | const testApi = await installAuthApi({ | ||
| 319 | session: () => | ||
| 320 | authenticated | ||
| 321 | ? authenticatedSession(LOOPBACK_AUTH) | ||
| 322 | : unauthenticatedSession(LOOPBACK_AUTH), | ||
| 323 | browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), | ||
| 324 | }); | ||
| 325 | const { submitServerAuthCredential } = await import("./environments/primary"); | ||
| 326 | |||
| 327 | const submission = submitServerAuthCredential("retry-token").finally(() => { | ||
| 328 | settled = true; | ||
| 329 | }); | ||
| 330 | await vi.advanceTimersByTimeAsync(0); | ||
| 331 | |||
| 332 | expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); | ||
| 333 | expect(testApi.calls.session).toBe(1); | ||
| 334 | expect(settled).toBe(false); | ||
| 335 | |||
| 336 | authenticated = true; | ||
| 337 | await vi.advanceTimersByTimeAsync(100); | ||
| 338 | await expect(submission).resolves.toBeUndefined(); | ||
| 339 | expect(testApi.calls.session).toBe(2); | ||
| 340 | } finally { | ||
| 341 | vi.useRealTimers(); | ||
| 342 | } | ||
| 343 | }); | ||
| 344 | |||
| 345 | it("fails manual token submission when the session is not established", async () => { | ||
| 346 | vi.useFakeTimers(); | ||
| 347 | try { | ||
| 348 | const testApi = await installAuthApi({ | ||
| 349 | session: () => unauthenticatedSession(LOOPBACK_AUTH), | ||
| 350 | browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), | ||
| 351 | }); | ||
| 352 | const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = | ||
| 353 | await import("./environments/primary/auth"); | ||
| 354 | |||
| 355 | const submission = submitServerAuthCredential("retry-token"); | ||
| 356 | const failure = submission.then( | ||
| 357 | () => null, | ||
| 358 | (error: unknown) => error, | ||
| 359 | ); | ||
| 360 | await vi.advanceTimersByTimeAsync(2_000); | ||
| 361 | |||
| 362 | await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); | ||
| 363 | expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); | ||
| 364 | expect(testApi.calls.session).toBeGreaterThan(1); | ||
| 365 | } finally { | ||
| 366 | vi.useRealTimers(); | ||
| 367 | } | ||
| 368 | }); | ||
| 369 | |||
| 313 | it("rejects a blank pairing token with a structured validation error", async () => { | 370 | it("rejects a blank pairing token with a structured validation error", async () => { |
| 314 | const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = | 371 | const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = |
| 315 | await import("./environments/primary/auth"); | 372 | await import("./environments/primary/auth"); |
apps/web/src/browser/HostedBrowserWebview.test.tsx created+200| ... | @@ -0,0 +1,200 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_CLIENT_SETTINGS, | ||
| 3 | EnvironmentId, | ||
| 4 | FILL_PREVIEW_VIEWPORT, | ||
| 5 | ThreadId, | ||
| 6 | type ClientSettings, | ||
| 7 | type DesktopPreviewBridge, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { act } from "react"; | ||
| 10 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 12 | |||
| 13 | const mocks = vi.hoisted(() => ({ | ||
| 14 | getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(), | ||
| 15 | setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(), | ||
| 16 | createTab: vi.fn<DesktopPreviewBridge["createTab"]>(), | ||
| 17 | closeTab: vi.fn<DesktopPreviewBridge["closeTab"]>(), | ||
| 18 | registerWebview: vi.fn<DesktopPreviewBridge["registerWebview"]>(), | ||
| 19 | getPreviewConfig: vi.fn<DesktopPreviewBridge["getPreviewConfig"]>(), | ||
| 20 | activeRecordings: new Set<string>(), | ||
| 21 | })); | ||
| 22 | |||
| 23 | vi.mock("~/localApi", () => ({ | ||
| 24 | ensureLocalApi: () => ({ persistence: mocks }), | ||
| 25 | })); | ||
| 26 | |||
| 27 | vi.mock("~/components/preview/previewBridge", () => ({ | ||
| 28 | previewBridge: { | ||
| 29 | createTab: mocks.createTab, | ||
| 30 | closeTab: mocks.closeTab, | ||
| 31 | registerWebview: mocks.registerWebview, | ||
| 32 | getPreviewConfig: mocks.getPreviewConfig, | ||
| 33 | }, | ||
| 34 | })); | ||
| 35 | |||
| 36 | vi.mock("~/components/preview/usePreviewBridge", () => ({ | ||
| 37 | usePreviewBridge: () => undefined, | ||
| 38 | })); | ||
| 39 | |||
| 40 | vi.mock("./browserRecording", () => ({ | ||
| 41 | useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, | ||
| 42 | stopBrowserRecording: async () => null, | ||
| 43 | })); | ||
| 44 | |||
| 45 | import { | ||
| 46 | __resetClientSettingsPersistenceForTests, | ||
| 47 | ensureClientSettingsHydrated, | ||
| 48 | } from "~/hooks/useSettings"; | ||
| 49 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | ||
| 50 | import * as desktopTabLifetime from "./desktopTabLifetime"; | ||
| 51 | import { HostedBrowserWebview } from "./HostedBrowserWebview"; | ||
| 52 | |||
| 53 | let renderer: ReactTestRenderer | undefined; | ||
| 54 | |||
| 55 | function deferred<A>() { | ||
| 56 | let resolve!: (value: A) => void; | ||
| 57 | let reject!: (error: unknown) => void; | ||
| 58 | const promise = new Promise<A>((resolvePromise, rejectPromise) => { | ||
| 59 | resolve = resolvePromise; | ||
| 60 | reject = rejectPromise; | ||
| 61 | }); | ||
| 62 | return { promise, resolve, reject }; | ||
| 63 | } | ||
| 64 | |||
| 65 | beforeEach(() => { | ||
| 66 | __resetClientSettingsPersistenceForTests(); | ||
| 67 | useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); | ||
| 68 | mocks.getClientSettings.mockReset(); | ||
| 69 | mocks.setClientSettings.mockReset().mockResolvedValue(undefined); | ||
| 70 | mocks.createTab.mockReset().mockResolvedValue(undefined); | ||
| 71 | mocks.closeTab.mockReset().mockResolvedValue(undefined); | ||
| 72 | mocks.registerWebview.mockReset().mockResolvedValue(undefined); | ||
| 73 | mocks.getPreviewConfig.mockReset().mockResolvedValue({ | ||
| 74 | partition: "persist:t3-preview-work", | ||
| 75 | webPreferences: "contextIsolation=yes", | ||
| 76 | preloadUrl: null, | ||
| 77 | }); | ||
| 78 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 79 | vi.stubGlobal("window", globalThis); | ||
| 80 | vi.stubGlobal("navigator", { platform: "Linux" }); | ||
| 81 | vi.stubGlobal( | ||
| 82 | "requestAnimationFrame", | ||
| 83 | vi.fn(() => 0), | ||
| 84 | ); | ||
| 85 | vi.stubGlobal("cancelAnimationFrame", vi.fn()); | ||
| 86 | vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 87 | }); | ||
| 88 | |||
| 89 | afterEach(async () => { | ||
| 90 | vi.useFakeTimers(); | ||
| 91 | await act(() => renderer?.unmount()); | ||
| 92 | renderer = undefined; | ||
| 93 | await vi.advanceTimersByTimeAsync(0); | ||
| 94 | vi.useRealTimers(); | ||
| 95 | __resetClientSettingsPersistenceForTests(); | ||
| 96 | useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); | ||
| 97 | vi.restoreAllMocks(); | ||
| 98 | vi.unstubAllGlobals(); | ||
| 99 | }); | ||
| 100 | |||
| 101 | describe("HostedBrowserWebview settings hydration", () => { | ||
| 102 | it("starts a retained background tab only after a settings read succeeds on retry", async () => { | ||
| 103 | const firstRead = deferred<ClientSettings | null>(); | ||
| 104 | const retryRead = deferred<ClientSettings | null>(); | ||
| 105 | const tabCreation = deferred<void>(); | ||
| 106 | mocks.getClientSettings | ||
| 107 | .mockReturnValueOnce(firstRead.promise) | ||
| 108 | .mockReturnValueOnce(retryRead.promise); | ||
| 109 | mocks.createTab.mockReturnValueOnce(tabCreation.promise); | ||
| 110 | const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); | ||
| 111 | const createGuest = vi.fn((_attributes: unknown) => | ||
| 112 | Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), | ||
| 113 | ); | ||
| 114 | const threadRef = { | ||
| 115 | environmentId: EnvironmentId.make("host-settings-retry"), | ||
| 116 | threadId: ThreadId.make("thread-settings-retry"), | ||
| 117 | }; | ||
| 118 | const runtimeTabId = "retained-background-tab"; | ||
| 119 | useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); | ||
| 120 | |||
| 121 | await act(() => { | ||
| 122 | renderer = create( | ||
| 123 | <HostedBrowserWebview | ||
| 124 | threadRef={threadRef} | ||
| 125 | tabId="server-tab" | ||
| 126 | runtimeTabId={runtimeTabId} | ||
| 127 | initialUrl="https://example.com" | ||
| 128 | viewport={FILL_PREVIEW_VIEWPORT} | ||
| 129 | pictureInPicture={false} | ||
| 130 | profileId="work" | ||
| 131 | zoomFactor={1.25} | ||
| 132 | />, | ||
| 133 | { | ||
| 134 | createNodeMock: (element) => | ||
| 135 | element.type === "webview" | ||
| 136 | ? createGuest(element.props) | ||
| 137 | : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, | ||
| 138 | }, | ||
| 139 | ); | ||
| 140 | }); | ||
| 141 | |||
| 142 | expect(mocks.getClientSettings).toHaveBeenCalledOnce(); | ||
| 143 | expect(acquire).not.toHaveBeenCalled(); | ||
| 144 | expect(createGuest).not.toHaveBeenCalled(); | ||
| 145 | expect(mocks.createTab).not.toHaveBeenCalled(); | ||
| 146 | |||
| 147 | const failure = new Error("Saved settings are unavailable"); | ||
| 148 | await act(async () => { | ||
| 149 | const hydration = ensureClientSettingsHydrated(); | ||
| 150 | firstRead.reject(failure); | ||
| 151 | await expect(hydration).rejects.toBe(failure); | ||
| 152 | }); | ||
| 153 | expect(acquire).not.toHaveBeenCalled(); | ||
| 154 | expect(createGuest).not.toHaveBeenCalled(); | ||
| 155 | expect(mocks.createTab).not.toHaveBeenCalled(); | ||
| 156 | |||
| 157 | let retry!: Promise<void>; | ||
| 158 | await act(() => { | ||
| 159 | retry = ensureClientSettingsHydrated(); | ||
| 160 | }); | ||
| 161 | expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); | ||
| 162 | expect(acquire).not.toHaveBeenCalled(); | ||
| 163 | expect(createGuest).not.toHaveBeenCalled(); | ||
| 164 | expect(mocks.createTab).not.toHaveBeenCalled(); | ||
| 165 | |||
| 166 | await act(async () => { | ||
| 167 | retryRead.resolve({ | ||
| 168 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 169 | browserDefaultZoomFactor: 1.25, | ||
| 170 | browserDefaultAppearance: "dark", | ||
| 171 | browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], | ||
| 172 | browserDefaultProfileId: "work", | ||
| 173 | }); | ||
| 174 | await retry; | ||
| 175 | }); | ||
| 176 | |||
| 177 | expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); | ||
| 178 | expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); | ||
| 179 | expect(createGuest).toHaveBeenCalledOnce(); | ||
| 180 | expect(createGuest).toHaveBeenCalledWith( | ||
| 181 | expect.objectContaining({ | ||
| 182 | partition: "persist:t3-preview-work", | ||
| 183 | src: "https://example.com", | ||
| 184 | }), | ||
| 185 | ); | ||
| 186 | expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { | ||
| 187 | zoomFactor: 1.25, | ||
| 188 | colorScheme: "dark", | ||
| 189 | }); | ||
| 190 | expect(mocks.registerWebview).not.toHaveBeenCalled(); | ||
| 191 | |||
| 192 | await act(async () => { | ||
| 193 | tabCreation.resolve(); | ||
| 194 | await tabCreation.promise; | ||
| 195 | }); | ||
| 196 | expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); | ||
| 197 | expect(mocks.closeTab).not.toHaveBeenCalled(); | ||
| 198 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); | ||
| 199 | }); | ||
| 200 | }); | ||
apps/web/src/browser/HostedBrowserWebview.tsx+7-4| ... | @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; | ... | @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { previewBridge } from "~/components/preview/previewBridge"; | 7 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { useClientSettingsHydrated } from "~/hooks/useSettings"; | ||
| 9 | import { cn, isMacPlatform } from "~/lib/utils"; | 10 | import { cn, isMacPlatform } from "~/lib/utils"; |
| 10 | 11 | ||
| 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | 12 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| ... | @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { |
| 66 | zoomFactor, | 67 | zoomFactor, |
| 67 | profileId, | 68 | profileId, |
| 68 | } = props; | 69 | } = props; |
| 70 | const clientSettingsHydrated = useClientSettingsHydrated(); | ||
| 69 | const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); | 71 | const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); |
| 70 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); | 72 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); |
| 71 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); | 73 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| ... | @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { |
| 94 | usePreviewBridge({ threadRef, tabId, runtimeTabId }); | 96 | usePreviewBridge({ threadRef, tabId, runtimeTabId }); |
| 95 | 97 | ||
| 96 | useEffect(() => { | 98 | useEffect(() => { |
| 99 | if (!clientSettingsHydrated) return; | ||
| 97 | crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; | 100 | crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; |
| 98 | const lease = acquireDesktopTab(runtimeTabId); | 101 | const lease = acquireDesktopTab(runtimeTabId); |
| 99 | tabLeaseRef.current = lease; | 102 | tabLeaseRef.current = lease; |
| ... | @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { |
| 101 | if (tabLeaseRef.current === lease) tabLeaseRef.current = null; | 104 | if (tabLeaseRef.current === lease) tabLeaseRef.current = null; |
| 102 | lease.release(); | 105 | lease.release(); |
| 103 | }; | 106 | }; |
| 104 | }, [runtimeTabId]); | 107 | }, [clientSettingsHydrated, runtimeTabId]); |
| 105 | 108 | ||
| 106 | const [webviewGeneration, setWebviewGeneration] = useState(0); | 109 | const [webviewGeneration, setWebviewGeneration] = useState(0); |
| 107 | const [recoverySrc, setRecoverySrc] = useState(initialSrc); | 110 | const [recoverySrc, setRecoverySrc] = useState(initialSrc); |
| ... | @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { |
| 118 | useEffect(() => { | 121 | useEffect(() => { |
| 119 | const webview = webviewRef.current; | 122 | const webview = webviewRef.current; |
| 120 | const bridge = previewBridge; | 123 | const bridge = previewBridge; |
| 121 | if (!webview || !config || !bridge) return; | 124 | if (!clientSettingsHydrated || !webview || !config || !bridge) return; |
| 122 | let disposed = false; | 125 | let disposed = false; |
| 123 | let recoveryTimeout: ReturnType<typeof setTimeout> | null = null; | 126 | let recoveryTimeout: ReturnType<typeof setTimeout> | null = null; |
| 124 | const register = () => { | 127 | const register = () => { |
| ... | @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { |
| 164 | webview.removeEventListener("dom-ready", register); | 167 | webview.removeEventListener("dom-ready", register); |
| 165 | webview.removeEventListener("render-process-gone", recoverGuest); | 168 | webview.removeEventListener("render-process-gone", recoverGuest); |
| 166 | }; | 169 | }; |
| 167 | }, [config, initialSrc, runtimeTabId, webviewGeneration]); | 170 | }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); |
| 168 | 171 | ||
| 169 | const active = presentation.visible && presentation.rect !== null; | 172 | const active = presentation.visible && presentation.rect !== null; |
| 170 | const lastRect = presentation.rect; | 173 | const lastRect = presentation.rect; |
| ... | @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { |
| 249 | wrapper.scrollTo({ left: 0, top: 0 }); | 252 | wrapper.scrollTo({ left: 0, top: 0 }); |
| 250 | }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); | 253 | }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); |
| 251 | 254 | ||
| 252 | if (!config) return null; | 255 | if (!clientSettingsHydrated || !config) return null; |
| 253 | 256 | ||
| 254 | const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; | 257 | const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; |
| 255 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ | 258 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
apps/web/src/browser/browserDefaults.test.ts+23-2| ... | @@ -1,15 +1,17 @@ | ... | @@ -1,15 +1,17 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; | 2 | import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; | ||
| 5 | |||
| 4 | const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> })); | 6 | const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> })); |
| 5 | 7 | ||
| 6 | vi.mock("~/hooks/useSettings", () => ({ | 8 | vi.mock("~/hooks/useSettings", () => ({ |
| 7 | getClientSettings: () => settings.current, | 9 | getClientSettings: () => settings.current, |
| 8 | useClientSettings: () => undefined, | 10 | useClientSettings: () => undefined, |
| 9 | ensureClientSettingsHydrated: () => Promise.resolve(), | 11 | ensureClientSettingsHydrated: vi.fn(async () => undefined), |
| 10 | })); | 12 | })); |
| 11 | 13 | ||
| 12 | const { getBrowserDefaults } = await import("./browserDefaults"); | 14 | const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); |
| 13 | 15 | ||
| 14 | const withDefaultProfile = (browserDefaultProfileId: string) => { | 16 | const withDefaultProfile = (browserDefaultProfileId: string) => { |
| 15 | settings.current = { | 17 | settings.current = { |
| ... | @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { | ... | @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { |
| 41 | ); | 43 | ); |
| 42 | }); | 44 | }); |
| 43 | }); | 45 | }); |
| 46 | |||
| 47 | describe("resolveBrowserDefaults", () => { | ||
| 48 | it("rejects failed reads and uses the saved profile after a successful retry", async () => { | ||
| 49 | withDefaultProfile("work"); | ||
| 50 | settings.current.browserDefaultZoomFactor = 1.25; | ||
| 51 | settings.current.browserDefaultAppearance = "dark"; | ||
| 52 | const failure = new Error("Settings read failed"); | ||
| 53 | vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); | ||
| 54 | |||
| 55 | await expect(resolveBrowserDefaults()).rejects.toBe(failure); | ||
| 56 | await expect(resolveBrowserDefaults()).resolves.toMatchObject({ | ||
| 57 | viewport: { _tag: "fill" }, | ||
| 58 | zoomFactor: 1.25, | ||
| 59 | appearance: "dark", | ||
| 60 | autoShowFloatingPreview: true, | ||
| 61 | profileId: "work", | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | }); |
apps/web/src/browser/browserDefaults.ts+1| ... | @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { | ... | @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { |
| 79 | * Opening a preview is asynchronous anyway, and before hydration the snapshot | 79 | * Opening a preview is asynchronous anyway, and before hydration the snapshot |
| 80 | * is the schema defaults rather than the user's — a tab opened in that window | 80 | * is the schema defaults rather than the user's — a tab opened in that window |
| 81 | * would be born at the wrong viewport, zoom and appearance and never corrected. | 81 | * would be born at the wrong viewport, zoom and appearance and never corrected. |
| 82 | * Read failures reject so a new tab cannot use the wrong profile or viewport. | ||
| 82 | */ | 83 | */ |
| 83 | export async function resolveBrowserDefaults(): Promise<BrowserDefaults> { | 84 | export async function resolveBrowserDefaults(): Promise<BrowserDefaults> { |
| 84 | await ensureClientSettingsHydrated(); | 85 | await ensureClientSettingsHydrated(); |
apps/web/src/browser/browserLinkTarget.test.ts+26-2| ... | @@ -1,6 +1,16 @@ | ... | @@ -1,6 +1,16 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import type { BrowserLinkTarget } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | 3 | ||
| 3 | import { resolveLinkTarget } from "./browserLinkTarget"; | 4 | import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; |
| 5 | |||
| 6 | import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; | ||
| 7 | |||
| 8 | const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); | ||
| 9 | |||
| 10 | vi.mock("~/hooks/useSettings", () => ({ | ||
| 11 | ensureClientSettingsHydrated: vi.fn(async () => undefined), | ||
| 12 | getClientSettings: () => settings, | ||
| 13 | })); | ||
| 4 | 14 | ||
| 5 | const click = { metaKey: false, ctrlKey: false }; | 15 | const click = { metaKey: false, ctrlKey: false }; |
| 6 | 16 | ||
| ... | @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { | ... | @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { |
| 67 | } | 77 | } |
| 68 | }); | 78 | }); |
| 69 | }); | 79 | }); |
| 80 | |||
| 81 | describe("resolveBrowserLinkTargetPreference", () => { | ||
| 82 | it.each(["system", "app"] as const)( | ||
| 83 | "rejects failed reads instead of using the current %s preference", | ||
| 84 | async (preference) => { | ||
| 85 | settings.browserLinkTarget = preference; | ||
| 86 | const failure = new Error("Settings read failed"); | ||
| 87 | vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); | ||
| 88 | |||
| 89 | await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); | ||
| 90 | await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); | ||
| 91 | }, | ||
| 92 | ); | ||
| 93 | }); |
apps/web/src/browser/browserLinkTarget.ts+1| ... | @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { | ... | @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { |
| 55 | * hydration the snapshot is the schema default ("system"), so a link clicked | 55 | * hydration the snapshot is the schema default ("system"), so a link clicked |
| 56 | * in the first moments after launch would ignore a persisted "app" — opening | 56 | * in the first moments after launch would ignore a persisted "app" — opening |
| 57 | * is asynchronous anyway, so waiting costs nothing the user can see. | 57 | * is asynchronous anyway, so waiting costs nothing the user can see. |
| 58 | * Read failures reject rather than choosing a browser without the saved preference. | ||
| 58 | */ | 59 | */ |
| 59 | export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> { | 60 | export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> { |
| 60 | await ensureClientSettingsHydrated(); | 61 | await ensureClientSettingsHydrated(); |
apps/web/src/browser/browserRecording.test.ts+31| ... | @@ -5,6 +5,8 @@ import { | ... | @@ -5,6 +5,8 @@ import { |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; | ||
| 9 | |||
| 8 | const { | 10 | const { |
| 9 | clientSettings, | 11 | clientSettings, |
| 10 | events, | 12 | events, |
| ... | @@ -241,6 +243,35 @@ describe("browser recording", () => { | ... | @@ -241,6 +243,35 @@ describe("browser recording", () => { |
| 241 | await stopBrowserRecording("recording-tab"); | 243 | await stopBrowserRecording("recording-tab"); |
| 242 | }); | 244 | }); |
| 243 | 245 | ||
| 246 | it("clears a failed settings read before retrying recording", async () => { | ||
| 247 | const tabId = "settings-read-failure-tab"; | ||
| 248 | const error = new Error("Settings read failed"); | ||
| 249 | vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); | ||
| 250 | |||
| 251 | await expect(startBrowserRecording(tabId)).rejects.toBe(error); | ||
| 252 | |||
| 253 | expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); | ||
| 254 | expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); | ||
| 255 | expect(animationFrameCount).toBe(0); | ||
| 256 | expect(startScreencast).not.toHaveBeenCalled(); | ||
| 257 | expect(stopScreencast).not.toHaveBeenCalled(); | ||
| 258 | expect(getDisplayMedia).not.toHaveBeenCalled(); | ||
| 259 | expect(FakeMediaRecorder.instances).toHaveLength(0); | ||
| 260 | |||
| 261 | clientSettings.browserRecordingFrameRate = 60; | ||
| 262 | await startBrowserRecording(tabId); | ||
| 263 | |||
| 264 | expect(getDisplayMedia).toHaveBeenCalledWith({ | ||
| 265 | audio: false, | ||
| 266 | video: { frameRate: { max: 60 } }, | ||
| 267 | }); | ||
| 268 | await stopBrowserRecording(tabId); | ||
| 269 | |||
| 270 | expect(startScreencast).toHaveBeenCalledOnce(); | ||
| 271 | expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); | ||
| 272 | expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); | ||
| 273 | }); | ||
| 274 | |||
| 244 | it("stops the native stream when MediaRecorder cleanup fails", async () => { | 275 | it("stops the native stream when MediaRecorder cleanup fails", async () => { |
| 245 | const stopTrack = vi.fn(); | 276 | const stopTrack = vi.fn(); |
| 246 | getDisplayMedia.mockResolvedValueOnce({ | 277 | getDisplayMedia.mockResolvedValueOnce({ |
apps/web/src/browser/browserRecording.ts+6-4| ... | @@ -516,10 +516,12 @@ export async function startBrowserRecording( | ... | @@ -516,10 +516,12 @@ export async function startBrowserRecording( |
| 516 | activeRecordings.set(tabId, recording); | 516 | activeRecordings.set(tabId, recording); |
| 517 | publishActiveRecordingTabIds(); | 517 | publishActiveRecordingTabIds(); |
| 518 | try { | 518 | try { |
| 519 | const frameRatePromise = ensureClientSettingsHydrated().then( | 519 | await ensureClientSettingsHydrated().catch((cause: unknown) => { |
| 520 | () => getClientSettings().browserRecordingFrameRate, | 520 | clearActiveRecording(recording); |
| 521 | ); | 521 | throw cause; |
| 522 | const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); | 522 | }); |
| 523 | const frameRate = getClientSettings().browserRecordingFrameRate; | ||
| 524 | await waitForBrowserRecordingPaint(); | ||
| 523 | const throwIfStartupCancelled = async (): Promise<void> => { | 525 | const throwIfStartupCancelled = async (): Promise<void> => { |
| 524 | // Once a grant starts, a stop lets startup finish so the caller receives an artifact. | 526 | // Once a grant starts, a stop lets startup finish so the caller receives an artifact. |
| 525 | // Only a contended start can be cancelled before it reaches native capture. | 527 | // Only a contended start can be cancelled before it reaches native capture. |
apps/web/src/browser/desktopTabLifetime.test.ts+34-1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_PREVIEW_APPEARANCE, | 2 | DEFAULT_PREVIEW_APPEARANCE, |
| 3 | DEFAULT_PREVIEW_ZOOM_FACTOR, | 3 | DEFAULT_PREVIEW_ZOOM_FACTOR, |
| 4 | DEFAULT_CLIENT_SETTINGS, | ||
| 4 | EnvironmentId, | 5 | EnvironmentId, |
| 5 | ThreadId, | 6 | ThreadId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| ... | @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ | ... | @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ |
| 21 | })); | 22 | })); |
| 22 | 23 | ||
| 23 | import { acquireDesktopTab } from "./desktopTabLifetime"; | 24 | import { acquireDesktopTab } from "./desktopTabLifetime"; |
| 25 | import * as browserDefaults from "./browserDefaults"; | ||
| 26 | import { __setClientSettingsForTests } from "~/hooks/useSettings"; | ||
| 24 | 27 | ||
| 25 | /** Client settings are unset in tests, so creation carries the schema defaults. */ | 28 | /** Tests load default settings unless they select other preferences. */ |
| 26 | const DEFAULT_TAB_STATE = { | 29 | const DEFAULT_TAB_STATE = { |
| 27 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, | 30 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, |
| 28 | colorScheme: DEFAULT_PREVIEW_APPEARANCE, | 31 | colorScheme: DEFAULT_PREVIEW_APPEARANCE, |
| ... | @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; | ... | @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; |
| 31 | 34 | ||
| 32 | describe("desktopTabLifetime", () => { | 35 | describe("desktopTabLifetime", () => { |
| 33 | beforeEach(() => { | 36 | beforeEach(() => { |
| 37 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 34 | closeTab.mockClear(); | 38 | closeTab.mockClear(); |
| 35 | createTab.mockClear(); | 39 | createTab.mockClear(); |
| 36 | stopBrowserRecording.mockClear(); | 40 | stopBrowserRecording.mockClear(); |
| ... | @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { | ... | @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { |
| 40 | afterEach(() => { | 44 | afterEach(() => { |
| 41 | vi.useRealTimers(); | 45 | vi.useRealTimers(); |
| 42 | vi.unstubAllGlobals(); | 46 | vi.unstubAllGlobals(); |
| 47 | vi.restoreAllMocks(); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { | ||
| 51 | vi.useFakeTimers(); | ||
| 52 | const failure = new Error("Settings read failed"); | ||
| 53 | vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); | ||
| 54 | const failed = acquireDesktopTab("tab_settings_retry"); | ||
| 55 | |||
| 56 | await expect(failed.ready).rejects.toBe(failure); | ||
| 57 | expect(createTab).not.toHaveBeenCalled(); | ||
| 58 | failed.release(); | ||
| 59 | await vi.advanceTimersByTimeAsync(0); | ||
| 60 | |||
| 61 | __setClientSettingsForTests({ | ||
| 62 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 63 | browserDefaultZoomFactor: 1.25, | ||
| 64 | browserDefaultAppearance: "dark", | ||
| 65 | }); | ||
| 66 | createTab.mockResolvedValueOnce(undefined); | ||
| 67 | const retry = acquireDesktopTab("tab_settings_retry"); | ||
| 68 | await retry.ready; | ||
| 69 | |||
| 70 | expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { | ||
| 71 | zoomFactor: 1.25, | ||
| 72 | colorScheme: "dark", | ||
| 73 | }); | ||
| 74 | retry.release(); | ||
| 75 | await vi.advanceTimersByTimeAsync(0); | ||
| 43 | }); | 76 | }); |
| 44 | 77 | ||
| 45 | it("shares tab creation readiness across concurrent leases", async () => { | 78 | it("shares tab creation readiness across concurrent leases", async () => { |
apps/web/src/browser/openFileInPreview.ts+21-3| ... | @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( | ... | @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( |
| 38 | readonly message: string; | 38 | readonly message: string; |
| 39 | }> {} | 39 | }> {} |
| 40 | 40 | ||
| 41 | export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ | ||
| 42 | readonly cause: unknown; | ||
| 43 | }> { | ||
| 44 | override get message(): string { | ||
| 45 | return "Saved browser settings could not be loaded."; | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 41 | export type OpenPreviewMutation<E = unknown> = (input: { | 49 | export type OpenPreviewMutation<E = unknown> = (input: { |
| 42 | readonly environmentId: EnvironmentId; | 50 | readonly environmentId: EnvironmentId; |
| 43 | readonly input: PreviewOpenInput; | 51 | readonly input: PreviewOpenInput; |
| ... | @@ -47,8 +55,13 @@ export async function openUrlInPreview<E>(input: { | ... | @@ -47,8 +55,13 @@ export async function openUrlInPreview<E>(input: { |
| 47 | readonly threadRef: ScopedThreadRef; | 55 | readonly threadRef: ScopedThreadRef; |
| 48 | readonly url: string; | 56 | readonly url: string; |
| 49 | readonly openPreview: OpenPreviewMutation<E>; | 57 | readonly openPreview: OpenPreviewMutation<E>; |
| 50 | }): Promise<AtomCommandResult<void, E>> { | 58 | }): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> { |
| 51 | const defaults = await resolveBrowserDefaults(); | 59 | const defaults = await resolveBrowserDefaults().catch( |
| 60 | (cause: unknown) => new BrowserSettingsReadError({ cause }), | ||
| 61 | ); | ||
| 62 | if (defaults instanceof BrowserSettingsReadError) { | ||
| 63 | return AsyncResult.failure(Cause.fail(defaults)); | ||
| 64 | } | ||
| 52 | const result = await input.openPreview({ | 65 | const result = await input.openPreview({ |
| 53 | environmentId: input.threadRef.environmentId, | 66 | environmentId: input.threadRef.environmentId, |
| 54 | input: { | 67 | input: { |
| ... | @@ -82,7 +95,12 @@ export async function openFileInPreview<AssetError, PreviewError>(input: { | ... | @@ -82,7 +95,12 @@ export async function openFileInPreview<AssetError, PreviewError>(input: { |
| 82 | readonly input: { readonly resource: AssetResource }; | 95 | readonly input: { readonly resource: AssetResource }; |
| 83 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>; | 96 | }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>; |
| 84 | readonly openPreview: OpenPreviewMutation<PreviewError>; | 97 | readonly openPreview: OpenPreviewMutation<PreviewError>; |
| 85 | }): Promise<AtomCommandResult<void, AssetError | PreviewError | BrowserPreviewUnavailableError>> { | 98 | }): Promise< |
| 99 | AtomCommandResult< | ||
| 100 | void, | ||
| 101 | AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError | ||
| 102 | > | ||
| 103 | > { | ||
| 86 | if (!isPreviewSupportedInRuntime()) { | 104 | if (!isPreviewSupportedInRuntime()) { |
| 87 | return AsyncResult.failure( | 105 | return AsyncResult.failure( |
| 88 | Cause.fail( | 106 | Cause.fail( |
apps/web/src/browser/useOpenLink.ts+9-4| ... | @@ -1,5 +1,8 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | 2 | import { |
| 3 | isAtomCommandInterrupted, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import { useCallback } from "react"; | 6 | import { useCallback } from "react"; |
| 4 | 7 | ||
| 5 | import { recordVisitForThread } from "~/browserHistoryStore"; | 8 | import { recordVisitForThread } from "~/browserHistoryStore"; |
| ... | @@ -12,7 +15,7 @@ import { | ... | @@ -12,7 +15,7 @@ import { |
| 12 | resolveBrowserLinkTargetPreference, | 15 | resolveBrowserLinkTargetPreference, |
| 13 | resolveLinkTarget, | 16 | resolveLinkTarget, |
| 14 | } from "./browserLinkTarget"; | 17 | } from "./browserLinkTarget"; |
| 15 | import { openUrlInPreview } from "./openFileInPreview"; | 18 | import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; |
| 16 | 19 | ||
| 17 | const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; | 20 | const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; |
| 18 | 21 | ||
| ... | @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; | ... | @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; |
| 24 | * | 27 | * |
| 25 | * An in-app open that fails falls back to the system browser rather than | 28 | * An in-app open that fails falls back to the system browser rather than |
| 26 | * dropping the click: the user asked for the link, and the setting only says | 29 | * dropping the click: the user asked for the link, and the setting only says |
| 27 | * where it should go first. The returned promise rejects only when that | 30 | * where it should go first. Failed settings reads reject without opening a |
| 28 | * fallback fails too, the same way `shell.openExternal` does. | 31 | * browser. The promise also rejects if the system-browser fallback fails. |
| 29 | */ | 32 | */ |
| 30 | export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( | 33 | export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( |
| 31 | url: string, | 34 | url: string, |
| ... | @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( | ... | @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( |
| 52 | recordVisitForThread(targetThreadRef, url); | 55 | recordVisitForThread(targetThreadRef, url); |
| 53 | return; | 56 | return; |
| 54 | } | 57 | } |
| 58 | const failure = squashAtomCommandFailure(result); | ||
| 59 | if (failure instanceof BrowserSettingsReadError) throw failure; | ||
| 55 | console.error(result.cause); | 60 | console.error(result.cause); |
| 56 | } | 61 | } |
| 57 | const api = readLocalApi(); | 62 | const api = readLocalApi(); |
apps/web/src/clientPersistenceStorage.test.ts+31-8| ... | @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { | ... | @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { |
| 52 | expect(readBrowserClientSettings()).toEqual(settings); | 52 | expect(readBrowserClientSettings()).toEqual(settings); |
| 53 | }); | 53 | }); |
| 54 | 54 | ||
| 55 | it("reports structured decode failures while preserving the fallback", async () => { | 55 | it.each(["not-json", '{"wordWrap":"invalid"}'])( |
| 56 | "does not treat invalid saved settings as absent: %s", | ||
| 57 | async (value) => { | ||
| 58 | const testWindow = getTestWindow(); | ||
| 59 | testWindow.localStorage.setItem("t3code:client-settings:v1", value); | ||
| 60 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); | ||
| 61 | |||
| 62 | expect(() => readBrowserClientSettings()).toThrow( | ||
| 63 | expect.objectContaining({ | ||
| 64 | _tag: "LocalStorageOperationError", | ||
| 65 | operation: "decode", | ||
| 66 | storageKey: "t3code:client-settings:v1", | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); | ||
| 70 | }, | ||
| 71 | ); | ||
| 72 | |||
| 73 | it("preserves saved settings across a transient read failure", async () => { | ||
| 56 | const testWindow = getTestWindow(); | 74 | const testWindow = getTestWindow(); |
| 57 | testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); | 75 | const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; |
| 58 | const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); | 76 | testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); |
| 77 | const write = vi.spyOn(testWindow.localStorage, "setItem"); | ||
| 78 | const failure = new Error("storage unavailable"); | ||
| 79 | vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { | ||
| 80 | throw failure; | ||
| 81 | }); | ||
| 59 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); | 82 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); |
| 60 | 83 | ||
| 61 | expect(readBrowserClientSettings()).toBeNull(); | 84 | expect(() => readBrowserClientSettings()).toThrow( |
| 62 | expect(consoleError).toHaveBeenCalledWith( | ||
| 63 | "Could not read persisted client settings.", | ||
| 64 | expect.objectContaining({ | 85 | expect.objectContaining({ |
| 65 | _tag: "LocalStorageOperationError", | 86 | _tag: "LocalStorageOperationError", |
| 66 | operation: "decode", | 87 | operation: "read", |
| 67 | storageKey: "t3code:client-settings:v1", | 88 | storageKey: "t3code:client-settings:v1", |
| 68 | cause: expect.anything(), | 89 | cause: failure, |
| 69 | }), | 90 | }), |
| 70 | ); | 91 | ); |
| 92 | expect(readBrowserClientSettings()).toEqual(settings); | ||
| 93 | expect(write).not.toHaveBeenCalled(); | ||
| 71 | }); | 94 | }); |
| 72 | 95 | ||
| 73 | it("defaults word wrap on and discards obsolete wrapping preferences", async () => { | 96 | it("defaults word wrap on and discards obsolete wrapping preferences", async () => { |
apps/web/src/clientPersistenceStorage.ts+1-6| ... | @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { | ... | @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { |
| 13 | return null; | 13 | return null; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | try { | 16 | return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); |
| 17 | return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); | ||
| 18 | } catch (error) { | ||
| 19 | console.error("Could not read persisted client settings.", error); | ||
| 20 | return null; | ||
| 21 | } | ||
| 22 | } | 17 | } |
| 23 | 18 | ||
| 24 | export function writeBrowserClientSettings(settings: ClientSettings): void { | 19 | export function writeBrowserClientSettings(settings: ClientSettings): void { |
apps/web/src/components/ChatMarkdown.tsx+15-2| ... | @@ -176,6 +176,7 @@ import { | ... | @@ -176,6 +176,7 @@ import { |
| 176 | openFileInPreview, | 176 | openFileInPreview, |
| 177 | openUrlInPreview, | 177 | openUrlInPreview, |
| 178 | BrowserPreviewUnavailableError, | 178 | BrowserPreviewUnavailableError, |
| 179 | BrowserSettingsReadError, | ||
| 179 | } from "../browser/openFileInPreview"; | 180 | } from "../browser/openFileInPreview"; |
| 180 | import { resolveLinkTarget } from "../browser/browserLinkTarget"; | 181 | import { resolveLinkTarget } from "../browser/browserLinkTarget"; |
| 181 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; | 182 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; |
| ... | @@ -2357,6 +2358,18 @@ function useChatMarkdownState({ | ... | @@ -2357,6 +2358,18 @@ function useChatMarkdownState({ |
| 2357 | } | 2358 | } |
| 2358 | return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { | 2359 | return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { |
| 2359 | if (result._tag === "Success") recordVisitForThread(threadRef, url); | 2360 | if (result._tag === "Success") recordVisitForThread(threadRef, url); |
| 2361 | else if (!isAtomCommandInterrupted(result)) { | ||
| 2362 | const error = squashAtomCommandFailure(result); | ||
| 2363 | if (error instanceof BrowserSettingsReadError) { | ||
| 2364 | toastManager.add( | ||
| 2365 | stackedThreadToast({ | ||
| 2366 | type: "error", | ||
| 2367 | title: "Unable to open link in browser", | ||
| 2368 | description: error.message, | ||
| 2369 | }), | ||
| 2370 | ); | ||
| 2371 | } | ||
| 2372 | } | ||
| 2360 | return result; | 2373 | return result; |
| 2361 | }); | 2374 | }); |
| 2362 | }, | 2375 | }, |
| ... | @@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2789 | } | 2802 | } |
| 2790 | event.preventDefault(); | 2803 | event.preventDefault(); |
| 2791 | event.stopPropagation(); | 2804 | event.stopPropagation(); |
| 2792 | // The click was taken from the shell, so an in-app open that fails | 2805 | // Keep the link here if saved settings could not be read. |
| 2793 | // hands the link to the system browser instead of dropping it. | ||
| 2794 | void openExternalLinkInPreview(href).then((result) => { | 2806 | void openExternalLinkInPreview(href).then((result) => { |
| 2795 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; | 2807 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; |
| 2796 | reportMarkdownActionFailure( | 2808 | reportMarkdownActionFailure( |
| 2797 | { operation: "open-link-in-preview", target: href }, | 2809 | { operation: "open-link-in-preview", target: href }, |
| 2798 | result.cause, | 2810 | result.cause, |
| 2799 | ); | 2811 | ); |
| 2812 | if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return; | ||
| 2800 | void readLocalApi()?.shell.openExternal(href); | 2813 | void readLocalApi()?.shell.openExternal(href); |
| 2801 | }); | 2814 | }); |
| 2802 | }} | 2815 | }} |
apps/web/src/components/ChatView.tsx+13| ... | @@ -162,6 +162,7 @@ import { | ... | @@ -162,6 +162,7 @@ import { |
| 162 | useThreadPreviewState, | 162 | useThreadPreviewState, |
| 163 | } from "../previewStateStore"; | 163 | } from "../previewStateStore"; |
| 164 | import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; | 164 | import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; |
| 165 | import { BrowserSettingsReadError } from "../browser/openFileInPreview"; | ||
| 165 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | 166 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 166 | import { closePreviewSession } from "./preview/closePreviewSession"; | 167 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 167 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | 168 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| ... | @@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) { |
| 3718 | threadRef: activeThreadRef, | 3719 | threadRef: activeThreadRef, |
| 3719 | openPreview, | 3720 | openPreview, |
| 3720 | ...(profileId === undefined ? {} : { profileId }), | 3721 | ...(profileId === undefined ? {} : { profileId }), |
| 3722 | }).then((result) => { | ||
| 3723 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; | ||
| 3724 | const error = squashAtomCommandFailure(result); | ||
| 3725 | if (error instanceof BrowserSettingsReadError) { | ||
| 3726 | toastManager.add( | ||
| 3727 | stackedThreadToast({ | ||
| 3728 | type: "error", | ||
| 3729 | title: "Unable to open browser", | ||
| 3730 | description: error.message, | ||
| 3731 | }), | ||
| 3732 | ); | ||
| 3733 | } | ||
| 3721 | }); | 3734 | }); |
| 3722 | }, | 3735 | }, |
| 3723 | [activeThreadRef, openPreview], | 3736 | [activeThreadRef, openPreview], |
apps/web/src/components/ThreadTerminalDrawer.test.ts+88-1| ... | @@ -1,11 +1,98 @@ | ... | @@ -1,11 +1,98 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | shouldClearTerminalSelectionAction, | 4 | shouldClearTerminalSelectionAction, |
| 5 | shouldHandleTerminalExit, | 5 | shouldHandleTerminalExit, |
| 6 | terminalContextMenuItems, | ||
| 6 | terminalSelectionLineRange, | 7 | terminalSelectionLineRange, |
| 8 | terminalSelectionMenuItems, | ||
| 9 | terminalThemeFromApp, | ||
| 7 | } from "./ThreadTerminalDrawer"; | 10 | } from "./ThreadTerminalDrawer"; |
| 8 | 11 | ||
| 12 | describe("terminal selection menus", () => { | ||
| 13 | it("omits Add to chat when the terminal has no chat target", () => { | ||
| 14 | expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]); | ||
| 15 | expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ | ||
| 16 | "add-to-chat", | ||
| 17 | "copy", | ||
| 18 | "paste", | ||
| 19 | ]); | ||
| 20 | |||
| 21 | expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ | ||
| 22 | "copy", | ||
| 23 | ]); | ||
| 24 | expect( | ||
| 25 | terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), | ||
| 26 | ).toEqual(["copy", "paste"]); | ||
| 27 | }); | ||
| 28 | }); | ||
| 29 | |||
| 30 | describe("terminalThemeFromApp", () => { | ||
| 31 | afterEach(() => { | ||
| 32 | vi.unstubAllGlobals(); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("uses terminal colors inherited by the mount instead of a light document theme", () => { | ||
| 36 | const root = { classList: { contains: () => false } }; | ||
| 37 | const body = {}; | ||
| 38 | const drawer = {}; | ||
| 39 | let canvasColor = "#000"; | ||
| 40 | const colors: Record<string, [number, number, number, number]> = { | ||
| 41 | "#000": [0, 0, 0, 255], | ||
| 42 | "#fff": [255, 255, 255, 255], | ||
| 43 | "#ddd": [221, 221, 221, 255], | ||
| 44 | "#111": [17, 17, 17, 255], | ||
| 45 | }; | ||
| 46 | |||
| 47 | vi.stubGlobal("document", { | ||
| 48 | documentElement: root, | ||
| 49 | body, | ||
| 50 | querySelector: () => drawer, | ||
| 51 | createElement: () => ({ | ||
| 52 | width: 0, | ||
| 53 | height: 0, | ||
| 54 | getContext: () => ({ | ||
| 55 | clearRect: () => undefined, | ||
| 56 | fillRect: () => undefined, | ||
| 57 | get fillStyle() { | ||
| 58 | return canvasColor; | ||
| 59 | }, | ||
| 60 | set fillStyle(value: string) { | ||
| 61 | canvasColor = value; | ||
| 62 | }, | ||
| 63 | getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }), | ||
| 64 | }), | ||
| 65 | }), | ||
| 66 | }); | ||
| 67 | vi.stubGlobal("getComputedStyle", (element: object) => { | ||
| 68 | const local = element === drawer; | ||
| 69 | const values = local | ||
| 70 | ? { | ||
| 71 | "--terminal-background": "#000", | ||
| 72 | "--terminal-foreground": "#fff", | ||
| 73 | "--terminal-cursor": "#ddd", | ||
| 74 | "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", | ||
| 75 | } | ||
| 76 | : { | ||
| 77 | "--terminal-background": "#fff", | ||
| 78 | "--terminal-foreground": "#111", | ||
| 79 | }; | ||
| 80 | return { | ||
| 81 | backgroundColor: local ? "#000" : "#fff", | ||
| 82 | color: local ? "#fff" : "#111", | ||
| 83 | colorScheme: local ? "dark" : "light", | ||
| 84 | getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", | ||
| 85 | }; | ||
| 86 | }); | ||
| 87 | |||
| 88 | const theme = terminalThemeFromApp(); | ||
| 89 | |||
| 90 | expect(theme.background).toEqual({ r: 0, g: 0, b: 0 }); | ||
| 91 | expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 }); | ||
| 92 | expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 }); | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 9 | describe("terminal selection actions", () => { | 96 | describe("terminal selection actions", () => { |
| 10 | it("clears a pending or currently owned menu when the selection disappears", () => { | 97 | it("clears a pending or currently owned menu when the selection disappears", () => { |
| 11 | expect( | 98 | expect( |
apps/web/src/components/ThreadTerminalDrawer.tsx+60-17| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | } from "lucide-react"; | 20 | } from "lucide-react"; |
| 21 | import { | 21 | import { |
| 22 | type ContextMenuItem, | 22 | type ContextMenuItem, |
| 23 | type ProviderInstanceId, | ||
| 23 | type ResolvedKeybindingsConfig, | 24 | type ResolvedKeybindingsConfig, |
| 24 | type ScopedThreadRef, | 25 | type ScopedThreadRef, |
| 25 | type ThreadId, | 26 | type ThreadId, |
| ... | @@ -41,6 +42,7 @@ import { | ... | @@ -41,6 +42,7 @@ import { |
| 41 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 42 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 42 | import { Button } from "~/components/ui/button"; | 43 | import { Button } from "~/components/ui/button"; |
| 43 | import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; | 44 | import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; |
| 45 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | ||
| 44 | import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 46 | import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 45 | import { cn } from "~/lib/utils"; | 47 | import { cn } from "~/lib/utils"; |
| 46 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | 48 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| ... | @@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s | ... | @@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s |
| 172 | } | 174 | } |
| 173 | 175 | ||
| 174 | export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { | 176 | export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { |
| 175 | const isDark = document.documentElement.classList.contains("dark"); | ||
| 176 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; | ||
| 177 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; | ||
| 178 | const drawerSurface = | 177 | const drawerSurface = |
| 179 | mountElement?.closest(".thread-terminal-drawer") ?? | 178 | mountElement?.closest(".thread-terminal-drawer") ?? |
| 180 | document.querySelector(".thread-terminal-drawer") ?? | 179 | document.querySelector(".thread-terminal-drawer") ?? |
| 181 | document.body; | 180 | document.body; |
| 182 | const drawerStyles = getComputedStyle(drawerSurface); | 181 | const drawerStyles = getComputedStyle(drawerSurface); |
| 182 | const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; | ||
| 183 | const colorScheme = themeStyles.colorScheme; | ||
| 184 | const isDark = | ||
| 185 | colorScheme === "dark" | ||
| 186 | ? true | ||
| 187 | : colorScheme === "light" | ||
| 188 | ? false | ||
| 189 | : document.documentElement.classList.contains("dark"); | ||
| 190 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; | ||
| 191 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; | ||
| 183 | const bodyStyles = getComputedStyle(document.body); | 192 | const bodyStyles = getComputedStyle(document.body); |
| 184 | const themeStyles = getComputedStyle(document.documentElement); | 193 | const rootThemeStyles = getComputedStyle(document.documentElement); |
| 185 | const background = normalizeComputedColor( | 194 | const background = normalizeComputedColor( |
| 186 | drawerStyles.backgroundColor, | 195 | drawerStyles.backgroundColor, |
| 187 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), | 196 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), |
| ... | @@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty | ... | @@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty |
| 190 | drawerStyles.color, | 199 | drawerStyles.color, |
| 191 | normalizeComputedColor(bodyStyles.color, fallbackForeground), | 200 | normalizeComputedColor(bodyStyles.color, fallbackForeground), |
| 192 | ); | 201 | ); |
| 193 | const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); | 202 | const terminalBackground = readThemeColor( |
| 194 | const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); | 203 | themeStyles, |
| 204 | "--terminal-background", | ||
| 205 | readThemeColor(rootThemeStyles, "--terminal-background", background), | ||
| 206 | ); | ||
| 207 | const terminalForeground = readThemeColor( | ||
| 208 | themeStyles, | ||
| 209 | "--terminal-foreground", | ||
| 210 | readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), | ||
| 211 | ); | ||
| 195 | const terminalCursor = readThemeColor( | 212 | const terminalCursor = readThemeColor( |
| 196 | themeStyles, | 213 | themeStyles, |
| 197 | "--terminal-cursor", | 214 | "--terminal-cursor", |
| ... | @@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: { | ... | @@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: { |
| 232 | 249 | ||
| 233 | export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; | 250 | export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; |
| 234 | 251 | ||
| 235 | /** Post-selection popup: just the two selection actions, always enabled. */ | 252 | /** Post-selection popup: available selection actions, always enabled. */ |
| 236 | export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { | 253 | export function terminalSelectionMenuItems(options?: { |
| 254 | canAddToChat?: boolean; | ||
| 255 | }): ContextMenuItem<"add-to-chat" | "copy">[] { | ||
| 237 | return [ | 256 | return [ |
| 238 | { id: "add-to-chat", label: "Add to chat" }, | 257 | ...(options?.canAddToChat === false |
| 258 | ? [] | ||
| 259 | : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])), | ||
| 239 | { id: "copy", label: "Copy" }, | 260 | { id: "copy", label: "Copy" }, |
| 240 | ]; | 261 | ]; |
| 241 | } | 262 | } |
| ... | @@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c | ... | @@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c |
| 248 | */ | 269 | */ |
| 249 | export function terminalContextMenuItems(options: { | 270 | export function terminalContextMenuItems(options: { |
| 250 | hasSelection: boolean; | 271 | hasSelection: boolean; |
| 272 | canAddToChat?: boolean; | ||
| 251 | }): ContextMenuItem<TerminalContextMenuAction>[] { | 273 | }): ContextMenuItem<TerminalContextMenuAction>[] { |
| 274 | const { hasSelection, canAddToChat = true } = options; | ||
| 252 | return [ | 275 | return [ |
| 253 | ...terminalSelectionMenuItems().map((item) => ({ | 276 | ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ |
| 254 | ...item, | 277 | ...item, |
| 255 | disabled: !options.hasSelection, | 278 | disabled: !hasSelection, |
| 256 | })), | 279 | })), |
| 257 | { id: "paste", label: "Paste" }, | 280 | { id: "paste", label: "Paste" }, |
| 258 | ]; | 281 | ]; |
| ... | @@ -292,8 +315,9 @@ interface TerminalViewportProps { | ... | @@ -292,8 +315,9 @@ interface TerminalViewportProps { |
| 292 | cwd: string; | 315 | cwd: string; |
| 293 | worktreePath?: string | null; | 316 | worktreePath?: string | null; |
| 294 | runtimeEnv?: Record<string, string>; | 317 | runtimeEnv?: Record<string, string>; |
| 318 | providerInstanceId?: ProviderInstanceId; | ||
| 295 | onSessionExited: () => void; | 319 | onSessionExited: () => void; |
| 296 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | 320 | onAddTerminalContext?: (selection: TerminalContextSelection) => void; |
| 297 | focusRequestId: number; | 321 | focusRequestId: number; |
| 298 | autoFocus: boolean; | 322 | autoFocus: boolean; |
| 299 | visible: boolean; | 323 | visible: boolean; |
| ... | @@ -317,6 +341,7 @@ export function TerminalViewport({ | ... | @@ -317,6 +341,7 @@ export function TerminalViewport({ |
| 317 | cwd, | 341 | cwd, |
| 318 | worktreePath, | 342 | worktreePath, |
| 319 | runtimeEnv, | 343 | runtimeEnv, |
| 344 | providerInstanceId, | ||
| 320 | onSessionExited, | 345 | onSessionExited, |
| 321 | onAddTerminalContext, | 346 | onAddTerminalContext, |
| 322 | focusRequestId, | 347 | focusRequestId, |
| ... | @@ -357,8 +382,9 @@ export function TerminalViewport({ | ... | @@ -357,8 +382,9 @@ export function TerminalViewport({ |
| 357 | onSessionExited(); | 382 | onSessionExited(); |
| 358 | }); | 383 | }); |
| 359 | const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { | 384 | const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { |
| 360 | onAddTerminalContext(selection); | 385 | onAddTerminalContext?.(selection); |
| 361 | }); | 386 | }); |
| 387 | const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); | ||
| 362 | const readTerminalLabel = useEffectEvent(() => terminalLabel); | 388 | const readTerminalLabel = useEffectEvent(() => terminalLabel); |
| 363 | const terminalFontFamily = useClientSettings((settings) => | 389 | const terminalFontFamily = useClientSettings((settings) => |
| 364 | resolveTerminalFontPreference({ | 390 | resolveTerminalFontPreference({ |
| ... | @@ -383,6 +409,7 @@ export function TerminalViewport({ | ... | @@ -383,6 +409,7 @@ export function TerminalViewport({ |
| 383 | cwd, | 409 | cwd, |
| 384 | ...(worktreePath !== undefined ? { worktreePath } : {}), | 410 | ...(worktreePath !== undefined ? { worktreePath } : {}), |
| 385 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | 411 | ...(runtimeEnv ? { env: runtimeEnv } : {}), |
| 412 | ...(providerInstanceId ? { providerInstanceId } : {}), | ||
| 386 | }, | 413 | }, |
| 387 | }); | 414 | }); |
| 388 | const writeTerminal = useEffectEvent((data: string) => | 415 | const writeTerminal = useEffectEvent((data: string) => |
| ... | @@ -635,7 +662,10 @@ export function TerminalViewport({ | ... | @@ -635,7 +662,10 @@ export function TerminalViewport({ |
| 635 | let clicked: TerminalContextMenuAction | null; | 662 | let clicked: TerminalContextMenuAction | null; |
| 636 | try { | 663 | try { |
| 637 | clicked = await localApi.contextMenu.show( | 664 | clicked = await localApi.contextMenu.show( |
| 638 | terminalContextMenuItems({ hasSelection: selectionAction !== null }), | 665 | terminalContextMenuItems({ |
| 666 | hasSelection: selectionAction !== null, | ||
| 667 | canAddToChat: canAddSelectionToChat(), | ||
| 668 | }), | ||
| 639 | { x: event.clientX, y: event.clientY }, | 669 | { x: event.clientX, y: event.clientY }, |
| 640 | ); | 670 | ); |
| 641 | } catch (error) { | 671 | } catch (error) { |
| ... | @@ -648,7 +678,9 @@ export function TerminalViewport({ | ... | @@ -648,7 +678,9 @@ export function TerminalViewport({ |
| 648 | } | 678 | } |
| 649 | switch (clicked) { | 679 | switch (clicked) { |
| 650 | case "add-to-chat": | 680 | case "add-to-chat": |
| 651 | if (selectionAction) addSelectionToChat(selectionAction.selection); | 681 | if (selectionAction && canAddSelectionToChat()) { |
| 682 | addSelectionToChat(selectionAction.selection); | ||
| 683 | } | ||
| 652 | return; | 684 | return; |
| 653 | case "copy": | 685 | case "copy": |
| 654 | if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); | 686 | if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); |
| ... | @@ -675,7 +707,10 @@ export function TerminalViewport({ | ... | @@ -675,7 +707,10 @@ export function TerminalViewport({ |
| 675 | const requestId = ++selectionActionRequestIdRef.current; | 707 | const requestId = ++selectionActionRequestIdRef.current; |
| 676 | openSelectionMenuRequestIdRef.current = requestId; | 708 | openSelectionMenuRequestIdRef.current = requestId; |
| 677 | const clicked = await localApi.contextMenu | 709 | const clicked = await localApi.contextMenu |
| 678 | .show(terminalSelectionMenuItems(), nextAction.position) | 710 | .show( |
| 711 | terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), | ||
| 712 | nextAction.position, | ||
| 713 | ) | ||
| 679 | .finally(() => { | 714 | .finally(() => { |
| 680 | if (openSelectionMenuRequestIdRef.current === requestId) { | 715 | if (openSelectionMenuRequestIdRef.current === requestId) { |
| 681 | openSelectionMenuRequestIdRef.current = null; | 716 | openSelectionMenuRequestIdRef.current = null; |
| ... | @@ -686,7 +721,7 @@ export function TerminalViewport({ | ... | @@ -686,7 +721,7 @@ export function TerminalViewport({ |
| 686 | } | 721 | } |
| 687 | switch (clicked) { | 722 | switch (clicked) { |
| 688 | case "add-to-chat": | 723 | case "add-to-chat": |
| 689 | addSelectionToChat(nextAction.selection); | 724 | if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); |
| 690 | return; | 725 | return; |
| 691 | case "copy": | 726 | case "copy": |
| 692 | await copySelection(nextAction.clipboardText, requestId); | 727 | await copySelection(nextAction.clipboardText, requestId); |
| ... | @@ -768,6 +803,14 @@ export function TerminalViewport({ | ... | @@ -768,6 +803,14 @@ export function TerminalViewport({ |
| 768 | threadRef, | 803 | threadRef, |
| 769 | openPreview, | 804 | openPreview, |
| 770 | fallbackToBrowser, | 805 | fallbackToBrowser, |
| 806 | }).catch((error: unknown) => { | ||
| 807 | toastManager.add( | ||
| 808 | stackedThreadToast({ | ||
| 809 | type: "error", | ||
| 810 | title: "Unable to open link", | ||
| 811 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 812 | }), | ||
| 813 | ); | ||
| 771 | }); | 814 | }); |
| 772 | return; | 815 | return; |
| 773 | } | 816 | } |
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | import type { Discovery } from "@t3tools/client-runtime/relay"; | ||
| 2 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 6 | import { act, type ButtonHTMLAttributes } from "react"; | ||
| 7 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 8 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 9 | |||
| 10 | type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; | ||
| 11 | |||
| 12 | const discovery = vi.hoisted(() => ({ | ||
| 13 | state: null as Discovery.RelayEnvironmentDiscoveryState | null, | ||
| 14 | listeners: new Set<() => void>(), | ||
| 15 | refreshCommand: Symbol("refresh"), | ||
| 16 | registerCommand: Symbol("register"), | ||
| 17 | refresh: vi.fn<() => Promise<AtomCommandResult<void, never>>>(), | ||
| 18 | register: vi.fn(), | ||
| 19 | listEnvironments: vi.fn<() => Promise<DiscoveredEnvironments>>(), | ||
| 20 | })); | ||
| 21 | |||
| 22 | vi.mock("~/state/relay", () => ({ | ||
| 23 | relayEnvironmentDiscovery: { refresh: discovery.refreshCommand }, | ||
| 24 | })); | ||
| 25 | vi.mock("~/connection/catalog", () => ({ | ||
| 26 | environmentCatalog: { register: discovery.registerCommand }, | ||
| 27 | })); | ||
| 28 | vi.mock("~/state/use-atom-command", () => ({ | ||
| 29 | useAtomCommand: (command: unknown) => | ||
| 30 | command === discovery.refreshCommand ? discovery.refresh : discovery.register, | ||
| 31 | })); | ||
| 32 | vi.mock("~/state/environments", async () => { | ||
| 33 | const { useSyncExternalStore } = await import("react"); | ||
| 34 | const subscribe = (listener: () => void) => { | ||
| 35 | discovery.listeners.add(listener); | ||
| 36 | return () => discovery.listeners.delete(listener); | ||
| 37 | }; | ||
| 38 | const read = () => { | ||
| 39 | if (discovery.state === null) throw new Error("Discovery fixture is not initialized"); | ||
| 40 | return discovery.state; | ||
| 41 | }; | ||
| 42 | return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) }; | ||
| 43 | }); | ||
| 44 | vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null })); | ||
| 45 | vi.mock("../ui/button", () => ({ | ||
| 46 | Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => ( | ||
| 47 | <button {...props}>{children}</button> | ||
| 48 | ), | ||
| 49 | })); | ||
| 50 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); | ||
| 51 | |||
| 52 | import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; | ||
| 53 | |||
| 54 | const newMachineId = EnvironmentId.make("new-computer"); | ||
| 55 | const linkedMachines: DiscoveredEnvironments = new Map([ | ||
| 56 | [ | ||
| 57 | newMachineId, | ||
| 58 | { | ||
| 59 | environment: { | ||
| 60 | environmentId: newMachineId, | ||
| 61 | label: "Work laptop", | ||
| 62 | endpoint: { | ||
| 63 | httpBaseUrl: "https://relay.example.test", | ||
| 64 | wsBaseUrl: "wss://relay.example.test/ws", | ||
| 65 | providerKind: "manual", | ||
| 66 | }, | ||
| 67 | linkedAt: "2026-09-05T12:00:00.000Z", | ||
| 68 | }, | ||
| 69 | availability: "online", | ||
| 70 | status: Option.none(), | ||
| 71 | error: Option.none(), | ||
| 72 | }, | ||
| 73 | ], | ||
| 74 | ]); | ||
| 75 | |||
| 76 | let renderer: ReactTestRenderer | null; | ||
| 77 | let page: EventTarget & { visibilityState: DocumentVisibilityState }; | ||
| 78 | let browserWindow: EventTarget; | ||
| 79 | |||
| 80 | function publish(state: Discovery.RelayEnvironmentDiscoveryState) { | ||
| 81 | discovery.state = state; | ||
| 82 | for (const listener of discovery.listeners) listener(); | ||
| 83 | } | ||
| 84 | |||
| 85 | async function mount(refreshWhileEmpty = true) { | ||
| 86 | await act(async () => { | ||
| 87 | renderer = create( | ||
| 88 | <CloudEnvironmentConnectRows | ||
| 89 | primaryEnvironmentId={null} | ||
| 90 | savedEnvironments={[]} | ||
| 91 | showSavedEnvironments | ||
| 92 | refreshWhileEmpty={refreshWhileEmpty} | ||
| 93 | empty={<p>Waiting for your computer to connect.</p>} | ||
| 94 | />, | ||
| 95 | ); | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | |||
| 99 | async function advance(milliseconds: number) { | ||
| 100 | await act(async () => { | ||
| 101 | await vi.advanceTimersByTimeAsync(milliseconds); | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | |||
| 105 | beforeEach(() => { | ||
| 106 | vi.useFakeTimers(); | ||
| 107 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 108 | page = Object.assign(new EventTarget(), { visibilityState: "visible" as const }); | ||
| 109 | browserWindow = new EventTarget(); | ||
| 110 | vi.stubGlobal("document", page); | ||
| 111 | vi.stubGlobal("window", browserWindow); | ||
| 112 | renderer = null; | ||
| 113 | discovery.listeners.clear(); | ||
| 114 | discovery.state = { | ||
| 115 | environments: new Map(), | ||
| 116 | refreshing: false, | ||
| 117 | offline: false, | ||
| 118 | error: Option.none(), | ||
| 119 | }; | ||
| 120 | discovery.listEnvironments.mockReset().mockResolvedValue(new Map()); | ||
| 121 | discovery.refresh.mockReset().mockImplementation(async () => { | ||
| 122 | publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() }); | ||
| 123 | const environments = await discovery.listEnvironments(); | ||
| 124 | publish({ environments, refreshing: false, offline: false, error: Option.none() }); | ||
| 125 | return AsyncResult.success(undefined); | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | afterEach(async () => { | ||
| 130 | await act(async () => renderer?.unmount()); | ||
| 131 | vi.useRealTimers(); | ||
| 132 | vi.unstubAllGlobals(); | ||
| 133 | }); | ||
| 134 | |||
| 135 | describe("cloud onboarding discovery", () => { | ||
| 136 | it("shows a newly linked computer without remounting and stops polling once found", async () => { | ||
| 137 | discovery.listEnvironments | ||
| 138 | .mockResolvedValueOnce(new Map()) | ||
| 139 | .mockResolvedValueOnce(linkedMachines); | ||
| 140 | await mount(); | ||
| 141 | expect(renderer!.root.findByType("p").children).toEqual([ | ||
| 142 | "Waiting for your computer to connect.", | ||
| 143 | ]); | ||
| 144 | |||
| 145 | await advance(5_000); | ||
| 146 | |||
| 147 | expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ | ||
| 148 | "Work laptop", | ||
| 149 | ]); | ||
| 150 | expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); | ||
| 151 | await advance(30_000); | ||
| 152 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it("waits while hidden and refreshes immediately when visible again", async () => { | ||
| 156 | page.visibilityState = "hidden"; | ||
| 157 | await mount(); | ||
| 158 | await advance(30_000); | ||
| 159 | expect(discovery.listEnvironments).not.toHaveBeenCalled(); | ||
| 160 | |||
| 161 | await act(async () => { | ||
| 162 | page.visibilityState = "visible"; | ||
| 163 | page.dispatchEvent(new Event("visibilitychange")); | ||
| 164 | }); | ||
| 165 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); | ||
| 166 | |||
| 167 | page.visibilityState = "hidden"; | ||
| 168 | page.dispatchEvent(new Event("visibilitychange")); | ||
| 169 | browserWindow.dispatchEvent(new Event("focus")); | ||
| 170 | await advance(30_000); | ||
| 171 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it("does not overlap a slow refresh or restart polling after unmount", async () => { | ||
| 175 | let resolveRefresh!: (environments: DiscoveredEnvironments) => void; | ||
| 176 | const pending = new Promise<DiscoveredEnvironments>((resolve) => { | ||
| 177 | resolveRefresh = resolve; | ||
| 178 | }); | ||
| 179 | discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending); | ||
| 180 | await mount(); | ||
| 181 | await advance(5_000); | ||
| 182 | expect(renderer!.root.findByType("p").children).toEqual([ | ||
| 183 | "Waiting for your computer to connect.", | ||
| 184 | ]); | ||
| 185 | |||
| 186 | browserWindow.dispatchEvent(new Event("focus")); | ||
| 187 | page.dispatchEvent(new Event("visibilitychange")); | ||
| 188 | await advance(30_000); | ||
| 189 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); | ||
| 190 | |||
| 191 | await act(async () => renderer!.unmount()); | ||
| 192 | renderer = null; | ||
| 193 | await act(async () => resolveRefresh(new Map())); | ||
| 194 | await advance(30_000); | ||
| 195 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); | ||
| 196 | }); | ||
| 197 | |||
| 198 | it("pauses while offline and resumes when discovery is online", async () => { | ||
| 199 | await mount(); | ||
| 200 | await act(async () => publish({ ...discovery.state!, offline: true })); | ||
| 201 | await advance(30_000); | ||
| 202 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); | ||
| 203 | |||
| 204 | await act(async () => publish({ ...discovery.state!, offline: false })); | ||
| 205 | await advance(5_000); | ||
| 206 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it("does not add polling to other cloud lists", async () => { | ||
| 210 | await mount(false); | ||
| 211 | await advance(30_000); | ||
| 212 | expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); | ||
| 213 | }); | ||
| 214 | }); | ||
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+57-3| ... | @@ -11,7 +11,7 @@ import { | ... | @@ -11,7 +11,7 @@ import { |
| 11 | import type { EnvironmentId } from "@t3tools/contracts"; | 11 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 12 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | 12 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; |
| 13 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import { type ReactNode, useCallback, useEffect, useState } from "react"; | 14 | import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react"; |
| 15 | 15 | ||
| 16 | import { environmentCatalog } from "~/connection/catalog"; | 16 | import { environmentCatalog } from "~/connection/catalog"; |
| 17 | import { cn } from "~/lib/utils"; | 17 | import { cn } from "~/lib/utils"; |
| ... | @@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton"; | ... | @@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton"; |
| 25 | import { toastManager } from "../ui/toast"; | 25 | import { toastManager } from "../ui/toast"; |
| 26 | import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; | 26 | import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; |
| 27 | 27 | ||
| 28 | const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000; | ||
| 29 | |||
| 28 | export interface SavedCloudEnvironmentConnection { | 30 | export interface SavedCloudEnvironmentConnection { |
| 29 | readonly environmentId: EnvironmentId; | 31 | readonly environmentId: EnvironmentId; |
| 30 | readonly connection: EnvironmentConnectionPresentation; | 32 | readonly connection: EnvironmentConnectionPresentation; |
| ... | @@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({ |
| 55 | primaryEnvironmentId, | 57 | primaryEnvironmentId, |
| 56 | savedEnvironments, | 58 | savedEnvironments, |
| 57 | showSavedEnvironments = false, | 59 | showSavedEnvironments = false, |
| 60 | refreshWhileEmpty = false, | ||
| 58 | empty = null, | 61 | empty = null, |
| 59 | }: { | 62 | }: { |
| 60 | readonly primaryEnvironmentId: EnvironmentId | null; | 63 | readonly primaryEnvironmentId: EnvironmentId | null; |
| 61 | readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>; | 64 | readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>; |
| 62 | readonly showSavedEnvironments?: boolean; | 65 | readonly showSavedEnvironments?: boolean; |
| 66 | readonly refreshWhileEmpty?: boolean; | ||
| 63 | readonly empty?: ReactNode; | 67 | readonly empty?: ReactNode; |
| 64 | }) { | 68 | }) { |
| 65 | const environmentsState = useRelayEnvironmentDiscovery(); | 69 | const environmentsState = useRelayEnvironmentDiscovery(); |
| ... | @@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({ |
| 69 | const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { | 73 | const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { |
| 70 | reportFailure: false, | 74 | reportFailure: false, |
| 71 | }); | 75 | }); |
| 76 | const refreshDiscoveryWhenIdle = useEffectEvent(async () => { | ||
| 77 | if (environmentsState.refreshing || environmentsState.offline) return; | ||
| 78 | await refreshRelayEnvironments(); | ||
| 79 | }); | ||
| 72 | const connectRelayEnvironment = useCallback( | 80 | const connectRelayEnvironment = useCallback( |
| 73 | (environment: RelayClientEnvironmentRecord) => | 81 | (environment: RelayClientEnvironmentRecord) => |
| 74 | registerEnvironment( | 82 | registerEnvironment( |
| ... | @@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({ |
| 89 | ); | 97 | ); |
| 90 | 98 | ||
| 91 | useEffect(() => { | 99 | useEffect(() => { |
| 92 | void refreshRelayEnvironments(); | 100 | if (!refreshWhileEmpty || document.visibilityState === "visible") { |
| 93 | }, [refreshRelayEnvironments]); | 101 | void refreshRelayEnvironments(); |
| 102 | } | ||
| 103 | }, [refreshRelayEnvironments, refreshWhileEmpty]); | ||
| 94 | 104 | ||
| 95 | const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { | 105 | const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { |
| 96 | setConnectingEnvironmentId(environment.environmentId); | 106 | setConnectingEnvironmentId(environment.environmentId); |
| ... | @@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({ |
| 132 | environment.environmentId !== primaryEnvironmentId && | 142 | environment.environmentId !== primaryEnvironmentId && |
| 133 | (showSavedEnvironments || !savedById.has(environment.environmentId)), | 143 | (showSavedEnvironments || !savedById.has(environment.environmentId)), |
| 134 | ); | 144 | ); |
| 145 | // Discovery clears its list on refresh, so poll only until a machine appears. | ||
| 146 | const shouldRefreshWhileEmpty = | ||
| 147 | refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; | ||
| 148 | |||
| 149 | useEffect(() => { | ||
| 150 | if (!shouldRefreshWhileEmpty) return; | ||
| 151 | let timer: ReturnType<typeof setTimeout> | undefined; | ||
| 152 | let disposed = false; | ||
| 153 | let pending = false; | ||
| 154 | const visible = () => document.visibilityState === "visible"; | ||
| 155 | const schedule = () => { | ||
| 156 | clearTimeout(timer); | ||
| 157 | if (!disposed && visible()) { | ||
| 158 | timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS); | ||
| 159 | } | ||
| 160 | }; | ||
| 161 | const refresh = async () => { | ||
| 162 | if (disposed || pending || !visible()) return; | ||
| 163 | clearTimeout(timer); | ||
| 164 | pending = true; | ||
| 165 | try { | ||
| 166 | await refreshDiscoveryWhenIdle(); | ||
| 167 | } finally { | ||
| 168 | pending = false; | ||
| 169 | schedule(); | ||
| 170 | } | ||
| 171 | }; | ||
| 172 | const onFocus = () => void refresh(); | ||
| 173 | const onVisibilityChange = () => { | ||
| 174 | clearTimeout(timer); | ||
| 175 | if (visible()) void refresh(); | ||
| 176 | }; | ||
| 177 | |||
| 178 | schedule(); | ||
| 179 | window.addEventListener("focus", onFocus); | ||
| 180 | document.addEventListener("visibilitychange", onVisibilityChange); | ||
| 181 | return () => { | ||
| 182 | disposed = true; | ||
| 183 | clearTimeout(timer); | ||
| 184 | window.removeEventListener("focus", onFocus); | ||
| 185 | document.removeEventListener("visibilitychange", onVisibilityChange); | ||
| 186 | }; | ||
| 187 | }, [shouldRefreshWhileEmpty]); | ||
| 135 | 188 | ||
| 136 | const standalone = showSavedEnvironments || savedEnvironments.length === 0; | 189 | const standalone = showSavedEnvironments || savedEnvironments.length === 0; |
| 137 | 190 | ||
| 138 | if ( | 191 | if ( |
| 192 | !refreshWhileEmpty && | ||
| 139 | standalone && | 193 | standalone && |
| 140 | visibleEnvironments.length === 0 && | 194 | visibleEnvironments.length === 0 && |
| 141 | environmentsState.refreshing && | 195 | environmentsState.refreshing && |
apps/web/src/components/onboarding/FirstRunGate.tsx created+244| ... | @@ -0,0 +1,244 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useLocation, useNavigate } from "@tanstack/react-router"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | import { RotateCcwIcon } from "lucide-react"; | ||
| 5 | import { useEffect, useLayoutEffect, useState } from "react"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | ensureClientSettingsHydrated, | ||
| 9 | useClientSettings, | ||
| 10 | useClientSettingsHydrationStatus, | ||
| 11 | } from "../../hooks/useSettings"; | ||
| 12 | import { mountOnboardingTheme } from "../../hooks/useTheme"; | ||
| 13 | import { useCompleteOnboarding } from "../../onboarding/firstRun"; | ||
| 14 | import { | ||
| 15 | isFirstRunWorkspaceProvenanceAuthoritative, | ||
| 16 | isFreshFirstRunWorkspace, | ||
| 17 | resolveFirstRunDecision, | ||
| 18 | resolveHostedFirstRunDecision, | ||
| 19 | transitionFirstRunGateState, | ||
| 20 | type FirstRunGateState, | ||
| 21 | } from "../../onboarding/firstRun.logic"; | ||
| 22 | import { | ||
| 23 | useAllEnvironmentShellsBootstrapped, | ||
| 24 | useProjects, | ||
| 25 | useThreadShells, | ||
| 26 | } from "../../state/entities"; | ||
| 27 | import { useEnvironments } from "../../state/environments"; | ||
| 28 | import { environmentProjects } from "../../state/projects"; | ||
| 29 | import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; | ||
| 30 | import { environmentShell } from "../../state/shell"; | ||
| 31 | import { environmentThreadShells } from "../../state/threads"; | ||
| 32 | import { Button } from "../ui/button"; | ||
| 33 | |||
| 34 | /** | ||
| 35 | * Holds back authenticated and hosted app trees until the first-run decision | ||
| 36 | * is known, so a fresh install never flashes the main screen before the wizard. | ||
| 37 | * Nothing renders while pending — no shell, no EventRouter (whose welcome | ||
| 38 | * payload would otherwise navigate into a thread), no dialogs. | ||
| 39 | * | ||
| 40 | * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as | ||
| 41 | * settings hydrate (the common case, no server round-trip). A `null` flag also | ||
| 42 | * covers installs that predate the field, so it alone is not enough — the gate | ||
| 43 | * waits for environment shells to bootstrap and inspects the workspace. | ||
| 44 | * Hosted mode instead checks its saved environment catalog. A timeout shows | ||
| 45 | * recovery for an unreachable primary server without mounting the app tree. | ||
| 46 | */ | ||
| 47 | |||
| 48 | const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; | ||
| 49 | |||
| 50 | const primaryShellLiveAtom = Atom.make((get) => { | ||
| 51 | const serverConfig = get(primaryServerConfigAtom); | ||
| 52 | return ( | ||
| 53 | serverConfig !== null && | ||
| 54 | get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" | ||
| 55 | ); | ||
| 56 | }).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); | ||
| 57 | |||
| 58 | const workspaceEvidenceLiveAtom = Atom.make((get) => { | ||
| 59 | const environmentIds = new Set([ | ||
| 60 | ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), | ||
| 61 | ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), | ||
| 62 | ]); | ||
| 63 | |||
| 64 | for (const environmentId of environmentIds) { | ||
| 65 | if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { | ||
| 66 | return false; | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | return true; | ||
| 71 | }).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); | ||
| 72 | |||
| 73 | export function FirstRunGate({ | ||
| 74 | enabled, | ||
| 75 | hostedStatic, | ||
| 76 | children, | ||
| 77 | }: { | ||
| 78 | readonly enabled: boolean; | ||
| 79 | readonly hostedStatic: boolean; | ||
| 80 | readonly children: React.ReactNode; | ||
| 81 | }) { | ||
| 82 | const navigate = useNavigate(); | ||
| 83 | const pathname = useLocation({ select: (location) => location.pathname }); | ||
| 84 | const hydrationStatus = useClientSettingsHydrationStatus(); | ||
| 85 | const hydrated = hydrationStatus === "ready"; | ||
| 86 | const completeOnboarding = useCompleteOnboarding(); | ||
| 87 | const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); | ||
| 88 | const bootstrapped = useAllEnvironmentShellsBootstrapped(); | ||
| 89 | const { environments, isReady: environmentCatalogReady } = useEnvironments(); | ||
| 90 | const projects = useProjects(); | ||
| 91 | const threads = useThreadShells(); | ||
| 92 | const serverConfig = useAtomValue(primaryServerConfigAtom); | ||
| 93 | const serverWelcome = useAtomValue(primaryServerWelcomeAtom); | ||
| 94 | const primaryShellLive = useAtomValue(primaryShellLiveAtom); | ||
| 95 | const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); | ||
| 96 | // Within a session settings stay hydrated, so remounts (e.g. returning from | ||
| 97 | // the wizard) resolve synchronously instead of blanking a frame. | ||
| 98 | const [gateState, setGateState] = useState<FirstRunGateState>(() => ({ | ||
| 99 | decision: | ||
| 100 | (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) | ||
| 101 | ? "app" | ||
| 102 | : "pending", | ||
| 103 | stalled: false, | ||
| 104 | })); | ||
| 105 | const { decision, stalled } = gateState; | ||
| 106 | const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; | ||
| 107 | const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard"; | ||
| 108 | |||
| 109 | useLayoutEffect(() => { | ||
| 110 | if (!ownsOnboardingTheme) return; | ||
| 111 | return mountOnboardingTheme(); | ||
| 112 | }, [ownsOnboardingTheme]); | ||
| 113 | |||
| 114 | // A workspace still counts as fresh when its only content is the server's | ||
| 115 | // own cwd auto-bootstrap: web mode creates a project + thread from cwd at | ||
| 116 | // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no | ||
| 117 | // projects at all" would mean `npx t3` users never see the wizard. Any | ||
| 118 | // other project, more than one thread, or state in a non-primary | ||
| 119 | // environment is real user state — the aggregate hooks span every | ||
| 120 | // environment, and a saved remote's project must never read as "the | ||
| 121 | // bootstrap project" just because its root string matches the primary cwd. | ||
| 122 | const serverCwd = serverConfig?.cwd ?? null; | ||
| 123 | const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; | ||
| 124 | const workspaceFresh = isFreshFirstRunWorkspace({ | ||
| 125 | primaryEnvironmentId, | ||
| 126 | serverCwd, | ||
| 127 | bootstrapProjectId: serverWelcome?.bootstrapProjectId, | ||
| 128 | bootstrapThreadId: serverWelcome?.bootstrapThreadId, | ||
| 129 | bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, | ||
| 130 | bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, | ||
| 131 | projects, | ||
| 132 | threads, | ||
| 133 | }); | ||
| 134 | |||
| 135 | const { decision: nextDecision, persistCompletion } = hostedStatic | ||
| 136 | ? resolveHostedFirstRunDecision({ | ||
| 137 | hydrated, | ||
| 138 | completed: onboardingCompletedAt !== null, | ||
| 139 | catalogReady: environmentCatalogReady, | ||
| 140 | environmentCount: environments.length, | ||
| 141 | }) | ||
| 142 | : resolveFirstRunDecision({ | ||
| 143 | enabled, | ||
| 144 | hydrated, | ||
| 145 | completed: onboardingCompletedAt !== null, | ||
| 146 | bootstrapped, | ||
| 147 | authoritative: primaryShellLive, | ||
| 148 | workspaceAuthoritative: workspaceEvidenceLive, | ||
| 149 | workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 150 | welcomeReceived: serverWelcome !== null, | ||
| 151 | bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, | ||
| 152 | }), | ||
| 153 | catalogReady: environmentCatalogReady, | ||
| 154 | serverConfigAvailable: serverConfig !== null, | ||
| 155 | workspaceFresh, | ||
| 156 | projectCount: projects.length, | ||
| 157 | threadCount: threads.length, | ||
| 158 | }); | ||
| 159 | |||
| 160 | useEffect(() => { | ||
| 161 | if (decision === "wizard" || !hydrated) return; | ||
| 162 | |||
| 163 | if (persistCompletion && onboardingCompletedAt === null) { | ||
| 164 | void completeOnboarding().catch(() => undefined); | ||
| 165 | } | ||
| 166 | |||
| 167 | setGateState((state) => | ||
| 168 | transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), | ||
| 169 | ); | ||
| 170 | }, [ | ||
| 171 | completeOnboarding, | ||
| 172 | decision, | ||
| 173 | hydrated, | ||
| 174 | nextDecision, | ||
| 175 | onboardingCompletedAt, | ||
| 176 | persistCompletion, | ||
| 177 | ]); | ||
| 178 | |||
| 179 | // A stalled server read gets a recovery screen, but never mounts the app. | ||
| 180 | // The timer starts after settings hydrate so slow local hydration does not | ||
| 181 | // show a false connection failure. | ||
| 182 | useEffect(() => { | ||
| 183 | if (!enabled || decision !== "pending" || !hydrated) return; | ||
| 184 | const timer = window.setTimeout( | ||
| 185 | () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), | ||
| 186 | FIRST_RUN_DECISION_TIMEOUT_MS, | ||
| 187 | ); | ||
| 188 | return () => window.clearTimeout(timer); | ||
| 189 | }, [decision, enabled, hydrated]); | ||
| 190 | |||
| 191 | useEffect(() => { | ||
| 192 | if (decision === "wizard" && pathname !== "/welcome") { | ||
| 193 | void navigate({ to: "/welcome", replace: true }); | ||
| 194 | } | ||
| 195 | }, [decision, navigate, pathname]); | ||
| 196 | |||
| 197 | if (settingsReadFailed) { | ||
| 198 | return <FirstRunRecovery reason="settings" retrying={hydrationStatus === "retrying"} />; | ||
| 199 | } | ||
| 200 | if (decision !== "app") { | ||
| 201 | return stalled ? <FirstRunRecovery reason="connection" /> : null; | ||
| 202 | } | ||
| 203 | return children; | ||
| 204 | } | ||
| 205 | |||
| 206 | function FirstRunRecovery({ | ||
| 207 | reason, | ||
| 208 | retrying = false, | ||
| 209 | }: { | ||
| 210 | readonly reason: "settings" | "connection"; | ||
| 211 | readonly retrying?: boolean; | ||
| 212 | }) { | ||
| 213 | const settingsReadFailed = reason === "settings"; | ||
| 214 | return ( | ||
| 215 | <main className="flex h-dvh min-h-0 items-center justify-center bg-background px-6 text-foreground"> | ||
| 216 | <div className="flex max-w-sm flex-col items-center text-center"> | ||
| 217 | <h1 className="text-lg font-semibold"> | ||
| 218 | {settingsReadFailed ? "Could not read settings" : "Still connecting"} | ||
| 219 | </h1> | ||
| 220 | <p className="mt-2 text-sm text-muted-foreground"> | ||
| 221 | {settingsReadFailed | ||
| 222 | ? "Your saved settings could not be loaded." | ||
| 223 | : "T3 Code could not confirm this workspace."} | ||
| 224 | </p> | ||
| 225 | <Button | ||
| 226 | className="mt-5" | ||
| 227 | size="sm" | ||
| 228 | variant="outline" | ||
| 229 | disabled={retrying} | ||
| 230 | onClick={() => { | ||
| 231 | if (settingsReadFailed) { | ||
| 232 | void ensureClientSettingsHydrated().catch(() => undefined); | ||
| 233 | } else { | ||
| 234 | window.location.reload(); | ||
| 235 | } | ||
| 236 | }} | ||
| 237 | > | ||
| 238 | <RotateCcwIcon /> | ||
| 239 | {settingsReadFailed ? "Retry" : "Reload"} | ||
| 240 | </Button> | ||
| 241 | </div> | ||
| 242 | </main> | ||
| 243 | ); | ||
| 244 | } | ||
apps/web/src/components/onboarding/WelcomeWizard.tsx created+1478| ... | @@ -0,0 +1,1478 @@ | ||
| 1 | import { useAuth } from "@clerk/react"; | ||
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 3 | import type { | ||
| 4 | AgentSessionProjectCandidate, | ||
| 5 | EnvironmentId, | ||
| 6 | ProjectId, | ||
| 7 | ScopedProjectRef, | ||
| 8 | ServerConfig, | ||
| 9 | ServerProvider, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 12 | import { | ||
| 13 | isAtomCommandInterrupted, | ||
| 14 | squashAtomCommandFailure, | ||
| 15 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 16 | import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import { | ||
| 19 | ArrowRightIcon, | ||
| 20 | CheckIcon, | ||
| 21 | ChevronLeftIcon, | ||
| 22 | ChevronRightIcon, | ||
| 23 | CloudIcon, | ||
| 24 | CopyIcon, | ||
| 25 | LinkIcon, | ||
| 26 | MonitorIcon, | ||
| 27 | TerminalIcon, | ||
| 28 | type LucideIcon, | ||
| 29 | } from "lucide-react"; | ||
| 30 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | ||
| 31 | |||
| 32 | import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; | ||
| 33 | import { useLocalStorage } from "../../hooks/useLocalStorage"; | ||
| 34 | import { mountOnboardingTheme } from "../../hooks/useTheme"; | ||
| 35 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | ||
| 36 | import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; | ||
| 37 | import { useCompleteOnboarding } from "../../onboarding/firstRun"; | ||
| 38 | import { | ||
| 39 | partitionOnboardingProjects, | ||
| 40 | resolveOnboardingLandingProject, | ||
| 41 | resolveOnboardingProjectId, | ||
| 42 | } from "../../onboarding/projectImport.logic"; | ||
| 43 | import { | ||
| 44 | getOnboardingProviderState, | ||
| 45 | resolveOnboardingProviderLoginCommand, | ||
| 46 | selectOnboardingProvidersByDriver, | ||
| 47 | } from "../../onboarding/providerReadiness.logic"; | ||
| 48 | import { | ||
| 49 | isOnboardingRelayEnvironment, | ||
| 50 | resolveOnboardingTargetEnvironment, | ||
| 51 | } from "../../onboarding/targetEnvironment.logic"; | ||
| 52 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ||
| 53 | import { newProjectId, randomUUID } from "../../lib/utils"; | ||
| 54 | import { resolveDefaultProviderModelSelection } from "../../providerInstances"; | ||
| 55 | import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; | ||
| 56 | import { readProjects, useProjects } from "../../state/entities"; | ||
| 57 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; | ||
| 58 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 59 | import { projectEnvironment } from "../../state/projects"; | ||
| 60 | import { serverEnvironment } from "../../state/server"; | ||
| 61 | import { terminalEnvironment } from "../../state/terminal"; | ||
| 62 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 63 | import { connectPairing } from "../../connection/onboarding"; | ||
| 64 | import { isElectron } from "../../env"; | ||
| 65 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | ||
| 66 | import { getProviderSummary } from "../settings/providerStatus"; | ||
| 67 | import { getDriverOption } from "../settings/providerDriverMeta"; | ||
| 68 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; | ||
| 69 | import { TerminalViewport } from "../ThreadTerminalDrawer"; | ||
| 70 | import { Button } from "../ui/button"; | ||
| 71 | import { Checkbox } from "../ui/checkbox"; | ||
| 72 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | ||
| 73 | import { Input } from "../ui/input"; | ||
| 74 | import { toastManager } from "../ui/toast"; | ||
| 75 | import { cn } from "../../lib/utils"; | ||
| 76 | |||
| 77 | /** | ||
| 78 | * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a | ||
| 79 | * fresh install (no completed-onboarding flag, empty workspace). Flow per the | ||
| 80 | * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → | ||
| 81 | * agent setup with inline install terminal → project import → main screen. | ||
| 82 | * Every step past the connection gate is skippable; the whole wizard is | ||
| 83 | * re-runnable by clearing the flag. | ||
| 84 | */ | ||
| 85 | |||
| 86 | type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import"; | ||
| 87 | |||
| 88 | type ConnectionMode = "local" | "connect" | "direct"; | ||
| 89 | |||
| 90 | /** | ||
| 91 | * The machine the agent and import steps run against. Local mode targets the | ||
| 92 | * primary environment; the remote modes prefer the machine the user just | ||
| 93 | * connected (the most recently added connected non-primary environment), so | ||
| 94 | * probing and import happen where their code lives rather than on the local | ||
| 95 | * server that happens to serve the app. Deliberately not a persisted | ||
| 96 | * "primary machine" concept — just whichever machine fits the chosen path | ||
| 97 | * right now, labeled inline on each step. | ||
| 98 | */ | ||
| 99 | function useOnboardingTargetEnvironment( | ||
| 100 | mode: ConnectionMode, | ||
| 101 | pairedEnvironmentId: EnvironmentId | null, | ||
| 102 | ) { | ||
| 103 | const { environments } = useEnvironments(); | ||
| 104 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 105 | return resolveOnboardingTargetEnvironment({ | ||
| 106 | mode, | ||
| 107 | environments, | ||
| 108 | primaryEnvironment, | ||
| 109 | pairedEnvironmentId, | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | |||
| 113 | const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); | ||
| 114 | const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; | ||
| 115 | const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; | ||
| 116 | |||
| 117 | export function WelcomeWizard({ | ||
| 118 | localAvailable, | ||
| 119 | onDone, | ||
| 120 | }: { | ||
| 121 | /** | ||
| 122 | * Whether the "Local Only" card is offered. True whenever the app is served | ||
| 123 | * by an authenticated primary server — desktop, `npx t3`, or a dev server — | ||
| 124 | * since that server is "this machine" regardless of the hostname the app | ||
| 125 | * was opened from. Only hosted-static (app.t3.codes) has no local server. | ||
| 126 | */ | ||
| 127 | readonly localAvailable: boolean; | ||
| 128 | readonly onDone: (projectRef?: ScopedProjectRef) => void; | ||
| 129 | }) { | ||
| 130 | useLayoutEffect(() => mountOnboardingTheme(), []); | ||
| 131 | const completeOnboarding = useCompleteOnboarding(); | ||
| 132 | const [step, setStep] = useState<WizardStep>("connection"); | ||
| 133 | const [mode, setMode] = useState<ConnectionMode>("local"); | ||
| 134 | const [pairedEnvironmentId, setPairedEnvironmentId] = useState<EnvironmentId | null>(null); | ||
| 135 | const finishingPromiseRef = useRef<Promise<boolean> | null>(null); | ||
| 136 | const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); | ||
| 137 | const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); | ||
| 138 | const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; | ||
| 139 | const finish = useCallback( | ||
| 140 | (projectRef?: ScopedProjectRef) => { | ||
| 141 | if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; | ||
| 142 | if (completionErrorToastIdRef.current !== null) { | ||
| 143 | toastManager.close(completionErrorToastIdRef.current); | ||
| 144 | completionErrorToastIdRef.current = null; | ||
| 145 | } | ||
| 146 | |||
| 147 | const completion = completeOnboarding() | ||
| 148 | .then(() => { | ||
| 149 | if (completionErrorToastIdRef.current !== null) { | ||
| 150 | toastManager.close(completionErrorToastIdRef.current); | ||
| 151 | completionErrorToastIdRef.current = null; | ||
| 152 | } | ||
| 153 | onDone(projectRef); | ||
| 154 | return true; | ||
| 155 | }) | ||
| 156 | .catch(() => { | ||
| 157 | const errorToast = { | ||
| 158 | type: "error", | ||
| 159 | title: "Could not finish setup", | ||
| 160 | description: "Your settings could not be saved. Try again.", | ||
| 161 | } as const; | ||
| 162 | if (completionErrorToastIdRef.current === null) { | ||
| 163 | completionErrorToastIdRef.current = toastManager.add(errorToast); | ||
| 164 | } else { | ||
| 165 | toastManager.update(completionErrorToastIdRef.current, errorToast); | ||
| 166 | } | ||
| 167 | return false; | ||
| 168 | }) | ||
| 169 | .finally(() => { | ||
| 170 | if (finishingPromiseRef.current === completion) { | ||
| 171 | finishingPromiseRef.current = null; | ||
| 172 | } | ||
| 173 | }); | ||
| 174 | finishingPromiseRef.current = completion; | ||
| 175 | return completion; | ||
| 176 | }, | ||
| 177 | [completeOnboarding, onDone], | ||
| 178 | ); | ||
| 179 | |||
| 180 | return ( | ||
| 181 | <div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-black text-foreground"> | ||
| 182 | {isElectron ? ( | ||
| 183 | <div | ||
| 184 | aria-hidden | ||
| 185 | className="drag-region h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 wco:pr-[var(--workspace-native-controls-inset)]" | ||
| 186 | /> | ||
| 187 | ) : null} | ||
| 188 | <main className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"> | ||
| 189 | <div className="mx-auto grid min-h-full w-full max-w-5xl content-center gap-10 px-6 py-12 sm:grid-cols-[170px_minmax(0,1fr)] sm:gap-14 sm:px-10 lg:px-12"> | ||
| 190 | <aside className="flex min-w-0 flex-col justify-between sm:min-h-72"> | ||
| 191 | <nav | ||
| 192 | aria-label="Setup progress" | ||
| 193 | className="flex flex-wrap gap-x-5 gap-y-1 sm:flex-col sm:gap-1" | ||
| 194 | > | ||
| 195 | {ONBOARDING_STAGES.map((stage, index) => ( | ||
| 196 | <div | ||
| 197 | key={stage} | ||
| 198 | aria-current={index === stageIndex ? "step" : undefined} | ||
| 199 | className={cn( | ||
| 200 | "flex min-h-9 items-center gap-2.5 text-sm", | ||
| 201 | index < stageIndex | ||
| 202 | ? "text-success-foreground" | ||
| 203 | : index === stageIndex | ||
| 204 | ? "text-foreground" | ||
| 205 | : "text-muted-foreground/60", | ||
| 206 | )} | ||
| 207 | > | ||
| 208 | {index < stageIndex ? ( | ||
| 209 | <CheckIcon className="size-3.5" /> | ||
| 210 | ) : ( | ||
| 211 | <span className="w-3.5 font-mono text-xs">0{index + 1}</span> | ||
| 212 | )} | ||
| 213 | <span>{stage}</span> | ||
| 214 | </div> | ||
| 215 | ))} | ||
| 216 | </nav> | ||
| 217 | {targetEnvironment ? ( | ||
| 218 | <div className="mt-8 hidden items-center gap-2 text-xs text-muted-foreground sm:flex"> | ||
| 219 | <span className="size-1.5 rounded-full bg-success" /> | ||
| 220 | <span className="truncate">{targetEnvironment.label}</span> | ||
| 221 | </div> | ||
| 222 | ) : null} | ||
| 223 | </aside> | ||
| 224 | |||
| 225 | <section className="min-w-0"> | ||
| 226 | {step === "connection" ? ( | ||
| 227 | <ConnectionStep | ||
| 228 | localAvailable={localAvailable} | ||
| 229 | localLabel={targetEnvironment?.label ?? "This computer"} | ||
| 230 | onLocal={() => { | ||
| 231 | setMode("local"); | ||
| 232 | setPairedEnvironmentId(null); | ||
| 233 | setStep("agents"); | ||
| 234 | }} | ||
| 235 | onConnect={() => { | ||
| 236 | setMode("connect"); | ||
| 237 | setPairedEnvironmentId(null); | ||
| 238 | setStep("connect-machines"); | ||
| 239 | }} | ||
| 240 | onDirect={() => { | ||
| 241 | setMode("direct"); | ||
| 242 | setPairedEnvironmentId(null); | ||
| 243 | setStep("pair-direct"); | ||
| 244 | }} | ||
| 245 | /> | ||
| 246 | ) : step === "connect-machines" ? ( | ||
| 247 | <ConnectMachinesStep | ||
| 248 | onBack={() => setStep("connection")} | ||
| 249 | onContinue={() => setStep("agents")} | ||
| 250 | /> | ||
| 251 | ) : step === "pair-direct" ? ( | ||
| 252 | <PairDirectStep | ||
| 253 | onBack={() => setStep("connection")} | ||
| 254 | onPaired={(environmentId) => { | ||
| 255 | setPairedEnvironmentId(environmentId); | ||
| 256 | setStep("agents"); | ||
| 257 | }} | ||
| 258 | /> | ||
| 259 | ) : step === "agents" ? ( | ||
| 260 | <AgentsStep | ||
| 261 | mode={mode} | ||
| 262 | pairedEnvironmentId={pairedEnvironmentId} | ||
| 263 | onBack={() => | ||
| 264 | setStep( | ||
| 265 | mode === "local" | ||
| 266 | ? "connection" | ||
| 267 | : mode === "connect" | ||
| 268 | ? "connect-machines" | ||
| 269 | : "pair-direct", | ||
| 270 | ) | ||
| 271 | } | ||
| 272 | onContinue={() => setStep("import")} | ||
| 273 | onSkip={() => setStep("import")} | ||
| 274 | /> | ||
| 275 | ) : ( | ||
| 276 | <ImportStep | ||
| 277 | mode={mode} | ||
| 278 | pairedEnvironmentId={pairedEnvironmentId} | ||
| 279 | onBack={() => setStep("agents")} | ||
| 280 | onDone={finish} | ||
| 281 | /> | ||
| 282 | )} | ||
| 283 | </section> | ||
| 284 | </div> | ||
| 285 | </main> | ||
| 286 | </div> | ||
| 287 | ); | ||
| 288 | } | ||
| 289 | |||
| 290 | // ── Step 1: connection choice ──────────────────────────────── | ||
| 291 | |||
| 292 | function ConnectionStep({ | ||
| 293 | localAvailable, | ||
| 294 | localLabel, | ||
| 295 | onLocal, | ||
| 296 | onConnect, | ||
| 297 | onDirect, | ||
| 298 | }: { | ||
| 299 | readonly localAvailable: boolean; | ||
| 300 | readonly localLabel: string; | ||
| 301 | readonly onLocal: () => void; | ||
| 302 | readonly onConnect: () => void; | ||
| 303 | readonly onDirect: () => void; | ||
| 304 | }) { | ||
| 305 | const cloudEnabled = hasCloudPublicConfig(); | ||
| 306 | const [choice, setChoice] = useState<"local" | "connect" | "direct">( | ||
| 307 | localAvailable ? "local" : cloudEnabled ? "connect" : "direct", | ||
| 308 | ); | ||
| 309 | |||
| 310 | const advance = () => { | ||
| 311 | if (choice === "local") onLocal(); | ||
| 312 | else if (choice === "connect") onConnect(); | ||
| 313 | else onDirect(); | ||
| 314 | }; | ||
| 315 | |||
| 316 | return ( | ||
| 317 | <> | ||
| 318 | <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">Where is your code?</h1> | ||
| 319 | <p className="mt-2.5 text-sm text-muted-foreground">Choose where your agents will run.</p> | ||
| 320 | <div className="mt-8 border-t border-border"> | ||
| 321 | {localAvailable ? ( | ||
| 322 | <ConnectionOption | ||
| 323 | icon={MonitorIcon} | ||
| 324 | title="This computer" | ||
| 325 | description={localLabel} | ||
| 326 | truncateDescription | ||
| 327 | detail="No account" | ||
| 328 | selected={choice === "local"} | ||
| 329 | onSelect={() => setChoice("local")} | ||
| 330 | /> | ||
| 331 | ) : null} | ||
| 332 | {cloudEnabled ? ( | ||
| 333 | <ConnectionOption | ||
| 334 | icon={CloudIcon} | ||
| 335 | title="T3 Connect" | ||
| 336 | description="Your computers, wherever you are" | ||
| 337 | detail="Sign in" | ||
| 338 | selected={choice === "connect"} | ||
| 339 | onSelect={() => setChoice("connect")} | ||
| 340 | /> | ||
| 341 | ) : null} | ||
| 342 | <ConnectionOption | ||
| 343 | icon={LinkIcon} | ||
| 344 | title="Pair a server" | ||
| 345 | description="Local network or Tailscale" | ||
| 346 | detail="Pairing link" | ||
| 347 | selected={choice === "direct"} | ||
| 348 | onSelect={() => setChoice("direct")} | ||
| 349 | /> | ||
| 350 | </div> | ||
| 351 | <div className="mt-7 flex justify-end"> | ||
| 352 | <Button className="gap-2" onClick={advance}> | ||
| 353 | Continue | ||
| 354 | <ArrowRightIcon className="size-3.5" /> | ||
| 355 | </Button> | ||
| 356 | </div> | ||
| 357 | </> | ||
| 358 | ); | ||
| 359 | } | ||
| 360 | |||
| 361 | function ConnectionOption({ | ||
| 362 | icon: Icon, | ||
| 363 | title, | ||
| 364 | description, | ||
| 365 | truncateDescription = false, | ||
| 366 | detail, | ||
| 367 | selected, | ||
| 368 | onSelect, | ||
| 369 | }: { | ||
| 370 | readonly icon: LucideIcon; | ||
| 371 | readonly title: string; | ||
| 372 | readonly description: string; | ||
| 373 | readonly truncateDescription?: boolean; | ||
| 374 | readonly detail: string; | ||
| 375 | readonly selected: boolean; | ||
| 376 | readonly onSelect: () => void; | ||
| 377 | }) { | ||
| 378 | return ( | ||
| 379 | <button | ||
| 380 | type="button" | ||
| 381 | aria-pressed={selected} | ||
| 382 | onClick={onSelect} | ||
| 383 | className={cn( | ||
| 384 | "group flex min-h-20 w-full cursor-pointer items-center gap-4 border-b border-border px-1 py-3 text-left transition-colors", | ||
| 385 | "outline-none focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-ring", | ||
| 386 | selected | ||
| 387 | ? "text-foreground" | ||
| 388 | : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", | ||
| 389 | )} | ||
| 390 | > | ||
| 391 | <Icon | ||
| 392 | className={cn("size-[18px]", selected ? "text-success-foreground" : "text-icon-muted")} | ||
| 393 | /> | ||
| 394 | <span className="min-w-0 flex-1"> | ||
| 395 | <span className="block text-sm font-medium">{title}</span> | ||
| 396 | <span | ||
| 397 | className={cn( | ||
| 398 | "mt-1 block text-xs text-muted-foreground", | ||
| 399 | truncateDescription ? "truncate" : "break-words whitespace-normal", | ||
| 400 | )} | ||
| 401 | > | ||
| 402 | {description} | ||
| 403 | </span> | ||
| 404 | </span> | ||
| 405 | <span className="hidden text-xs text-muted-foreground sm:block">{detail}</span> | ||
| 406 | {selected ? ( | ||
| 407 | <CheckIcon className="size-4 text-success-foreground" /> | ||
| 408 | ) : ( | ||
| 409 | <span className="size-4" /> | ||
| 410 | )} | ||
| 411 | </button> | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | |||
| 415 | // ── Step 2: T3 Connect (sign in, then connect machines) ────── | ||
| 416 | |||
| 417 | const CONNECT_LOGIN_COMMAND = "npx t3 connect"; | ||
| 418 | |||
| 419 | /** | ||
| 420 | * Sign-in and machine-connection combined: signed out shows the Clerk prompt, | ||
| 421 | * signed in forks on account state — zero connected machines blocks on the | ||
| 422 | * `npx t3 connect` command and auto-advance is left to the user pressing | ||
| 423 | * Continue once their machine appears; existing machines show a confirmation | ||
| 424 | * list with the command folded away. There is deliberately no "primary | ||
| 425 | * machine" selection. | ||
| 426 | */ | ||
| 427 | function ConnectMachinesStep({ | ||
| 428 | onBack, | ||
| 429 | onContinue, | ||
| 430 | }: { | ||
| 431 | readonly onBack: () => void; | ||
| 432 | readonly onContinue: () => void; | ||
| 433 | }) { | ||
| 434 | // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read | ||
| 435 | // as signed-out mid-transition. | ||
| 436 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 437 | const { openAuthPrompt } = useT3ConnectAuthPrompt(); | ||
| 438 | const { environments } = useEnvironments(); | ||
| 439 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 440 | const savedEnvironments = environments.filter(isOnboardingRelayEnvironment); | ||
| 441 | // Only a live connection counts: a saved-but-offline machine must not show | ||
| 442 | // the "connected" confirmation (the agents step would find nothing to | ||
| 443 | // probe). Its row still renders in the list either way. | ||
| 444 | const hasRemoteMachines = savedEnvironments.some( | ||
| 445 | (environment) => environment.connection.phase === "connected", | ||
| 446 | ); | ||
| 447 | |||
| 448 | if (!isLoaded) { | ||
| 449 | return <StepShell title="Sign in to T3 Connect" onBack={onBack} />; | ||
| 450 | } | ||
| 451 | |||
| 452 | if (!isSignedIn) { | ||
| 453 | return ( | ||
| 454 | <StepShell | ||
| 455 | title="Sign in to T3 Connect" | ||
| 456 | description="Connect your computers with one account." | ||
| 457 | onBack={onBack} | ||
| 458 | > | ||
| 459 | <div className="mt-6"> | ||
| 460 | <Button onClick={openAuthPrompt}>Sign in</Button> | ||
| 461 | </div> | ||
| 462 | </StepShell> | ||
| 463 | ); | ||
| 464 | } | ||
| 465 | |||
| 466 | return ( | ||
| 467 | <StepShell | ||
| 468 | title={hasRemoteMachines ? "Your computers" : "Connect your computer"} | ||
| 469 | description={ | ||
| 470 | hasRemoteMachines | ||
| 471 | ? "Connected to your T3 account." | ||
| 472 | : "Run this command on the computer with your code." | ||
| 473 | } | ||
| 474 | onBack={onBack} | ||
| 475 | > | ||
| 476 | {hasRemoteMachines ? ( | ||
| 477 | <> | ||
| 478 | <div className="mt-6 overflow-hidden border-y border-border"> | ||
| 479 | <CloudEnvironmentConnectRows | ||
| 480 | primaryEnvironmentId={primaryEnvironment?.environmentId ?? null} | ||
| 481 | savedEnvironments={savedEnvironments} | ||
| 482 | showSavedEnvironments | ||
| 483 | empty={null} | ||
| 484 | /> | ||
| 485 | </div> | ||
| 486 | <Collapsible className="mt-4"> | ||
| 487 | <CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"> | ||
| 488 | <ChevronRightIcon className="size-3.5 transition-transform duration-200 group-data-panel-open:rotate-90" /> | ||
| 489 | Add another machine | ||
| 490 | </CollapsibleTrigger> | ||
| 491 | <CollapsiblePanel> | ||
| 492 | <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-2" /> | ||
| 493 | <p className="mt-2 text-xs text-muted-foreground"> | ||
| 494 | Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} | ||
| 495 | <code className="font-mono">npx t3 serve</code>. | ||
| 496 | </p> | ||
| 497 | </CollapsiblePanel> | ||
| 498 | </Collapsible> | ||
| 499 | <div className="mt-7 flex justify-end"> | ||
| 500 | <Button onClick={onContinue}>Continue</Button> | ||
| 501 | </div> | ||
| 502 | </> | ||
| 503 | ) : ( | ||
| 504 | <> | ||
| 505 | <CommandBlock command={CONNECT_LOGIN_COMMAND} className="mt-7" prominent /> | ||
| 506 | <p className="mt-2 text-xs text-muted-foreground"> | ||
| 507 | Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} | ||
| 508 | <code className="font-mono">npx t3 serve</code>. | ||
| 509 | </p> | ||
| 510 | <div className="mt-5 overflow-hidden border-y border-border"> | ||
| 511 | <CloudEnvironmentConnectRows | ||
| 512 | primaryEnvironmentId={primaryEnvironment?.environmentId ?? null} | ||
| 513 | savedEnvironments={savedEnvironments} | ||
| 514 | showSavedEnvironments | ||
| 515 | refreshWhileEmpty | ||
| 516 | empty={ | ||
| 517 | <p className="px-1 py-4 text-xs text-muted-foreground"> | ||
| 518 | Waiting for your computer to connect. | ||
| 519 | </p> | ||
| 520 | } | ||
| 521 | /> | ||
| 522 | </div> | ||
| 523 | <div className="mt-7 flex flex-wrap items-center justify-between gap-3"> | ||
| 524 | <Button variant="ghost-muted" onClick={onContinue}> | ||
| 525 | Skip for now | ||
| 526 | </Button> | ||
| 527 | <div className="flex flex-wrap items-center justify-end gap-3"> | ||
| 528 | <span className="hidden text-xs text-muted-foreground sm:block"> | ||
| 529 | Waiting for connection | ||
| 530 | </span> | ||
| 531 | <Button disabled>Continue</Button> | ||
| 532 | </div> | ||
| 533 | </div> | ||
| 534 | </> | ||
| 535 | )} | ||
| 536 | </StepShell> | ||
| 537 | ); | ||
| 538 | } | ||
| 539 | |||
| 540 | // ── Step 2′: Direct pairing ────────────────────────────────── | ||
| 541 | |||
| 542 | /** | ||
| 543 | * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the | ||
| 544 | * server, paste the URL here. Registers the remote environment in this | ||
| 545 | * browser's catalog (same path the hosted /pair surface uses). | ||
| 546 | */ | ||
| 547 | function PairDirectStep({ | ||
| 548 | onBack, | ||
| 549 | onPaired, | ||
| 550 | }: { | ||
| 551 | readonly onBack: () => void; | ||
| 552 | readonly onPaired: (environmentId: EnvironmentId) => void; | ||
| 553 | }) { | ||
| 554 | const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); | ||
| 555 | const [pairingUrl, setPairingUrl] = useState(""); | ||
| 556 | const [errorMessage, setErrorMessage] = useState(""); | ||
| 557 | const [isPairing, setIsPairing] = useState(false); | ||
| 558 | const mountedRef = useRef(true); | ||
| 559 | |||
| 560 | useEffect(() => { | ||
| 561 | mountedRef.current = true; | ||
| 562 | return () => { | ||
| 563 | mountedRef.current = false; | ||
| 564 | }; | ||
| 565 | }, []); | ||
| 566 | |||
| 567 | const submit = async () => { | ||
| 568 | setIsPairing(true); | ||
| 569 | setErrorMessage(""); | ||
| 570 | const result = await connectPairingEnvironment({ pairingUrl }); | ||
| 571 | if (!mountedRef.current) return; | ||
| 572 | setIsPairing(false); | ||
| 573 | if (result._tag === "Success") { | ||
| 574 | onPaired(result.value); | ||
| 575 | return; | ||
| 576 | } | ||
| 577 | if (isAtomCommandInterrupted(result)) return; | ||
| 578 | const cause = squashAtomCommandFailure(result); | ||
| 579 | setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed."); | ||
| 580 | }; | ||
| 581 | |||
| 582 | return ( | ||
| 583 | <StepShell | ||
| 584 | title="Pair a server" | ||
| 585 | description="Connect with a one-time pairing link." | ||
| 586 | onBack={onBack} | ||
| 587 | > | ||
| 588 | <div className="mt-7 space-y-5"> | ||
| 589 | <div> | ||
| 590 | <p className="text-sm text-muted-foreground"> | ||
| 591 | <span className="font-mono text-muted-foreground/70">01</span> Run this on your server | ||
| 592 | </p> | ||
| 593 | <CommandBlock command="npx t3 pair" className="mt-2" /> | ||
| 594 | <p className="mt-2 text-xs text-muted-foreground"> | ||
| 595 | Start the server with <code className="font-mono">npx t3 serve</code> first. Add{" "} | ||
| 596 | <code className="font-mono">--tailscale</code> to use your tailnet. | ||
| 597 | </p> | ||
| 598 | </div> | ||
| 599 | <div> | ||
| 600 | <label className="block text-sm text-muted-foreground" htmlFor="onboarding-pairing-url"> | ||
| 601 | <span className="font-mono text-muted-foreground/70">02</span> Paste the pairing link | ||
| 602 | </label> | ||
| 603 | <Input | ||
| 604 | id="onboarding-pairing-url" | ||
| 605 | className="mt-2" | ||
| 606 | size="lg" | ||
| 607 | autoCapitalize="none" | ||
| 608 | autoComplete="off" | ||
| 609 | autoCorrect="off" | ||
| 610 | spellCheck={false} | ||
| 611 | nativeInput | ||
| 612 | disabled={isPairing} | ||
| 613 | placeholder="https://your-server:5230/pair#token=…" | ||
| 614 | value={pairingUrl} | ||
| 615 | onChange={(event) => setPairingUrl(event.currentTarget.value)} | ||
| 616 | onKeyDown={(event) => { | ||
| 617 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; | ||
| 618 | if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit(); | ||
| 619 | }} | ||
| 620 | /> | ||
| 621 | </div> | ||
| 622 | {errorMessage ? ( | ||
| 623 | <div className="rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive"> | ||
| 624 | {errorMessage} | ||
| 625 | </div> | ||
| 626 | ) : null} | ||
| 627 | </div> | ||
| 628 | <div className="mt-7 flex justify-end"> | ||
| 629 | <Button | ||
| 630 | disabled={isPairing || pairingUrl.trim().length === 0} | ||
| 631 | onClick={() => void submit()} | ||
| 632 | > | ||
| 633 | {isPairing ? "Pairing..." : "Connect"} | ||
| 634 | </Button> | ||
| 635 | </div> | ||
| 636 | </StepShell> | ||
| 637 | ); | ||
| 638 | } | ||
| 639 | |||
| 640 | // ── Step 3: agents ─────────────────────────────────────────── | ||
| 641 | |||
| 642 | const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; | ||
| 643 | type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; | ||
| 644 | |||
| 645 | const AGENT_INSTALL_COMMANDS: Record<OnboardingAgentDriver, string> = { | ||
| 646 | claudeAgent: "npm install -g @anthropic-ai/claude-code", | ||
| 647 | codex: "npm install -g @openai/codex", | ||
| 648 | }; | ||
| 649 | |||
| 650 | /** Setup values stay fixed while provider probes refresh the surrounding cards. */ | ||
| 651 | interface AgentTerminalSession { | ||
| 652 | readonly environmentId: EnvironmentId; | ||
| 653 | readonly driver: OnboardingAgentDriver; | ||
| 654 | readonly providerInstanceId: ServerProvider["instanceId"]; | ||
| 655 | readonly cwd: string; | ||
| 656 | readonly command: string; | ||
| 657 | readonly keybindings: ServerConfig["keybindings"]; | ||
| 658 | } | ||
| 659 | |||
| 660 | /** | ||
| 661 | * Claude Code and Codex use live probe status. Install opens the built-in terminal inline | ||
| 662 | * with the command pre-typed — the update RPC can't install a binary that | ||
| 663 | * isn't there yet (it infers the package manager from the installed binary's | ||
| 664 | * path), and the terminal also handles the interactive login that follows. | ||
| 665 | */ | ||
| 666 | function AgentsStep({ | ||
| 667 | mode, | ||
| 668 | pairedEnvironmentId, | ||
| 669 | onBack, | ||
| 670 | onContinue, | ||
| 671 | onSkip, | ||
| 672 | }: { | ||
| 673 | readonly mode: ConnectionMode; | ||
| 674 | readonly pairedEnvironmentId: EnvironmentId | null; | ||
| 675 | readonly onBack: () => void; | ||
| 676 | readonly onContinue: () => void; | ||
| 677 | readonly onSkip: () => void; | ||
| 678 | }) { | ||
| 679 | const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); | ||
| 680 | if (targetEnvironment === null) { | ||
| 681 | return ( | ||
| 682 | <StepShell | ||
| 683 | title="Your agents" | ||
| 684 | description="Waiting for this computer to connect." | ||
| 685 | onBack={onBack} | ||
| 686 | > | ||
| 687 | <div className="mt-6 flex justify-end"> | ||
| 688 | <Button variant="ghost-muted" onClick={onSkip}> | ||
| 689 | Skip for now | ||
| 690 | </Button> | ||
| 691 | </div> | ||
| 692 | </StepShell> | ||
| 693 | ); | ||
| 694 | } | ||
| 695 | return ( | ||
| 696 | <ConnectedAgentsStep | ||
| 697 | key={targetEnvironment.environmentId} | ||
| 698 | environmentId={targetEnvironment.environmentId} | ||
| 699 | machineLabel={targetEnvironment.label} | ||
| 700 | onBack={onBack} | ||
| 701 | onContinue={onContinue} | ||
| 702 | onSkip={onSkip} | ||
| 703 | /> | ||
| 704 | ); | ||
| 705 | } | ||
| 706 | |||
| 707 | function ConnectedAgentsStep({ | ||
| 708 | environmentId, | ||
| 709 | machineLabel, | ||
| 710 | onBack, | ||
| 711 | onContinue, | ||
| 712 | onSkip, | ||
| 713 | }: { | ||
| 714 | readonly environmentId: EnvironmentId; | ||
| 715 | readonly machineLabel: string; | ||
| 716 | readonly onBack: () => void; | ||
| 717 | readonly onContinue: () => void; | ||
| 718 | readonly onSkip: () => void; | ||
| 719 | }) { | ||
| 720 | const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); | ||
| 721 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | ||
| 722 | reportFailure: false, | ||
| 723 | }); | ||
| 724 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | ||
| 725 | const [terminalSession, setTerminalSession] = useState<AgentTerminalSession | null>(null); | ||
| 726 | |||
| 727 | // Re-probe on entry so freshly installed CLIs show up without a manual | ||
| 728 | // refresh; harmless when nothing changed (single-flighted per environment). | ||
| 729 | useEffect(() => { | ||
| 730 | void refreshProviders({ environmentId, input: {} }); | ||
| 731 | }, [environmentId, refreshProviders]); | ||
| 732 | |||
| 733 | const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); | ||
| 734 | |||
| 735 | const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ | ||
| 736 | driver, | ||
| 737 | provider: byDriver.get(driver), | ||
| 738 | })); | ||
| 739 | const readyCount = primaryAgents.filter( | ||
| 740 | ({ provider }) => getOnboardingProviderState(provider) === "ready", | ||
| 741 | ).length; | ||
| 742 | return ( | ||
| 743 | <StepShell title="Your agents" description={`Detected on ${machineLabel}.`} onBack={onBack}> | ||
| 744 | <div className="mt-7 border-t border-border"> | ||
| 745 | {primaryAgents.map(({ driver, provider }) => ( | ||
| 746 | <AgentCard | ||
| 747 | key={driver} | ||
| 748 | driver={driver} | ||
| 749 | provider={provider} | ||
| 750 | terminalOpen={terminalSession?.driver === driver} | ||
| 751 | terminalAvailable={serverConfig !== null} | ||
| 752 | onOpenTerminal={() => { | ||
| 753 | if (provider === undefined || serverConfig === null) return; | ||
| 754 | setTerminalSession({ | ||
| 755 | environmentId, | ||
| 756 | driver, | ||
| 757 | providerInstanceId: provider.instanceId, | ||
| 758 | cwd: serverConfig.cwd, | ||
| 759 | command: provider.installed | ||
| 760 | ? resolveOnboardingProviderLoginCommand( | ||
| 761 | provider, | ||
| 762 | serverConfig.settings, | ||
| 763 | serverConfig.environment.platform.os, | ||
| 764 | ) | ||
| 765 | : AGENT_INSTALL_COMMANDS[driver], | ||
| 766 | keybindings: serverConfig.keybindings, | ||
| 767 | }); | ||
| 768 | }} | ||
| 769 | /> | ||
| 770 | ))} | ||
| 771 | </div> | ||
| 772 | {terminalSession !== null ? ( | ||
| 773 | <AgentInstallTerminal | ||
| 774 | key={`${terminalSession.environmentId}:${terminalSession.providerInstanceId}:${terminalSession.driver}`} | ||
| 775 | session={terminalSession} | ||
| 776 | onClose={() => { | ||
| 777 | setTerminalSession(null); | ||
| 778 | void refreshProviders({ environmentId, input: {} }); | ||
| 779 | }} | ||
| 780 | /> | ||
| 781 | ) : null} | ||
| 782 | <div className="mt-7 flex flex-wrap items-center justify-between gap-3"> | ||
| 783 | <Button variant="ghost-muted" onClick={onSkip}> | ||
| 784 | Skip | ||
| 785 | </Button> | ||
| 786 | <div className="flex flex-wrap items-center justify-end gap-3"> | ||
| 787 | <span className="text-xs text-muted-foreground"> | ||
| 788 | {readyCount} of {primaryAgents.length} ready | ||
| 789 | </span> | ||
| 790 | <Button className="gap-2" onClick={onContinue}> | ||
| 791 | Continue | ||
| 792 | <ArrowRightIcon className="size-3.5" /> | ||
| 793 | </Button> | ||
| 794 | </div> | ||
| 795 | </div> | ||
| 796 | </StepShell> | ||
| 797 | ); | ||
| 798 | } | ||
| 799 | |||
| 800 | function AgentCard({ | ||
| 801 | driver, | ||
| 802 | provider, | ||
| 803 | terminalOpen, | ||
| 804 | terminalAvailable, | ||
| 805 | onOpenTerminal, | ||
| 806 | }: { | ||
| 807 | readonly driver: OnboardingAgentDriver; | ||
| 808 | readonly provider: ServerProvider | undefined; | ||
| 809 | readonly terminalOpen: boolean; | ||
| 810 | readonly terminalAvailable: boolean; | ||
| 811 | readonly onOpenTerminal: () => void; | ||
| 812 | }) { | ||
| 813 | const meta = getDriverOption(ProviderDriverKind.make(driver)); | ||
| 814 | const Icon = meta?.icon; | ||
| 815 | const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); | ||
| 816 | const summary = getProviderSummary(provider); | ||
| 817 | const providerState = getOnboardingProviderState(provider); | ||
| 818 | |||
| 819 | return ( | ||
| 820 | <div className="flex min-h-20 items-center gap-4 border-b border-border py-3"> | ||
| 821 | {Icon ? ( | ||
| 822 | <Icon className={cn("size-6 shrink-0", driver !== "claudeAgent" && "fill-foreground")} /> | ||
| 823 | ) : null} | ||
| 824 | <div className="min-w-0 flex-1"> | ||
| 825 | <span className="block text-sm font-medium text-foreground">{displayName}</span> | ||
| 826 | <p className="mt-1 truncate text-xs text-muted-foreground"> | ||
| 827 | {summary.headline} | ||
| 828 | {summary.detail ? ` · ${summary.detail}` : ""} | ||
| 829 | </p> | ||
| 830 | </div> | ||
| 831 | <div className="shrink-0"> | ||
| 832 | {providerState === "ready" ? ( | ||
| 833 | <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground"> | ||
| 834 | <CheckIcon className="size-3.5" /> | ||
| 835 | Ready | ||
| 836 | </span> | ||
| 837 | ) : providerState === "checking" ? ( | ||
| 838 | <span className="text-xs text-muted-foreground">Checking...</span> | ||
| 839 | ) : providerState === "disabled" ? ( | ||
| 840 | <span className="text-xs text-muted-foreground">Disabled</span> | ||
| 841 | ) : providerState === "attention" ? ( | ||
| 842 | <span className="text-xs text-muted-foreground">{summary.headline}</span> | ||
| 843 | ) : ( | ||
| 844 | <Button | ||
| 845 | size="xs" | ||
| 846 | variant="ghost" | ||
| 847 | onClick={onOpenTerminal} | ||
| 848 | disabled={terminalOpen || !terminalAvailable} | ||
| 849 | > | ||
| 850 | <TerminalIcon className="size-3.5" /> | ||
| 851 | {providerState === "signIn" ? "Sign in" : "Install"} | ||
| 852 | </Button> | ||
| 853 | )} | ||
| 854 | </div> | ||
| 855 | </div> | ||
| 856 | ); | ||
| 857 | } | ||
| 858 | |||
| 859 | /** | ||
| 860 | * Inline install terminal. Opens a PTY on the connected environment under a | ||
| 861 | * synthetic onboarding thread id (terminals are keyed by free-form thread id; | ||
| 862 | * the server validates only the cwd) and pre-types the install or login | ||
| 863 | * command without submitting, so the user reviews and presses Enter. | ||
| 864 | */ | ||
| 865 | function AgentInstallTerminal({ | ||
| 866 | session, | ||
| 867 | onClose, | ||
| 868 | }: { | ||
| 869 | readonly session: AgentTerminalSession; | ||
| 870 | readonly onClose: () => void; | ||
| 871 | }) { | ||
| 872 | const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session; | ||
| 873 | // Same terminal typography preference the thread drawer honors. | ||
| 874 | const [advancedTypography] = useLocalStorage( | ||
| 875 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, | ||
| 876 | false, | ||
| 877 | Schema.Boolean, | ||
| 878 | ); | ||
| 879 | const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); | ||
| 880 | const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); | ||
| 881 | const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); | ||
| 882 | const setupQueueRef = useRef(Promise.resolve()); | ||
| 883 | const setupGenerationRef = useRef(0); | ||
| 884 | const activeSetupGenerationRef = useRef<number | null>(null); | ||
| 885 | const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`); | ||
| 886 | const threadRef = useMemo( | ||
| 887 | () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), | ||
| 888 | [environmentId], | ||
| 889 | ); | ||
| 890 | const [setupAttempt, setSetupAttempt] = useState(0); | ||
| 891 | const [setupState, setSetupState] = useState< | ||
| 892 | "preparing" | "ready" | "openFailed" | "writeFailed" | ||
| 893 | >("preparing"); | ||
| 894 | const terminalReady = setupState === "ready" || setupState === "writeFailed"; | ||
| 895 | |||
| 896 | // Keep each setup generation distinct. In Strict Mode, a canceled open can | ||
| 897 | // finish after the replacement setup starts; it must not close or pre-type | ||
| 898 | // into the replacement session that shares this terminal id. | ||
| 899 | useEffect(() => { | ||
| 900 | const generation = setupGenerationRef.current + 1; | ||
| 901 | setupGenerationRef.current = generation; | ||
| 902 | activeSetupGenerationRef.current = generation; | ||
| 903 | setSetupState("preparing"); | ||
| 904 | |||
| 905 | setupQueueRef.current = setupQueueRef.current.then(async () => { | ||
| 906 | if (activeSetupGenerationRef.current !== generation) return; | ||
| 907 | const opened = await openTerminal({ | ||
| 908 | environmentId, | ||
| 909 | input: { | ||
| 910 | threadId: AGENT_ONBOARDING_THREAD_ID, | ||
| 911 | terminalId, | ||
| 912 | cwd, | ||
| 913 | providerInstanceId, | ||
| 914 | }, | ||
| 915 | }); | ||
| 916 | if (opened._tag !== "Success") { | ||
| 917 | if (activeSetupGenerationRef.current === generation) setSetupState("openFailed"); | ||
| 918 | return; | ||
| 919 | } | ||
| 920 | |||
| 921 | if (activeSetupGenerationRef.current !== generation) return; | ||
| 922 | |||
| 923 | const wrote = await writeTerminal({ | ||
| 924 | environmentId, | ||
| 925 | input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, | ||
| 926 | }); | ||
| 927 | if (activeSetupGenerationRef.current !== generation) return; | ||
| 928 | setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed"); | ||
| 929 | }); | ||
| 930 | |||
| 931 | // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, | ||
| 932 | // session exit), so this cleanup is the single place the PTY dies — | ||
| 933 | // nothing is left running behind the wizard. An interrupted install is | ||
| 934 | // re-runnable from the card. | ||
| 935 | return () => { | ||
| 936 | if (activeSetupGenerationRef.current === generation) { | ||
| 937 | activeSetupGenerationRef.current = null; | ||
| 938 | } | ||
| 939 | setupQueueRef.current = setupQueueRef.current.then(async () => { | ||
| 940 | await closeTerminal({ | ||
| 941 | environmentId, | ||
| 942 | input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true }, | ||
| 943 | }); | ||
| 944 | }); | ||
| 945 | }; | ||
| 946 | }, [ | ||
| 947 | closeTerminal, | ||
| 948 | command, | ||
| 949 | cwd, | ||
| 950 | environmentId, | ||
| 951 | openTerminal, | ||
| 952 | providerInstanceId, | ||
| 953 | setupAttempt, | ||
| 954 | terminalId, | ||
| 955 | writeTerminal, | ||
| 956 | ]); | ||
| 957 | |||
| 958 | return ( | ||
| 959 | <div className="thread-terminal-drawer mt-4 overflow-hidden rounded-lg border border-border/70 bg-background text-foreground"> | ||
| 960 | <div className="flex items-center justify-between border-b border-border/60 bg-background/60 px-3 py-1.5"> | ||
| 961 | <span className="text-[11px] font-medium text-muted-foreground"> | ||
| 962 | {setupState === "writeFailed" ? ( | ||
| 963 | <> | ||
| 964 | Run <code className="rounded bg-muted px-1 font-mono">{command}</code> in this | ||
| 965 | terminal. | ||
| 966 | </> | ||
| 967 | ) : setupState === "ready" ? ( | ||
| 968 | "Review the command, then press Enter to run it." | ||
| 969 | ) : setupState === "openFailed" ? ( | ||
| 970 | "Could not open the setup terminal." | ||
| 971 | ) : ( | ||
| 972 | "Preparing command..." | ||
| 973 | )} | ||
| 974 | </span> | ||
| 975 | <div className="flex items-center gap-1"> | ||
| 976 | {setupState === "openFailed" ? ( | ||
| 977 | <Button size="xs" variant="ghost" onClick={() => setSetupAttempt((value) => value + 1)}> | ||
| 978 | Retry | ||
| 979 | </Button> | ||
| 980 | ) : null} | ||
| 981 | <Button size="xs" variant="ghost-muted" onClick={onClose}> | ||
| 982 | Close | ||
| 983 | </Button> | ||
| 984 | </div> | ||
| 985 | </div> | ||
| 986 | <div className="h-64"> | ||
| 987 | {terminalReady ? ( | ||
| 988 | <TerminalViewport | ||
| 989 | threadRef={threadRef} | ||
| 990 | threadId={AGENT_ONBOARDING_THREAD_ID} | ||
| 991 | terminalId={terminalId} | ||
| 992 | terminalLabel={`Install ${driver}`} | ||
| 993 | cwd={cwd} | ||
| 994 | providerInstanceId={providerInstanceId} | ||
| 995 | advancedTypography={advancedTypography} | ||
| 996 | onSessionExited={onClose} | ||
| 997 | focusRequestId={1} | ||
| 998 | autoFocus | ||
| 999 | visible | ||
| 1000 | resizeEpoch={0} | ||
| 1001 | drawerHeight={256} | ||
| 1002 | keybindings={keybindings} | ||
| 1003 | /> | ||
| 1004 | ) : null} | ||
| 1005 | </div> | ||
| 1006 | </div> | ||
| 1007 | ); | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | // ── Step 4: import ─────────────────────────────────────────── | ||
| 1011 | |||
| 1012 | /** | ||
| 1013 | * One-decision import (4B): a summary line with Import recent / Choose / | ||
| 1014 | * Skip. The default imports only projects touched in the last 30 days; | ||
| 1015 | * Choose expands a checklist including older ones. Imported projects also | ||
| 1016 | * receive Codex and Claude threads active within the last 30 days. | ||
| 1017 | */ | ||
| 1018 | function ImportStep({ | ||
| 1019 | mode, | ||
| 1020 | pairedEnvironmentId, | ||
| 1021 | onBack, | ||
| 1022 | onDone, | ||
| 1023 | }: { | ||
| 1024 | readonly mode: ConnectionMode; | ||
| 1025 | readonly pairedEnvironmentId: EnvironmentId | null; | ||
| 1026 | readonly onBack: () => void; | ||
| 1027 | readonly onDone: (projectRef?: ScopedProjectRef) => Promise<boolean>; | ||
| 1028 | }) { | ||
| 1029 | const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); | ||
| 1030 | const environmentId = targetEnvironment?.environmentId ?? null; | ||
| 1031 | const machineLabel = targetEnvironment?.label ?? "this machine"; | ||
| 1032 | const providers = useAtomValue( | ||
| 1033 | serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), | ||
| 1034 | ); | ||
| 1035 | const scan = useEnvironmentQuery( | ||
| 1036 | environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), | ||
| 1037 | ); | ||
| 1038 | const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); | ||
| 1039 | const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); | ||
| 1040 | const projects = useProjects(); | ||
| 1041 | const [choosing, setChoosing] = useState(false); | ||
| 1042 | const [deselected, setDeselected] = useState<ReadonlySet<string>>(new Set()); | ||
| 1043 | const [isImporting, setIsImporting] = useState(false); | ||
| 1044 | const [importError, setImportError] = useState(""); | ||
| 1045 | const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null); | ||
| 1046 | // Keep project creation attempts separate from completed history imports so both can retry. | ||
| 1047 | const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>()); | ||
| 1048 | const projectsWithImportedHistoryRef = useRef(new Map<string, ScopedProjectRef>()); | ||
| 1049 | const lastImportSelectionRef = useRef<ReadonlyArray<string>>([]); | ||
| 1050 | const projectAttemptsRef = useRef( | ||
| 1051 | new Map<string, { readonly projectId: ProjectId; readonly commandId: CommandId }>(), | ||
| 1052 | ); | ||
| 1053 | const importGenerationRef = useRef(0); | ||
| 1054 | |||
| 1055 | // Candidate paths are per-environment; a target switch would otherwise | ||
| 1056 | // leave stale entries in the deselection set (and stale success records). | ||
| 1057 | useEffect(() => { | ||
| 1058 | importGenerationRef.current += 1; | ||
| 1059 | setDeselected(new Set()); | ||
| 1060 | setIsImporting(false); | ||
| 1061 | setImportError(""); | ||
| 1062 | setLandingProject(null); | ||
| 1063 | importedProjectsRef.current = new Map(); | ||
| 1064 | projectsWithImportedHistoryRef.current = new Map(); | ||
| 1065 | lastImportSelectionRef.current = []; | ||
| 1066 | projectAttemptsRef.current = new Map(); | ||
| 1067 | return () => { | ||
| 1068 | importGenerationRef.current += 1; | ||
| 1069 | }; | ||
| 1070 | }, [environmentId]); | ||
| 1071 | |||
| 1072 | useEffect(() => { | ||
| 1073 | if ( | ||
| 1074 | landingProject !== null && | ||
| 1075 | landingProject.environmentId === environmentId && | ||
| 1076 | projects.some( | ||
| 1077 | (project) => | ||
| 1078 | project.id === landingProject.projectId && | ||
| 1079 | project.environmentId === landingProject.environmentId, | ||
| 1080 | ) | ||
| 1081 | ) { | ||
| 1082 | setLandingProject(null); | ||
| 1083 | void onDone(landingProject).then((completed) => { | ||
| 1084 | if (!completed) setIsImporting(false); | ||
| 1085 | }); | ||
| 1086 | } | ||
| 1087 | }, [environmentId, landingProject, onDone, projects]); | ||
| 1088 | |||
| 1089 | const { available: candidates, recent } = useMemo( | ||
| 1090 | () => partitionOnboardingProjects(scan.data?.candidates ?? []), | ||
| 1091 | [scan.data], | ||
| 1092 | ); | ||
| 1093 | const more = candidates.length - recent.length; | ||
| 1094 | const scanTruncated = scan.data?.truncated === true; | ||
| 1095 | const scanLimitNotice = scanTruncated ? ( | ||
| 1096 | <p className="mt-3 text-sm text-muted-foreground" role="status"> | ||
| 1097 | {SCAN_LIMIT_MESSAGE} | ||
| 1098 | </p> | ||
| 1099 | ) : null; | ||
| 1100 | |||
| 1101 | const finishAfterImport = () => { | ||
| 1102 | const projectRef = resolveOnboardingLandingProject( | ||
| 1103 | lastImportSelectionRef.current, | ||
| 1104 | projectsWithImportedHistoryRef.current, | ||
| 1105 | importedProjectsRef.current, | ||
| 1106 | ); | ||
| 1107 | if (projectRef === undefined) { | ||
| 1108 | void onDone(); | ||
| 1109 | return; | ||
| 1110 | } | ||
| 1111 | setIsImporting(true); | ||
| 1112 | setLandingProject(projectRef); | ||
| 1113 | }; | ||
| 1114 | |||
| 1115 | const runImport = async (selection: ReadonlyArray<AgentSessionProjectCandidate>) => { | ||
| 1116 | if (environmentId === null || selection.length === 0) { | ||
| 1117 | void onDone(); | ||
| 1118 | return; | ||
| 1119 | } | ||
| 1120 | setIsImporting(true); | ||
| 1121 | setImportError(""); | ||
| 1122 | lastImportSelectionRef.current = selection.map((candidate) => candidate.path); | ||
| 1123 | const importGeneration = importGenerationRef.current; | ||
| 1124 | const importedProjects = importedProjectsRef.current; | ||
| 1125 | const projectAttempts = projectAttemptsRef.current; | ||
| 1126 | const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); | ||
| 1127 | // Interrupted imports are neither failures nor successes — the command was | ||
| 1128 | // superseded or the environment dropped — but they still didn't land, so | ||
| 1129 | // they must not read as "imported everything". Retries skip paths that | ||
| 1130 | // already landed this session (re-creating them would only trip the | ||
| 1131 | // duplicate-root invariant and read as a failure). | ||
| 1132 | let importedProjectsCount = | ||
| 1133 | importedProjects.size > 0 | ||
| 1134 | ? selection.filter((candidate) => importedProjects.has(candidate.path)).length | ||
| 1135 | : 0; | ||
| 1136 | let importedThreadCount = 0; | ||
| 1137 | let skippedThreadCount = 0; | ||
| 1138 | let shouldRefreshScan = false; | ||
| 1139 | for (const candidate of selection) { | ||
| 1140 | if ( | ||
| 1141 | importGeneration !== importGenerationRef.current || | ||
| 1142 | importedProjects !== importedProjectsRef.current | ||
| 1143 | ) { | ||
| 1144 | return; | ||
| 1145 | } | ||
| 1146 | if (importedProjects.has(candidate.path)) continue; | ||
| 1147 | let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); | ||
| 1148 | if (projectId === null) { | ||
| 1149 | let attempt = projectAttempts.get(candidate.path); | ||
| 1150 | if (attempt === undefined) { | ||
| 1151 | const nextProjectId = newProjectId(); | ||
| 1152 | attempt = { | ||
| 1153 | projectId: nextProjectId, | ||
| 1154 | commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`), | ||
| 1155 | }; | ||
| 1156 | projectAttempts.set(candidate.path, attempt); | ||
| 1157 | } | ||
| 1158 | projectId = attempt.projectId; | ||
| 1159 | const result = await createProject({ | ||
| 1160 | environmentId, | ||
| 1161 | input: { | ||
| 1162 | projectId, | ||
| 1163 | commandId: attempt.commandId, | ||
| 1164 | title: candidate.title, | ||
| 1165 | workspaceRoot: candidate.path, | ||
| 1166 | createWorkspaceRootIfMissing: false, | ||
| 1167 | defaultModelSelection, | ||
| 1168 | }, | ||
| 1169 | }); | ||
| 1170 | if ( | ||
| 1171 | importGeneration !== importGenerationRef.current || | ||
| 1172 | importedProjects !== importedProjectsRef.current | ||
| 1173 | ) { | ||
| 1174 | return; | ||
| 1175 | } | ||
| 1176 | if (result._tag !== "Success") { | ||
| 1177 | if (!isAtomCommandInterrupted(result)) { | ||
| 1178 | projectAttempts.delete(candidate.path); | ||
| 1179 | shouldRefreshScan = true; | ||
| 1180 | } | ||
| 1181 | continue; | ||
| 1182 | } | ||
| 1183 | } | ||
| 1184 | |||
| 1185 | const threadImportResult = await importThreads({ | ||
| 1186 | environmentId, | ||
| 1187 | input: { projectId, expectedWorkspaceRoot: candidate.path }, | ||
| 1188 | }); | ||
| 1189 | if ( | ||
| 1190 | importGeneration !== importGenerationRef.current || | ||
| 1191 | importedProjects !== importedProjectsRef.current | ||
| 1192 | ) { | ||
| 1193 | return; | ||
| 1194 | } | ||
| 1195 | if (threadImportResult._tag === "Success") { | ||
| 1196 | importedThreadCount += threadImportResult.value.importedCount; | ||
| 1197 | skippedThreadCount += threadImportResult.value.skippedCount; | ||
| 1198 | if (threadImportResult.value.importedCount > 0) { | ||
| 1199 | projectsWithImportedHistoryRef.current.set( | ||
| 1200 | candidate.path, | ||
| 1201 | scopeProjectRef(environmentId, projectId), | ||
| 1202 | ); | ||
| 1203 | } | ||
| 1204 | if (threadImportResult.value.skippedCount === 0) { | ||
| 1205 | importedProjectsCount += 1; | ||
| 1206 | importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId)); | ||
| 1207 | } | ||
| 1208 | } else if (!isAtomCommandInterrupted(threadImportResult)) { | ||
| 1209 | projectAttempts.delete(candidate.path); | ||
| 1210 | shouldRefreshScan = true; | ||
| 1211 | } | ||
| 1212 | } | ||
| 1213 | if (shouldRefreshScan) scan.refresh(); | ||
| 1214 | setIsImporting(false); | ||
| 1215 | if (importedProjectsCount < selection.length) { | ||
| 1216 | if (importedThreadCount > 0 && skippedThreadCount > 0) { | ||
| 1217 | setImportError( | ||
| 1218 | `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, | ||
| 1219 | ); | ||
| 1220 | } else if (skippedThreadCount > 0) { | ||
| 1221 | setImportError( | ||
| 1222 | `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, | ||
| 1223 | ); | ||
| 1224 | } else if (importedThreadCount > 0) { | ||
| 1225 | setImportError( | ||
| 1226 | `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, | ||
| 1227 | ); | ||
| 1228 | } else { | ||
| 1229 | setImportError("Could not import thread history."); | ||
| 1230 | } | ||
| 1231 | return; | ||
| 1232 | } | ||
| 1233 | finishAfterImport(); | ||
| 1234 | }; | ||
| 1235 | |||
| 1236 | if (environmentId === null || (scan.isPending && scan.data === null)) { | ||
| 1237 | return ( | ||
| 1238 | <StepShell | ||
| 1239 | title="Your projects" | ||
| 1240 | description="Looking for projects from Claude Code and Codex." | ||
| 1241 | onBack={onBack} | ||
| 1242 | > | ||
| 1243 | <div className="mt-6 flex justify-end"> | ||
| 1244 | <Button variant="ghost-muted" onClick={() => void onDone()}> | ||
| 1245 | Skip | ||
| 1246 | </Button> | ||
| 1247 | </div> | ||
| 1248 | </StepShell> | ||
| 1249 | ); | ||
| 1250 | } | ||
| 1251 | |||
| 1252 | if (scan.error !== null || candidates.length === 0) { | ||
| 1253 | return ( | ||
| 1254 | <StepShell | ||
| 1255 | title="Your projects" | ||
| 1256 | description={ | ||
| 1257 | scan.error !== null | ||
| 1258 | ? "Could not check this computer for projects." | ||
| 1259 | : scanTruncated | ||
| 1260 | ? SCAN_LIMIT_MESSAGE | ||
| 1261 | : "No existing Claude Code or Codex projects found." | ||
| 1262 | } | ||
| 1263 | onBack={onBack} | ||
| 1264 | > | ||
| 1265 | {scan.error !== null ? ( | ||
| 1266 | <p className="mt-3 text-xs text-muted-foreground">You can add projects later.</p> | ||
| 1267 | ) : null} | ||
| 1268 | <div className="mt-6 flex justify-end gap-2"> | ||
| 1269 | {scan.error !== null ? ( | ||
| 1270 | <Button variant="ghost" onClick={scan.refresh}> | ||
| 1271 | Retry | ||
| 1272 | </Button> | ||
| 1273 | ) : null} | ||
| 1274 | <Button | ||
| 1275 | variant={scan.error !== null || scanTruncated ? "ghost-muted" : "default"} | ||
| 1276 | onClick={() => void onDone()} | ||
| 1277 | > | ||
| 1278 | {scan.error !== null || scanTruncated ? "Skip" : "Start coding"} | ||
| 1279 | </Button> | ||
| 1280 | </div> | ||
| 1281 | </StepShell> | ||
| 1282 | ); | ||
| 1283 | } | ||
| 1284 | |||
| 1285 | if (choosing) { | ||
| 1286 | const selected = candidates.filter((candidate) => !deselected.has(candidate.path)); | ||
| 1287 | return ( | ||
| 1288 | <StepShell | ||
| 1289 | title="Choose your projects" | ||
| 1290 | onBack={() => setChoosing(false)} | ||
| 1291 | backDisabled={isImporting} | ||
| 1292 | description={`${candidates.length} found on ${machineLabel}.`} | ||
| 1293 | > | ||
| 1294 | {scanLimitNotice} | ||
| 1295 | <div className="mt-6 max-h-72 overflow-x-hidden overflow-y-auto border-y border-border"> | ||
| 1296 | {candidates.map((candidate) => ( | ||
| 1297 | <label | ||
| 1298 | key={candidate.path} | ||
| 1299 | className="flex min-h-12 cursor-pointer items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0 hover:bg-accent/50" | ||
| 1300 | > | ||
| 1301 | <Checkbox | ||
| 1302 | checked={!deselected.has(candidate.path)} | ||
| 1303 | onCheckedChange={(checked) => { | ||
| 1304 | setDeselected((previous) => { | ||
| 1305 | const next = new Set(previous); | ||
| 1306 | if (checked === true) next.delete(candidate.path); | ||
| 1307 | else next.add(candidate.path); | ||
| 1308 | return next; | ||
| 1309 | }); | ||
| 1310 | }} | ||
| 1311 | /> | ||
| 1312 | <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | ||
| 1313 | {candidate.path} | ||
| 1314 | </span> | ||
| 1315 | <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block"> | ||
| 1316 | {candidate.sources.map(formatSource).join(", ")} · {candidate.threadCount}{" "} | ||
| 1317 | {candidate.threadCount === 1 ? "thread" : "threads"} | ||
| 1318 | {candidate.lastActiveAt | ||
| 1319 | ? ` · ${formatRelativeTimeLabel(candidate.lastActiveAt)}` | ||
| 1320 | : ""} | ||
| 1321 | </span> | ||
| 1322 | </label> | ||
| 1323 | ))} | ||
| 1324 | </div> | ||
| 1325 | {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null} | ||
| 1326 | <div className="mt-7 flex flex-wrap items-center justify-between gap-3"> | ||
| 1327 | <Button | ||
| 1328 | variant="ghost-muted" | ||
| 1329 | disabled={isImporting} | ||
| 1330 | onClick={importError ? finishAfterImport : () => void onDone()} | ||
| 1331 | > | ||
| 1332 | {importError ? "Continue without the rest" : "Skip"} | ||
| 1333 | </Button> | ||
| 1334 | <Button | ||
| 1335 | disabled={isImporting || selected.length === 0} | ||
| 1336 | onClick={() => void runImport(selected)} | ||
| 1337 | > | ||
| 1338 | {isImporting ? "Importing..." : `Import ${selected.length}`} | ||
| 1339 | </Button> | ||
| 1340 | </div> | ||
| 1341 | </StepShell> | ||
| 1342 | ); | ||
| 1343 | } | ||
| 1344 | |||
| 1345 | return ( | ||
| 1346 | <StepShell | ||
| 1347 | title="Your recent projects" | ||
| 1348 | description={`${recent.length} ${recent.length === 1 ? "project" : "projects"} found on ${machineLabel}.${more > 0 ? ` ${more} more available.` : ""}`} | ||
| 1349 | onBack={onBack} | ||
| 1350 | backDisabled={isImporting} | ||
| 1351 | > | ||
| 1352 | {scanLimitNotice} | ||
| 1353 | <div className="mt-6 border-y border-border"> | ||
| 1354 | {recent.slice(0, 4).map((candidate) => ( | ||
| 1355 | <div | ||
| 1356 | key={candidate.path} | ||
| 1357 | className="flex min-h-12 items-center gap-3 border-b border-border/60 px-1 py-2 last:border-b-0" | ||
| 1358 | > | ||
| 1359 | <CheckIcon className="size-3.5 shrink-0 text-success-foreground" /> | ||
| 1360 | <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | ||
| 1361 | {candidate.path} | ||
| 1362 | </span> | ||
| 1363 | <span className="hidden shrink-0 whitespace-nowrap text-[11px] text-muted-foreground sm:block"> | ||
| 1364 | {candidate.sources.map(formatSource).join(", ")} | ||
| 1365 | </span> | ||
| 1366 | </div> | ||
| 1367 | ))} | ||
| 1368 | {recent.length > 4 ? ( | ||
| 1369 | <p className="px-1 py-3 text-xs text-muted-foreground"> | ||
| 1370 | {recent.length - 4} more projects | ||
| 1371 | </p> | ||
| 1372 | ) : null} | ||
| 1373 | </div> | ||
| 1374 | {importError ? <p className="mt-3 text-sm text-destructive">{importError}</p> : null} | ||
| 1375 | <div className="mt-7 flex flex-wrap items-center justify-between gap-3"> | ||
| 1376 | <Button | ||
| 1377 | variant="ghost-muted" | ||
| 1378 | disabled={isImporting} | ||
| 1379 | onClick={importError ? finishAfterImport : () => void onDone()} | ||
| 1380 | > | ||
| 1381 | {importError ? "Continue without the rest" : "Skip"} | ||
| 1382 | </Button> | ||
| 1383 | <div className="flex items-center gap-2"> | ||
| 1384 | <Button variant="ghost" disabled={isImporting} onClick={() => setChoosing(true)}> | ||
| 1385 | Choose | ||
| 1386 | </Button> | ||
| 1387 | <Button | ||
| 1388 | disabled={isImporting || recent.length === 0} | ||
| 1389 | onClick={() => void runImport(recent)} | ||
| 1390 | > | ||
| 1391 | {isImporting | ||
| 1392 | ? "Importing..." | ||
| 1393 | : `Import ${recent.length} ${recent.length === 1 ? "project" : "projects"}`} | ||
| 1394 | </Button> | ||
| 1395 | </div> | ||
| 1396 | </div> | ||
| 1397 | </StepShell> | ||
| 1398 | ); | ||
| 1399 | } | ||
| 1400 | |||
| 1401 | // ── Shared bits ────────────────────────────────────────────── | ||
| 1402 | |||
| 1403 | function StepShell({ | ||
| 1404 | title, | ||
| 1405 | description, | ||
| 1406 | onBack, | ||
| 1407 | backDisabled = false, | ||
| 1408 | children, | ||
| 1409 | }: { | ||
| 1410 | readonly title: string; | ||
| 1411 | readonly description?: string; | ||
| 1412 | readonly onBack?: () => void; | ||
| 1413 | readonly backDisabled?: boolean; | ||
| 1414 | readonly children?: React.ReactNode; | ||
| 1415 | }) { | ||
| 1416 | return ( | ||
| 1417 | <> | ||
| 1418 | {onBack ? ( | ||
| 1419 | <Button | ||
| 1420 | className="mb-5 -ml-2" | ||
| 1421 | disabled={backDisabled} | ||
| 1422 | onClick={onBack} | ||
| 1423 | size="xs" | ||
| 1424 | variant="ghost-muted" | ||
| 1425 | > | ||
| 1426 | <ChevronLeftIcon className="size-3.5" /> | ||
| 1427 | Back | ||
| 1428 | </Button> | ||
| 1429 | ) : null} | ||
| 1430 | <h1 className="text-3xl font-semibold text-foreground sm:text-[34px]">{title}</h1> | ||
| 1431 | {description ? ( | ||
| 1432 | <p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{description}</p> | ||
| 1433 | ) : null} | ||
| 1434 | {children} | ||
| 1435 | </> | ||
| 1436 | ); | ||
| 1437 | } | ||
| 1438 | |||
| 1439 | function CommandBlock({ | ||
| 1440 | command, | ||
| 1441 | className, | ||
| 1442 | prominent = false, | ||
| 1443 | }: { | ||
| 1444 | readonly command: string; | ||
| 1445 | readonly className?: string; | ||
| 1446 | readonly prominent?: boolean; | ||
| 1447 | }) { | ||
| 1448 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | ||
| 1449 | timeout: 1500, | ||
| 1450 | target: "command", | ||
| 1451 | }); | ||
| 1452 | return ( | ||
| 1453 | <div | ||
| 1454 | className={cn( | ||
| 1455 | "flex items-center justify-between gap-3 border border-border bg-accent/50 font-mono", | ||
| 1456 | prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm", | ||
| 1457 | className, | ||
| 1458 | )} | ||
| 1459 | > | ||
| 1460 | <span className="min-w-0 truncate"> | ||
| 1461 | <span className="mr-2 text-muted-foreground">$</span> | ||
| 1462 | {command} | ||
| 1463 | </span> | ||
| 1464 | <Button | ||
| 1465 | size="icon-xs" | ||
| 1466 | variant="ghost" | ||
| 1467 | aria-label="Copy command" | ||
| 1468 | onClick={() => copyToClipboard(command, undefined)} | ||
| 1469 | > | ||
| 1470 | {isCopied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />} | ||
| 1471 | </Button> | ||
| 1472 | </div> | ||
| 1473 | ); | ||
| 1474 | } | ||
| 1475 | |||
| 1476 | function formatSource(source: "claudeAgent" | "codex"): string { | ||
| 1477 | return source === "claudeAgent" ? "Claude" : "Codex"; | ||
| 1478 | } | ||
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_CLIENT_SETTINGS, | ||
| 3 | EnvironmentId, | ||
| 4 | ThreadId, | ||
| 5 | type ClientSettings, | ||
| 6 | type PreviewAutomationResponse, | ||
| 7 | type PreviewAutomationStreamEvent, | ||
| 8 | type PreviewOpenInput, | ||
| 9 | type PreviewSessionSnapshot, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 12 | import { act } from "react"; | ||
| 13 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; | ||
| 17 | import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; | ||
| 18 | import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; | ||
| 19 | |||
| 20 | import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; | ||
| 21 | |||
| 22 | const mocks = vi.hoisted(() => ({ | ||
| 23 | getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(), | ||
| 24 | setClientSettings: vi.fn(), | ||
| 25 | open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => | ||
| 26 | AsyncResult.success(snapshot), | ||
| 27 | ), | ||
| 28 | list: vi.fn(async () => AsyncResult.success(emptyList)), | ||
| 29 | resize: vi.fn(), | ||
| 30 | respond: | ||
| 31 | vi.fn< | ||
| 32 | (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void> | ||
| 33 | >(), | ||
| 34 | focus: vi.fn(async () => undefined), | ||
| 35 | })); | ||
| 36 | |||
| 37 | vi.mock("~/localApi", () => ({ | ||
| 38 | ensureLocalApi: () => ({ persistence: mocks }), | ||
| 39 | })); | ||
| 40 | vi.mock("~/env", () => ({ isElectron: true })); | ||
| 41 | vi.mock("~/state/environments", () => ({ | ||
| 42 | useEnvironments: () => ({ environments: [{ environmentId }] }), | ||
| 43 | })); | ||
| 44 | vi.mock("~/state/preview", () => ({ | ||
| 45 | previewEnvironment: { | ||
| 46 | automationRequests: () => requestsAtom, | ||
| 47 | list: () => listAtom, | ||
| 48 | open: mocks.open, | ||
| 49 | resize: mocks.resize, | ||
| 50 | respondToAutomation: mocks.respond, | ||
| 51 | focusAutomationHost: mocks.focus, | ||
| 52 | }, | ||
| 53 | })); | ||
| 54 | vi.mock("~/state/use-atom-command", () => ({ | ||
| 55 | useAtomCommand: (command: unknown) => command, | ||
| 56 | })); | ||
| 57 | vi.mock("~/state/use-atom-query-runner", () => ({ | ||
| 58 | useAtomQueryRunner: () => mocks.list, | ||
| 59 | })); | ||
| 60 | vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); | ||
| 61 | |||
| 62 | const environmentId = EnvironmentId.make("automation-environment"); | ||
| 63 | const threadId = ThreadId.make("automation-thread"); | ||
| 64 | const threadRef = { environmentId, threadId }; | ||
| 65 | const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; | ||
| 66 | const savedSettings: ClientSettings = { | ||
| 67 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 68 | browserDefaultViewport: viewport, | ||
| 69 | browserDefaultProfileId: "work", | ||
| 70 | browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], | ||
| 71 | }; | ||
| 72 | const snapshot: PreviewSessionSnapshot = { | ||
| 73 | threadId, | ||
| 74 | tabId: "automation-tab", | ||
| 75 | navStatus: { _tag: "Idle" }, | ||
| 76 | canGoBack: false, | ||
| 77 | canGoForward: false, | ||
| 78 | viewport, | ||
| 79 | profileId: "work", | ||
| 80 | updatedAt: "2026-09-05T00:00:00.000Z", | ||
| 81 | }; | ||
| 82 | const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; | ||
| 83 | const listAtom = Atom.make(AsyncResult.success(emptyList)); | ||
| 84 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( | ||
| 85 | AsyncResult.initial(false), | ||
| 86 | ); | ||
| 87 | const requestEvent: PreviewAutomationStreamEvent = { | ||
| 88 | type: "request", | ||
| 89 | connectionId: "automation-connection", | ||
| 90 | request: { | ||
| 91 | requestId: "open-request", | ||
| 92 | threadId, | ||
| 93 | operation: "open", | ||
| 94 | input: { open: false, reuseExistingTab: false }, | ||
| 95 | timeoutMs: 15_000, | ||
| 96 | }, | ||
| 97 | }; | ||
| 98 | |||
| 99 | function deferred<A>() { | ||
| 100 | let resolve!: (value: A) => void; | ||
| 101 | const promise = new Promise<A>((complete) => { | ||
| 102 | resolve = complete; | ||
| 103 | }); | ||
| 104 | return { promise, resolve }; | ||
| 105 | } | ||
| 106 | |||
| 107 | let renderer: ReactTestRenderer | null = null; | ||
| 108 | |||
| 109 | beforeEach(async () => { | ||
| 110 | vi.clearAllMocks(); | ||
| 111 | mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); | ||
| 112 | mocks.respond.mockReset(); | ||
| 113 | __resetClientSettingsPersistenceForTests(); | ||
| 114 | resetPreviewStateForTests(); | ||
| 115 | appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); | ||
| 116 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 117 | vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); | ||
| 118 | vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); | ||
| 119 | await act(() => { | ||
| 120 | renderer = create( | ||
| 121 | <AppAtomRegistryProvider> | ||
| 122 | <PreviewAutomationHosts /> | ||
| 123 | </AppAtomRegistryProvider>, | ||
| 124 | ); | ||
| 125 | }); | ||
| 126 | }); | ||
| 127 | |||
| 128 | afterEach(async () => { | ||
| 129 | await act(() => renderer?.unmount()); | ||
| 130 | renderer = null; | ||
| 131 | resetPreviewStateForTests(); | ||
| 132 | __resetClientSettingsPersistenceForTests(); | ||
| 133 | vi.unstubAllGlobals(); | ||
| 134 | vi.restoreAllMocks(); | ||
| 135 | }); | ||
| 136 | |||
| 137 | describe("PreviewAutomationHosts open", () => { | ||
| 138 | it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { | ||
| 139 | const readStarted = deferred<void>(); | ||
| 140 | const read = deferred<ClientSettings>(); | ||
| 141 | const response = deferred<PreviewAutomationResponse>(); | ||
| 142 | mocks.getClientSettings.mockImplementationOnce(() => { | ||
| 143 | readStarted.resolve(); | ||
| 144 | return read.promise; | ||
| 145 | }); | ||
| 146 | mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); | ||
| 147 | |||
| 148 | await act(async () => { | ||
| 149 | appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); | ||
| 150 | await readStarted.promise; | ||
| 151 | }); | ||
| 152 | expect(mocks.open).not.toHaveBeenCalled(); | ||
| 153 | |||
| 154 | await act(async () => { | ||
| 155 | read.resolve(savedSettings); | ||
| 156 | await response.promise; | ||
| 157 | }); | ||
| 158 | |||
| 159 | expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ | ||
| 160 | environmentId, | ||
| 161 | input: { threadId, viewport, profileId: "work" }, | ||
| 162 | }); | ||
| 163 | expect(mocks.getClientSettings).toHaveBeenCalledOnce(); | ||
| 164 | await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); | ||
| 165 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); | ||
| 166 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); | ||
| 167 | }); | ||
| 168 | |||
| 169 | it("reports a settings read failure without opening a tab", async () => { | ||
| 170 | vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 171 | mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); | ||
| 172 | const response = deferred<PreviewAutomationResponse>(); | ||
| 173 | mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); | ||
| 174 | |||
| 175 | await act(async () => { | ||
| 176 | appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); | ||
| 177 | await response.promise; | ||
| 178 | }); | ||
| 179 | |||
| 180 | await expect(response.promise).resolves.toMatchObject({ | ||
| 181 | requestId: "open-request", | ||
| 182 | ok: false, | ||
| 183 | error: { _tag: "PreviewAutomationExecutionError" }, | ||
| 184 | }); | ||
| 185 | expect(mocks.getClientSettings).toHaveBeenCalledOnce(); | ||
| 186 | expect(mocks.open).not.toHaveBeenCalled(); | ||
| 187 | expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); | ||
| 188 | expect(mocks.setClientSettings).not.toHaveBeenCalled(); | ||
| 189 | }); | ||
| 190 | }); | ||
apps/web/src/components/preview/PreviewAutomationHosts.tsx+8-2| ... | @@ -41,7 +41,11 @@ import { | ... | @@ -41,7 +41,11 @@ import { |
| 41 | acquireBrowserSurfaceActivity, | 41 | acquireBrowserSurfaceActivity, |
| 42 | useBrowserSurfaceStore, | 42 | useBrowserSurfaceStore, |
| 43 | } from "~/browser/browserSurfaceStore"; | 43 | } from "~/browser/browserSurfaceStore"; |
| 44 | import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; | 44 | import { |
| 45 | browserDefaultOpenProfileId, | ||
| 46 | browserDefaultOpenViewport, | ||
| 47 | resolveBrowserDefaults, | ||
| 48 | } from "~/browser/browserDefaults"; | ||
| 45 | import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; | 49 | import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; |
| 46 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | 50 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 47 | import { isElectron } from "~/env"; | 51 | import { isElectron } from "~/env"; |
| ... | @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 412 | const reusedExistingTab = activeTabId !== null; | 416 | const reusedExistingTab = activeTabId !== null; |
| 413 | tabId = activeTabId; | 417 | tabId = activeTabId; |
| 414 | if (!activeTabId) { | 418 | if (!activeTabId) { |
| 419 | const defaults = await resolveBrowserDefaults(); | ||
| 415 | const result = await open({ | 420 | const result = await open({ |
| 416 | environmentId, | 421 | environmentId, |
| 417 | input: { | 422 | input: { |
| ... | @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 419 | ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), | 424 | ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), |
| 420 | // An agent that didn't state a size gets the user's | 425 | // An agent that didn't state a size gets the user's |
| 421 | // configured default, same as a hand-opened tab. | 426 | // configured default, same as a hand-opened tab. |
| 422 | viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), | 427 | viewport: browserDefaultOpenViewport(defaults), |
| 428 | profileId: browserDefaultOpenProfileId(defaults), | ||
| 423 | }, | 429 | }, |
| 424 | }); | 430 | }); |
| 425 | if (result._tag === "Failure") { | 431 | if (result._tag === "Failure") { |
apps/web/src/components/preview/PreviewView.tsx+15-1| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 4 | import { |
| 5 | isAtomCommandInterrupted, | ||
| 6 | squashAtomCommandFailure, | ||
| 7 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 5 | import { | 8 | import { |
| 6 | DEFAULT_BROWSER_PROFILE_ID, | 9 | DEFAULT_BROWSER_PROFILE_ID, |
| 7 | FILL_PREVIEW_VIEWPORT, | 10 | FILL_PREVIEW_VIEWPORT, |
| ... | @@ -46,6 +49,7 @@ import { | ... | @@ -46,6 +49,7 @@ import { |
| 46 | } from "~/browser/browserViewportActions"; | 49 | } from "~/browser/browserViewportActions"; |
| 47 | import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; | 50 | import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; |
| 48 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | 51 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 52 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; | ||
| 49 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 53 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 50 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | 54 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 51 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | 55 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| ... | @@ -186,6 +190,16 @@ export function PreviewView({ | ... | @@ -186,6 +190,16 @@ export function PreviewView({ |
| 186 | return true; | 190 | return true; |
| 187 | } | 191 | } |
| 188 | const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); | 192 | const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); |
| 193 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 194 | const error = squashAtomCommandFailure(result); | ||
| 195 | if (error instanceof BrowserSettingsReadError) { | ||
| 196 | toastManager.add({ | ||
| 197 | type: "error", | ||
| 198 | title: "Unable to open browser", | ||
| 199 | description: error.message, | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | } | ||
| 189 | return result._tag === "Success"; | 203 | return result._tag === "Success"; |
| 190 | }, | 204 | }, |
| 191 | [open, runtimeTabId, threadRef], | 205 | [open, runtimeTabId, threadRef], |
apps/web/src/components/preview/addBrowserSurface.test.ts+3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_BROWSER_PROFILE_ID, | 2 | DEFAULT_BROWSER_PROFILE_ID, |
| 3 | DEFAULT_CLIENT_SETTINGS, | ||
| 3 | FILL_PREVIEW_VIEWPORT, | 4 | FILL_PREVIEW_VIEWPORT, |
| 4 | type PreviewOpenInput, | 5 | type PreviewOpenInput, |
| 5 | type PreviewSessionSnapshot, | 6 | type PreviewSessionSnapshot, |
| ... | @@ -14,6 +15,7 @@ import { | ... | @@ -14,6 +15,7 @@ import { |
| 14 | resetPreviewStateForTests, | 15 | resetPreviewStateForTests, |
| 15 | } from "~/previewStateStore"; | 16 | } from "~/previewStateStore"; |
| 16 | import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; | 17 | import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; |
| 18 | import { __setClientSettingsForTests } from "~/hooks/useSettings"; | ||
| 17 | 19 | ||
| 18 | import { addBrowserSurface } from "./addBrowserSurface"; | 20 | import { addBrowserSurface } from "./addBrowserSurface"; |
| 19 | 21 | ||
| ... | @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ | ... | @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ |
| 32 | }); | 34 | }); |
| 33 | 35 | ||
| 34 | beforeEach(() => { | 36 | beforeEach(() => { |
| 37 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 35 | resetPreviewStateForTests(); | 38 | resetPreviewStateForTests(); |
| 36 | useRightPanelStore.setState({ byThreadKey: {} }); | 39 | useRightPanelStore.setState({ byThreadKey: {} }); |
| 37 | }); | 40 | }); |
apps/web/src/components/preview/addBrowserSurface.ts+2-2| ... | @@ -4,7 +4,7 @@ import { | ... | @@ -4,7 +4,7 @@ import { |
| 4 | } from "@t3tools/client-runtime/state/runtime"; | 4 | } from "@t3tools/client-runtime/state/runtime"; |
| 5 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 5 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 6 | 6 | ||
| 7 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | 7 | import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 8 | import { useRightPanelStore } from "~/rightPanelStore"; | 8 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 9 | 9 | ||
| 10 | import { openPreviewSession } from "./openPreviewSession"; | 10 | import { openPreviewSession } from "./openPreviewSession"; |
| ... | @@ -15,7 +15,7 @@ export async function addBrowserSurface<E>(input: { | ... | @@ -15,7 +15,7 @@ export async function addBrowserSurface<E>(input: { |
| 15 | readonly openPreview: OpenPreviewMutation<E>; | 15 | readonly openPreview: OpenPreviewMutation<E>; |
| 16 | /** Omit to use the configured default profile. */ | 16 | /** Omit to use the configured default profile. */ |
| 17 | readonly profileId?: string | undefined; | 17 | readonly profileId?: string | undefined; |
| 18 | }): Promise<AtomCommandResult<void, E>> { | 18 | }): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> { |
| 19 | const result = await openPreviewSession({ | 19 | const result = await openPreviewSession({ |
| 20 | openPreview: input.openPreview, | 20 | openPreview: input.openPreview, |
| 21 | threadRef: input.threadRef, | 21 | threadRef: input.threadRef, |
apps/web/src/components/preview/openDiscoveredPort.ts+2-2| ... | @@ -5,7 +5,7 @@ import { | ... | @@ -5,7 +5,7 @@ import { |
| 5 | } from "@t3tools/client-runtime/state/runtime"; | 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | 6 | ||
| 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 8 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | 8 | import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 9 | import { recordVisitForThread } from "~/browserHistoryStore"; | 9 | import { recordVisitForThread } from "~/browserHistoryStore"; |
| 10 | import { useRightPanelStore } from "~/rightPanelStore"; | 10 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 11 | import { openPreviewSession } from "./openPreviewSession"; | 11 | import { openPreviewSession } from "./openPreviewSession"; |
| ... | @@ -14,7 +14,7 @@ export async function openDiscoveredPort<E>(input: { | ... | @@ -14,7 +14,7 @@ export async function openDiscoveredPort<E>(input: { |
| 14 | readonly threadRef: ScopedThreadRef; | 14 | readonly threadRef: ScopedThreadRef; |
| 15 | readonly port: DiscoveredLocalServer; | 15 | readonly port: DiscoveredLocalServer; |
| 16 | readonly openPreview: OpenPreviewMutation<E>; | 16 | readonly openPreview: OpenPreviewMutation<E>; |
| 17 | }): Promise<AtomCommandResult<void, E>> { | 17 | }): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> { |
| 18 | const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); | 18 | const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); |
| 19 | const result = await openPreviewSession({ | 19 | const result = await openPreviewSession({ |
| 20 | openPreview: input.openPreview, | 20 | openPreview: input.openPreview, |
apps/web/src/components/preview/openPreviewSession.test.ts+53-2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_BROWSER_PROFILE_ID, | 2 | DEFAULT_BROWSER_PROFILE_ID, |
| 3 | DEFAULT_CLIENT_SETTINGS, | ||
| 3 | FILL_PREVIEW_VIEWPORT, | 4 | FILL_PREVIEW_VIEWPORT, |
| 4 | type PreviewOpenInput, | 5 | type PreviewOpenInput, |
| 5 | type PreviewSessionSnapshot, | 6 | type PreviewSessionSnapshot, |
| ... | @@ -7,8 +8,11 @@ import { | ... | @@ -7,8 +8,11 @@ import { |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; | 9 | import * as Cause from "effect/Cause"; |
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 11 | 12 | ||
| 13 | import * as browserDefaults from "~/browser/browserDefaults"; | ||
| 14 | import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; | ||
| 15 | import { __setClientSettingsForTests } from "~/hooks/useSettings"; | ||
| 12 | import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; | 16 | import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; |
| 13 | 17 | ||
| 14 | import { openPreviewSession } from "./openPreviewSession"; | 18 | import { openPreviewSession } from "./openPreviewSession"; |
| ... | @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { | ... | @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { |
| 31 | updatedAt: "2026-06-11T23:00:00.000Z", | 35 | updatedAt: "2026-06-11T23:00:00.000Z", |
| 32 | }; | 36 | }; |
| 33 | 37 | ||
| 34 | beforeEach(resetPreviewStateForTests); | 38 | beforeEach(() => { |
| 39 | resetPreviewStateForTests(); | ||
| 40 | __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); | ||
| 41 | }); | ||
| 42 | |||
| 43 | afterEach(() => { | ||
| 44 | vi.restoreAllMocks(); | ||
| 45 | }); | ||
| 35 | 46 | ||
| 36 | describe("openPreviewSession", () => { | 47 | describe("openPreviewSession", () => { |
| 37 | it("creates an idle tab without recording a recently visited URL", async () => { | 48 | it("creates an idle tab without recording a recently visited URL", async () => { |
| ... | @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { | ... | @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { |
| 88 | expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); | 99 | expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); |
| 89 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); | 100 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); |
| 90 | }); | 101 | }); |
| 102 | |||
| 103 | it.each(["session", "link"] as const)( | ||
| 104 | "does not open a %s with unread settings and uses the saved profile on retry", | ||
| 105 | async (entryPoint) => { | ||
| 106 | const failure = new Error("Settings read failed"); | ||
| 107 | vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); | ||
| 108 | const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; | ||
| 109 | __setClientSettingsForTests({ | ||
| 110 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 111 | browserDefaultViewport: viewport, | ||
| 112 | browserDefaultProfileId: "work", | ||
| 113 | browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], | ||
| 114 | }); | ||
| 115 | const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); | ||
| 116 | const input = { openPreview, threadRef, url: "https://t3.chat/" }; | ||
| 117 | const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; | ||
| 118 | |||
| 119 | const result = await open(input); | ||
| 120 | |||
| 121 | expect(result._tag).toBe("Failure"); | ||
| 122 | if (result._tag === "Failure") { | ||
| 123 | expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); | ||
| 124 | expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); | ||
| 125 | } | ||
| 126 | expect(openPreview).not.toHaveBeenCalled(); | ||
| 127 | expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); | ||
| 128 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); | ||
| 129 | |||
| 130 | await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); | ||
| 131 | expect(openPreview).toHaveBeenCalledExactlyOnceWith({ | ||
| 132 | environmentId: threadRef.environmentId, | ||
| 133 | input: { | ||
| 134 | threadId: threadRef.threadId, | ||
| 135 | url: input.url, | ||
| 136 | viewport, | ||
| 137 | profileId: "work", | ||
| 138 | }, | ||
| 139 | }); | ||
| 140 | }, | ||
| 141 | ); | ||
| 91 | }); | 142 | }); |
apps/web/src/components/preview/openPreviewSession.ts+10-2| ... | @@ -6,12 +6,15 @@ import type { | ... | @@ -6,12 +6,15 @@ import type { |
| 6 | ScopedThreadRef, | 6 | ScopedThreadRef, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | 8 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 9 | 11 | ||
| 10 | import { | 12 | import { |
| 11 | browserDefaultOpenProfileId, | 13 | browserDefaultOpenProfileId, |
| 12 | browserDefaultOpenViewport, | 14 | browserDefaultOpenViewport, |
| 13 | resolveBrowserDefaults, | 15 | resolveBrowserDefaults, |
| 14 | } from "~/browser/browserDefaults"; | 16 | } from "~/browser/browserDefaults"; |
| 17 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; | ||
| 15 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; | 18 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; |
| 16 | 19 | ||
| 17 | interface OpenPreviewSessionInput<E> { | 20 | interface OpenPreviewSessionInput<E> { |
| ... | @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput<E> { | ... | @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput<E> { |
| 29 | 32 | ||
| 30 | export async function openPreviewSession<E>( | 33 | export async function openPreviewSession<E>( |
| 31 | input: OpenPreviewSessionInput<E>, | 34 | input: OpenPreviewSessionInput<E>, |
| 32 | ): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> { | 35 | ): Promise<AtomCommandResult<PreviewSessionSnapshot, E | BrowserSettingsReadError>> { |
| 33 | // Resolved once: a tab opened before client settings hydrate would otherwise | 36 | // Resolved once: a tab opened before client settings hydrate would otherwise |
| 34 | // be born at the schema defaults and never corrected. | 37 | // be born at the schema defaults and never corrected. |
| 35 | const defaults = await resolveBrowserDefaults(); | 38 | const defaults = await resolveBrowserDefaults().catch( |
| 39 | (cause: unknown) => new BrowserSettingsReadError({ cause }), | ||
| 40 | ); | ||
| 41 | if (defaults instanceof BrowserSettingsReadError) { | ||
| 42 | return AsyncResult.failure(Cause.fail(defaults)); | ||
| 43 | } | ||
| 36 | const result = await input.openPreview({ | 44 | const result = await input.openPreview({ |
| 37 | environmentId: input.threadRef.environmentId, | 45 | environmentId: input.threadRef.environmentId, |
| 38 | input: { | 46 | input: { |
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+27| ... | @@ -68,6 +68,33 @@ afterEach(() => { | ... | @@ -68,6 +68,33 @@ afterEach(() => { |
| 68 | }); | 68 | }); |
| 69 | 69 | ||
| 70 | describe("openTerminalLinkInPreview", () => { | 70 | describe("openTerminalLinkInPreview", () => { |
| 71 | it.each(["target", "defaults"] as const)( | ||
| 72 | "does not open either browser when reading %s fails", | ||
| 73 | async (setting) => { | ||
| 74 | const failure = new Error("Settings read failed"); | ||
| 75 | if (setting === "target") { | ||
| 76 | linkTargetMocks.preference.mockImplementationOnce(() => { | ||
| 77 | throw failure; | ||
| 78 | }); | ||
| 79 | } else { | ||
| 80 | browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); | ||
| 81 | } | ||
| 82 | const fallbackToBrowser = vi.fn(); | ||
| 83 | const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); | ||
| 84 | |||
| 85 | await expect( | ||
| 86 | openTerminalLinkInPreview({ | ||
| 87 | url: "https://example.com/docs", | ||
| 88 | threadRef, | ||
| 89 | openPreview, | ||
| 90 | fallbackToBrowser, | ||
| 91 | }), | ||
| 92 | ).rejects.toBe(failure); | ||
| 93 | expect(fallbackToBrowser).not.toHaveBeenCalled(); | ||
| 94 | expect(openPreview).not.toHaveBeenCalled(); | ||
| 95 | }, | ||
| 96 | ); | ||
| 97 | |||
| 71 | it("opens in the system browser while that is the configured target", async () => { | 98 | it("opens in the system browser while that is the configured target", async () => { |
| 72 | linkTargetMocks.preference.mockReturnValue("system"); | 99 | linkTargetMocks.preference.mockReturnValue("system"); |
| 73 | const fallbackToBrowser = vi.fn(); | 100 | const fallbackToBrowser = vi.fn(); |
apps/web/src/components/settings/providerStatus.test.ts created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { getProviderSummary } from "./providerStatus"; | ||
| 5 | |||
| 6 | const provider: ServerProvider = { | ||
| 7 | instanceId: ProviderInstanceId.make("codex"), | ||
| 8 | driver: ProviderDriverKind.make("codex"), | ||
| 9 | enabled: true, | ||
| 10 | installed: true, | ||
| 11 | version: "1.0.0", | ||
| 12 | status: "ready", | ||
| 13 | auth: { status: "authenticated", label: "ChatGPT" }, | ||
| 14 | checkedAt: "2026-08-23T00:00:00.000Z", | ||
| 15 | models: [], | ||
| 16 | slashCommands: [], | ||
| 17 | skills: [], | ||
| 18 | }; | ||
| 19 | |||
| 20 | describe("getProviderSummary", () => { | ||
| 21 | it("reports ready providers with unknown authentication as available", () => { | ||
| 22 | expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ | ||
| 23 | headline: "Available", | ||
| 24 | detail: null, | ||
| 25 | }); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("does not hide a provider error behind a previous authenticated state", () => { | ||
| 29 | expect( | ||
| 30 | getProviderSummary({ | ||
| 31 | ...provider, | ||
| 32 | status: "error", | ||
| 33 | message: "The provider process failed to start.", | ||
| 34 | }), | ||
| 35 | ).toEqual({ | ||
| 36 | headline: "Unavailable", | ||
| 37 | detail: "The provider process failed to start.", | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("does not hide a provider warning behind an authenticated state", () => { | ||
| 42 | expect( | ||
| 43 | getProviderSummary({ | ||
| 44 | ...provider, | ||
| 45 | status: "warning", | ||
| 46 | message: "The provider version is unsupported.", | ||
| 47 | }), | ||
| 48 | ).toEqual({ | ||
| 49 | headline: "Needs attention", | ||
| 50 | detail: "The provider version is unsupported.", | ||
| 51 | }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("keeps authentication failures actionable when their provider status is error", () => { | ||
| 55 | expect( | ||
| 56 | getProviderSummary({ | ||
| 57 | ...provider, | ||
| 58 | status: "error", | ||
| 59 | auth: { status: "unauthenticated" }, | ||
| 60 | message: "Run codex login.", | ||
| 61 | }), | ||
| 62 | ).toEqual({ | ||
| 63 | headline: "Not authenticated", | ||
| 64 | detail: "Run codex login.", | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("treats a disabled provider status as disabled even before its enabled flag updates", () => { | ||
| 69 | expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); | ||
| 70 | }); | ||
| 71 | }); | ||
apps/web/src/components/settings/providerStatus.ts+11-10| ... | @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; | ... | @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; |
| 26 | * settings page. Prefers `provider.message` for server-supplied detail and | 26 | * settings page. Prefers `provider.message` for server-supplied detail and |
| 27 | * falls back to generic phrasing when the server has not yet reported any | 27 | * falls back to generic phrasing when the server has not yet reported any |
| 28 | * state — which happens before the first probe or when an instance names a | 28 | * state — which happens before the first probe or when an instance names a |
| 29 | * driver this build does not ship. | 29 | * driver this build does not ship. A ready provider without account metadata |
| 30 | * remains available and does not imply an authentication failure. | ||
| 30 | */ | 31 | */ |
| 31 | export function getProviderSummary(provider: ServerProvider | undefined) { | 32 | export function getProviderSummary(provider: ServerProvider | undefined) { |
| 32 | if (!provider) { | 33 | if (!provider) { |
| ... | @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { | ... | @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { |
| 35 | detail: "Waiting for the server to report installation and authentication details.", | 36 | detail: "Waiting for the server to report installation and authentication details.", |
| 36 | }; | 37 | }; |
| 37 | } | 38 | } |
| 38 | if (!provider.enabled) { | 39 | if (!provider.enabled || provider.status === "disabled") { |
| 39 | return { | 40 | return { |
| 40 | headline: "Disabled", | 41 | headline: "Disabled", |
| 41 | detail: | 42 | detail: |
| ... | @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { | ... | @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { |
| 48 | detail: provider.message ?? "CLI not detected on PATH.", | 49 | detail: provider.message ?? "CLI not detected on PATH.", |
| 49 | }; | 50 | }; |
| 50 | } | 51 | } |
| 51 | if (provider.auth.status === "authenticated") { | ||
| 52 | const authLabel = provider.auth.label ?? provider.auth.type; | ||
| 53 | return { | ||
| 54 | headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", | ||
| 55 | detail: provider.message ?? null, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | if (provider.auth.status === "unauthenticated") { | 52 | if (provider.auth.status === "unauthenticated") { |
| 59 | return { | 53 | return { |
| 60 | headline: "Not authenticated", | 54 | headline: "Not authenticated", |
| ... | @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { | ... | @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { |
| 74 | detail: provider.message ?? "The provider failed its startup checks.", | 68 | detail: provider.message ?? "The provider failed its startup checks.", |
| 75 | }; | 69 | }; |
| 76 | } | 70 | } |
| 71 | if (provider.auth.status === "authenticated") { | ||
| 72 | const authLabel = provider.auth.label ?? provider.auth.type; | ||
| 73 | return { | ||
| 74 | headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", | ||
| 75 | detail: provider.message ?? null, | ||
| 76 | }; | ||
| 77 | } | ||
| 77 | return { | 78 | return { |
| 78 | headline: "Available", | 79 | headline: "Available", |
| 79 | detail: provider.message ?? "Installed and ready, but authentication could not be verified.", | 80 | detail: provider.message ?? null, |
| 80 | }; | 81 | }; |
| 81 | } | 82 | } |
| 82 | 83 |
apps/web/src/environments/primary/auth.ts+2| ... | @@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise<vo | ... | @@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 353 | 353 | ||
| 354 | resolvedAuthenticatedGateState = null; | 354 | resolvedAuthenticatedGateState = null; |
| 355 | await exchangeBootstrapCredential(trimmedCredential); | 355 | await exchangeBootstrapCredential(trimmedCredential); |
| 356 | await waitForAuthenticatedSessionAfterBootstrap(); | ||
| 357 | resolvedAuthenticatedGateState = { status: "authenticated" }; | ||
| 356 | bootstrapPromise = null; | 358 | bootstrapPromise = null; |
| 357 | stripPairingTokenFromUrl(); | 359 | stripPairingTokenFromUrl(); |
| 358 | } | 360 | } |
apps/web/src/hooks/useLocalStorage.test.ts+21-2| ... | @@ -55,9 +55,28 @@ describe("local storage errors", () => { | ... | @@ -55,9 +55,28 @@ describe("local storage errors", () => { |
| 55 | } | 55 | } |
| 56 | }); | 56 | }); |
| 57 | 57 | ||
| 58 | it("preserves decode failure context", async () => { | 58 | it("retries when access to browser storage becomes available", async () => { |
| 59 | const storage = createStorage(); | ||
| 60 | storage.setItem("read-key", JSON.stringify("saved value")); | ||
| 61 | let blocked = true; | ||
| 62 | vi.stubGlobal("window", { | ||
| 63 | get localStorage() { | ||
| 64 | if (blocked) throw new Error("storage unavailable"); | ||
| 65 | return storage; | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage"); | ||
| 69 | |||
| 70 | expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow( | ||
| 71 | LocalStorageOperationError, | ||
| 72 | ); | ||
| 73 | blocked = false; | ||
| 74 | expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value"); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => { | ||
| 59 | const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( | 78 | const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( |
| 60 | createStorage({ getItem: () => "not-json" }), | 79 | createStorage({ getItem: () => value }), |
| 61 | ); | 80 | ); |
| 62 | 81 | ||
| 63 | try { | 82 | try { |
apps/web/src/hooks/useLocalStorage.ts+20-20| ... | @@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass<LocalSto | ... | @@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass<LocalSto |
| 15 | } | 15 | } |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | const isomorphicLocalStorage: Storage = | 18 | const fallbackStorage: Storage = (() => { |
| 19 | typeof window !== "undefined" | 19 | const store = new Map<string, string>(); |
| 20 | ? window.localStorage | 20 | return { |
| 21 | : (function () { | 21 | clear: () => store.clear(), |
| 22 | const store = new Map<string, string>(); | 22 | getItem: (_) => store.get(_) ?? null, |
| 23 | return { | 23 | key: (_) => Record.keys(store).at(_) ?? null, |
| 24 | clear: () => store.clear(), | 24 | get length() { |
| 25 | getItem: (_) => store.get(_) ?? null, | 25 | return store.size; |
| 26 | key: (_) => Record.keys(store).at(_) ?? null, | 26 | }, |
| 27 | get length() { | 27 | removeItem: (_) => store.delete(_), |
| 28 | return store.size; | 28 | setItem: (_, value) => store.set(_, value), |
| 29 | }, | 29 | }; |
| 30 | removeItem: (_) => store.delete(_), | 30 | })(); |
| 31 | setItem: (_, value) => store.set(_, value), | 31 | |
| 32 | }; | 32 | const getStorage = (): Storage => |
| 33 | })(); | 33 | typeof window !== "undefined" ? window.localStorage : fallbackStorage; |
| 34 | 34 | ||
| 35 | const read = (key: string) => { | 35 | const read = (key: string) => { |
| 36 | try { | 36 | try { |
| 37 | return isomorphicLocalStorage.getItem(key); | 37 | return getStorage().getItem(key); |
| 38 | } catch (cause) { | 38 | } catch (cause) { |
| 39 | throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); | 39 | throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); |
| 40 | } | 40 | } |
| ... | @@ -58,13 +58,13 @@ const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => { | ... | @@ -58,13 +58,13 @@ const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => { |
| 58 | 58 | ||
| 59 | export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => { | 59 | export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => { |
| 60 | const item = read(key); | 60 | const item = read(key); |
| 61 | return item ? decode(key, schema, item) : null; | 61 | return item === null ? null : decode(key, schema, item); |
| 62 | }; | 62 | }; |
| 63 | 63 | ||
| 64 | export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => { | 64 | export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => { |
| 65 | const valueToSet = encode(key, schema, value); | 65 | const valueToSet = encode(key, schema, value); |
| 66 | try { | 66 | try { |
| 67 | isomorphicLocalStorage.setItem(key, valueToSet); | 67 | getStorage().setItem(key, valueToSet); |
| 68 | } catch (cause) { | 68 | } catch (cause) { |
| 69 | throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); | 69 | throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); |
| 70 | } | 70 | } |
| ... | @@ -72,7 +72,7 @@ export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema. | ... | @@ -72,7 +72,7 @@ export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema. |
| 72 | 72 | ||
| 73 | export const removeLocalStorageItem = (key: string) => { | 73 | export const removeLocalStorageItem = (key: string) => { |
| 74 | try { | 74 | try { |
| 75 | isomorphicLocalStorage.removeItem(key); | 75 | getStorage().removeItem(key); |
| 76 | } catch (cause) { | 76 | } catch (cause) { |
| 77 | throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); | 77 | throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); |
| 78 | } | 78 | } |
apps/web/src/hooks/useSettings.test.ts+178-2| ... | @@ -3,12 +3,22 @@ import { | ... | @@ -3,12 +3,22 @@ import { |
| 3 | ProviderDriverKind, | 3 | ProviderDriverKind, |
| 4 | ProviderInstanceId, | 4 | ProviderInstanceId, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | 6 | import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; |
| 7 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 7 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 8 | |||
| 9 | const persistenceMocks = vi.hoisted(() => ({ | ||
| 10 | getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(), | ||
| 11 | setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(), | ||
| 12 | })); | ||
| 13 | |||
| 14 | vi.mock("~/localApi", () => ({ | ||
| 15 | ensureLocalApi: () => ({ persistence: persistenceMocks }), | ||
| 16 | })); | ||
| 8 | 17 | ||
| 9 | import { | 18 | import { |
| 10 | __resetClientSettingsPersistenceForTests, | 19 | __resetClientSettingsPersistenceForTests, |
| 11 | __setClientSettingsForTests, | 20 | __setClientSettingsForTests, |
| 21 | ensureClientSettingsHydrated, | ||
| 12 | getClientSettings, | 22 | getClientSettings, |
| 13 | mergeEnvironmentSettings, | 23 | mergeEnvironmentSettings, |
| 14 | persistClientSettingsPatch, | 24 | persistClientSettingsPatch, |
| ... | @@ -17,9 +27,138 @@ import { | ... | @@ -17,9 +27,138 @@ import { |
| 17 | } from "./useSettings"; | 27 | } from "./useSettings"; |
| 18 | 28 | ||
| 19 | beforeEach(() => { | 29 | beforeEach(() => { |
| 30 | persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); | ||
| 31 | persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined); | ||
| 20 | __resetClientSettingsPersistenceForTests(); | 32 | __resetClientSettingsPersistenceForTests(); |
| 21 | }); | 33 | }); |
| 22 | 34 | ||
| 35 | afterEach(() => { | ||
| 36 | vi.restoreAllMocks(); | ||
| 37 | }); | ||
| 38 | |||
| 39 | describe("client settings hydration", () => { | ||
| 40 | const savedSettings = { | ||
| 41 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 42 | timestampFormat: "12-hour" as const, | ||
| 43 | favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }], | ||
| 44 | }; | ||
| 45 | const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; | ||
| 46 | const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); | ||
| 47 | |||
| 48 | it("rejects completion after a failed read and preserves saved preferences on retry", async () => { | ||
| 49 | const failure = new Error("storage unavailable"); | ||
| 50 | vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 51 | persistenceMocks.getClientSettings | ||
| 52 | .mockRejectedValueOnce(failure) | ||
| 53 | .mockResolvedValue(savedSettings); | ||
| 54 | |||
| 55 | await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure); | ||
| 56 | expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); | ||
| 57 | expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); | ||
| 58 | |||
| 59 | const completedSettings = { ...savedSettings, onboardingCompletedAt }; | ||
| 60 | await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); | ||
| 61 | expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); | ||
| 62 | expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("uses defaults only after storage confirms no saved settings exist", async () => { | ||
| 66 | const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt }; | ||
| 67 | |||
| 68 | await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); | ||
| 69 | expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); | ||
| 70 | expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("holds patches until a pending read supplies the saved preferences", async () => { | ||
| 74 | let finishRead!: (settings: ClientSettings) => void; | ||
| 75 | persistenceMocks.getClientSettings.mockImplementationOnce( | ||
| 76 | () => | ||
| 77 | new Promise((resolve) => { | ||
| 78 | finishRead = resolve; | ||
| 79 | }), | ||
| 80 | ); | ||
| 81 | const persisted = new Promise<ClientSettings>((resolve) => { | ||
| 82 | persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { | ||
| 83 | resolve(settings); | ||
| 84 | }); | ||
| 85 | }); | ||
| 86 | |||
| 87 | const hydration = ensureClientSettingsHydrated(); | ||
| 88 | persistClientSettingsPatch({ wordWrap: false }); | ||
| 89 | expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); | ||
| 90 | expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); | ||
| 91 | |||
| 92 | finishRead(savedSettings); | ||
| 93 | await hydration; | ||
| 94 | await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); | ||
| 95 | expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("handles failed patch reads without writing and retries with the saved preferences", async () => { | ||
| 99 | const failure = new Error("storage unavailable"); | ||
| 100 | vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 101 | persistenceMocks.getClientSettings.mockRejectedValue(failure); | ||
| 102 | |||
| 103 | const hydration = ensureClientSettingsHydrated(); | ||
| 104 | persistClientSettingsPatch({ wordWrap: false }); | ||
| 105 | await expect(hydration).rejects.toBe(failure); | ||
| 106 | expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); | ||
| 107 | |||
| 108 | persistenceMocks.getClientSettings.mockResolvedValue(savedSettings); | ||
| 109 | const persisted = new Promise<ClientSettings>((resolve) => { | ||
| 110 | persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { | ||
| 111 | resolve(settings); | ||
| 112 | }); | ||
| 113 | }); | ||
| 114 | persistClientSettingsPatch({ wordWrap: false }); | ||
| 115 | |||
| 116 | await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("preserves patch order across hydration and a blocked completion write", async () => { | ||
| 120 | let finishRead!: (settings: ClientSettings) => void; | ||
| 121 | const read = new Promise<ClientSettings>((resolve) => { | ||
| 122 | finishRead = resolve; | ||
| 123 | }); | ||
| 124 | persistenceMocks.getClientSettings.mockReturnValue(read); | ||
| 125 | let finishCompletionWrite!: () => void; | ||
| 126 | const blockedWrite = new Promise<void>((resolve) => { | ||
| 127 | finishCompletionWrite = resolve; | ||
| 128 | }); | ||
| 129 | let signalCompletionWrite!: () => void; | ||
| 130 | const completionWriteStarted = new Promise<void>((resolve) => { | ||
| 131 | signalCompletionWrite = resolve; | ||
| 132 | }); | ||
| 133 | let durableSettings: ClientSettings = savedSettings; | ||
| 134 | const persist = vi | ||
| 135 | .fn<(settings: ClientSettings) => Promise<void>>() | ||
| 136 | .mockImplementationOnce(async (settings) => { | ||
| 137 | signalCompletionWrite(); | ||
| 138 | await blockedWrite; | ||
| 139 | durableSettings = settings; | ||
| 140 | }) | ||
| 141 | .mockImplementation(async (settings) => { | ||
| 142 | durableSettings = settings; | ||
| 143 | }); | ||
| 144 | |||
| 145 | const completion = persistClientSettingsUpdate(complete, persist); | ||
| 146 | persistClientSettingsPatch({ wordWrap: false }, persist); | ||
| 147 | finishRead(savedSettings); | ||
| 148 | await completionWriteStarted; | ||
| 149 | persistClientSettingsPatch({ wordWrap: true }, persist); | ||
| 150 | const finalWrite = persistClientSettingsUpdate((current) => current, persist); | ||
| 151 | |||
| 152 | finishCompletionWrite(); | ||
| 153 | await completion; | ||
| 154 | await finalWrite; | ||
| 155 | |||
| 156 | const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true }; | ||
| 157 | expect(getClientSettings()).toEqual(expected); | ||
| 158 | expect(durableSettings).toEqual(expected); | ||
| 159 | }); | ||
| 160 | }); | ||
| 161 | |||
| 23 | describe("persistClientSettingsUpdate", () => { | 162 | describe("persistClientSettingsUpdate", () => { |
| 24 | it("publishes the update only after persistence succeeds", async () => { | 163 | it("publishes the update only after persistence succeeds", async () => { |
| 25 | let finishPersistence!: () => void; | 164 | let finishPersistence!: () => void; |
| ... | @@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => { | ... | @@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => { |
| 245 | expect(settings.sidebarAutoSettleOnMerge).toBe(false); | 384 | expect(settings.sidebarAutoSettleOnMerge).toBe(false); |
| 246 | }); | 385 | }); |
| 247 | }); | 386 | }); |
| 387 | |||
| 388 | describe("onboarding completion persistence", () => { | ||
| 389 | it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => { | ||
| 390 | const failure = new Error("disk full"); | ||
| 391 | const persist = vi | ||
| 392 | .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>() | ||
| 393 | .mockRejectedValueOnce(failure) | ||
| 394 | .mockResolvedValue(undefined); | ||
| 395 | const existingSettings = { | ||
| 396 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 397 | timestampFormat: "12-hour" as const, | ||
| 398 | favorites: [ | ||
| 399 | { | ||
| 400 | provider: ProviderInstanceId.make("codex_work"), | ||
| 401 | model: "gpt-5.6", | ||
| 402 | }, | ||
| 403 | ], | ||
| 404 | }; | ||
| 405 | __setClientSettingsForTests(existingSettings); | ||
| 406 | const onboardingCompletedAt = "2026-09-01T12:00:00.000Z"; | ||
| 407 | const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({ | ||
| 408 | ...current, | ||
| 409 | onboardingCompletedAt, | ||
| 410 | }); | ||
| 411 | |||
| 412 | await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure); | ||
| 413 | expect(getClientSettings()).toBe(existingSettings); | ||
| 414 | expect(getClientSettings().onboardingCompletedAt).toBeNull(); | ||
| 415 | |||
| 416 | const completedSettings = { ...existingSettings, onboardingCompletedAt }; | ||
| 417 | await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual( | ||
| 418 | completedSettings, | ||
| 419 | ); | ||
| 420 | expect(getClientSettings()).toEqual(completedSettings); | ||
| 421 | expect(persist).toHaveBeenLastCalledWith(completedSettings); | ||
| 422 | }); | ||
| 423 | }); |
apps/web/src/hooks/useSettings.ts+67-24| ... | @@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; | ... | @@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; |
| 54 | 54 | ||
| 55 | const clientSettingsListeners = new Set<() => void>(); | 55 | const clientSettingsListeners = new Set<() => void>(); |
| 56 | const clientSettingsHydrationListeners = new Set<() => void>(); | 56 | const clientSettingsHydrationListeners = new Set<() => void>(); |
| 57 | type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; | ||
| 57 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | 58 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 58 | let clientSettingsHydrated = false; | 59 | let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending"; |
| 59 | let clientSettingsHydrationPromise: Promise<void> | null = null; | 60 | let clientSettingsHydrationPromise: Promise<void> | null = null; |
| 60 | let clientSettingsHydrationGeneration = 0; | 61 | let clientSettingsHydrationGeneration = 0; |
| 61 | let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve(); | 62 | let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve(); |
| 63 | let deferredClientSettingsPatchCount = 0; | ||
| 62 | 64 | ||
| 63 | function emitClientSettingsChange() { | 65 | function emitClientSettingsChange() { |
| 64 | for (const listener of clientSettingsListeners) { | 66 | for (const listener of clientSettingsListeners) { |
| ... | @@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { | ... | @@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { |
| 81 | emitClientSettingsChange(); | 83 | emitClientSettingsChange(); |
| 82 | } | 84 | } |
| 83 | 85 | ||
| 84 | function setClientSettingsHydrated(nextHydrated: boolean): void { | 86 | function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void { |
| 85 | if (clientSettingsHydrated === nextHydrated) { | 87 | if (clientSettingsHydrationStatus === nextStatus) { |
| 86 | return; | 88 | return; |
| 87 | } | 89 | } |
| 88 | clientSettingsHydrated = nextHydrated; | 90 | clientSettingsHydrationStatus = nextStatus; |
| 89 | emitClientSettingsHydrationChange(); | 91 | emitClientSettingsHydrationChange(); |
| 90 | } | 92 | } |
| 91 | 93 | ||
| 92 | function subscribeClientSettings(listener: () => void): () => void { | 94 | function subscribeClientSettings(listener: () => void): () => void { |
| 93 | clientSettingsListeners.add(listener); | 95 | clientSettingsListeners.add(listener); |
| 94 | void hydrateClientSettings(); | 96 | void hydrateClientSettings().catch(() => undefined); |
| 95 | return () => { | 97 | return () => { |
| 96 | clientSettingsListeners.delete(listener); | 98 | clientSettingsListeners.delete(listener); |
| 97 | }; | 99 | }; |
| 98 | } | 100 | } |
| 99 | 101 | ||
| 100 | function getClientSettingsHydratedSnapshot(): boolean { | 102 | function getClientSettingsHydratedSnapshot(): boolean { |
| 101 | return clientSettingsHydrated; | 103 | return clientSettingsHydrationStatus === "ready"; |
| 104 | } | ||
| 105 | |||
| 106 | function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus { | ||
| 107 | return clientSettingsHydrationStatus; | ||
| 102 | } | 108 | } |
| 103 | 109 | ||
| 104 | function subscribeClientSettingsHydration(listener: () => void): () => void { | 110 | function subscribeClientSettingsHydration(listener: () => void): () => void { |
| 105 | clientSettingsHydrationListeners.add(listener); | 111 | clientSettingsHydrationListeners.add(listener); |
| 106 | void hydrateClientSettings(); | 112 | void hydrateClientSettings().catch(() => undefined); |
| 107 | return () => { | 113 | return () => { |
| 108 | clientSettingsHydrationListeners.delete(listener); | 114 | clientSettingsHydrationListeners.delete(listener); |
| 109 | }; | 115 | }; |
| 110 | } | 116 | } |
| 111 | 117 | ||
| 112 | async function hydrateClientSettings(): Promise<void> { | 118 | async function hydrateClientSettings(): Promise<void> { |
| 113 | if (clientSettingsHydrated) { | 119 | if (clientSettingsHydrationStatus === "ready") { |
| 114 | return; | 120 | return; |
| 115 | } | 121 | } |
| 116 | if (clientSettingsHydrationPromise) { | 122 | if (clientSettingsHydrationPromise) { |
| ... | @@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise<void> { | ... | @@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise<void> { |
| 118 | } | 124 | } |
| 119 | 125 | ||
| 120 | const hydrationGeneration = clientSettingsHydrationGeneration; | 126 | const hydrationGeneration = clientSettingsHydrationGeneration; |
| 127 | setClientSettingsHydrationStatus( | ||
| 128 | clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying" | ||
| 129 | ? "retrying" | ||
| 130 | : "pending", | ||
| 131 | ); | ||
| 121 | const nextHydration = (async () => { | 132 | const nextHydration = (async () => { |
| 122 | try { | 133 | try { |
| 123 | const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); | 134 | const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); |
| ... | @@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise<void> { | ... | @@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise<void> { |
| 127 | if (persistedSettings) { | 138 | if (persistedSettings) { |
| 128 | replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); | 139 | replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); |
| 129 | } | 140 | } |
| 141 | setClientSettingsHydrationStatus("ready"); | ||
| 130 | } catch (error) { | 142 | } catch (error) { |
| 143 | if (hydrationGeneration === clientSettingsHydrationGeneration) { | ||
| 144 | setClientSettingsHydrationStatus("failed"); | ||
| 145 | } | ||
| 131 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { | 146 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { |
| 132 | operation: "hydrate", | 147 | operation: "hydrate", |
| 133 | ...safeErrorLogAttributes(error), | 148 | ...safeErrorLogAttributes(error), |
| 134 | }); | 149 | }); |
| 135 | } finally { | 150 | throw error; |
| 136 | if (hydrationGeneration === clientSettingsHydrationGeneration) { | ||
| 137 | setClientSettingsHydrated(true); | ||
| 138 | } | ||
| 139 | } | 151 | } |
| 140 | })(); | 152 | })(); |
| 141 | 153 | ||
| ... | @@ -165,15 +177,32 @@ export function persistClientSettingsPatch( | ... | @@ -165,15 +177,32 @@ export function persistClientSettingsPatch( |
| 165 | patch: ClientSettingsPatch, | 177 | patch: ClientSettingsPatch, |
| 166 | persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence, | 178 | persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence, |
| 167 | ): void { | 179 | ): void { |
| 168 | replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); | 180 | // Patches queued before hydration must publish before newer optimistic patches. |
| 169 | void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch( | 181 | const deferPatch = |
| 170 | (error) => { | 182 | clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; |
| 171 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { | 183 | if (deferPatch) { |
| 172 | operation: "persist", | 184 | deferredClientSettingsPatchCount += 1; |
| 173 | ...safeErrorLogAttributes(error), | 185 | } else { |
| 174 | }); | 186 | replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); |
| 175 | }, | 187 | } |
| 176 | ); | 188 | void enqueueClientSettingsPersistence(async () => { |
| 189 | if (deferPatch) { | ||
| 190 | try { | ||
| 191 | if (clientSettingsHydrationStatus !== "ready") { | ||
| 192 | await hydrateClientSettings(); | ||
| 193 | } | ||
| 194 | replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); | ||
| 195 | } finally { | ||
| 196 | deferredClientSettingsPatchCount -= 1; | ||
| 197 | } | ||
| 198 | } | ||
| 199 | await persist(getClientSettingsSnapshot()); | ||
| 200 | }).catch((error) => { | ||
| 201 | console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { | ||
| 202 | operation: "persist", | ||
| 203 | ...safeErrorLogAttributes(error), | ||
| 204 | }); | ||
| 205 | }); | ||
| 177 | } | 206 | } |
| 178 | 207 | ||
| 179 | /** | 208 | /** |
| ... | @@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate( | ... | @@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate( |
| 187 | persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence, | 216 | persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence, |
| 188 | ): Promise<ClientSettings> { | 217 | ): Promise<ClientSettings> { |
| 189 | return enqueueClientSettingsPersistence(async () => { | 218 | return enqueueClientSettingsPersistence(async () => { |
| 219 | if (clientSettingsHydrationStatus !== "ready") { | ||
| 220 | await hydrateClientSettings(); | ||
| 221 | } | ||
| 190 | for (;;) { | 222 | for (;;) { |
| 191 | const current = getClientSettingsSnapshot(); | 223 | const current = getClientSettingsSnapshot(); |
| 192 | const next = update(current); | 224 | const next = update(current); |
| ... | @@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings { | ... | @@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings { |
| 234 | } | 266 | } |
| 235 | 267 | ||
| 236 | /** | 268 | /** |
| 237 | * Resolves once client settings have been read from disk. | 269 | * Resolves after settings load or storage confirms no saved settings exist. |
| 270 | * Failed reads reject and remain retryable. They must not allow defaults to | ||
| 271 | * overwrite saved preferences. | ||
| 238 | * | 272 | * |
| 239 | * The pre-hydration snapshot is just the schema defaults, so imperative paths | 273 | * The pre-hydration snapshot is just the schema defaults, so imperative paths |
| 240 | * that open a preview must await this or they bake the built-in viewport, zoom | 274 | * that open a preview must await this or they bake the built-in viewport, zoom |
| ... | @@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean { | ... | @@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean { |
| 252 | ); | 286 | ); |
| 253 | } | 287 | } |
| 254 | 288 | ||
| 289 | export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus { | ||
| 290 | return useSyncExternalStore( | ||
| 291 | subscribeClientSettingsHydration, | ||
| 292 | getClientSettingsHydrationStatusSnapshot, | ||
| 293 | () => "pending", | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | |||
| 255 | function useClientSettingsValue(): ClientSettings { | 297 | function useClientSettingsValue(): ClientSettings { |
| 256 | return useSyncExternalStore( | 298 | return useSyncExternalStore( |
| 257 | subscribeClientSettings, | 299 | subscribeClientSettings, |
| ... | @@ -524,9 +566,10 @@ export function useUpdateClientSettings() { | ... | @@ -524,9 +566,10 @@ export function useUpdateClientSettings() { |
| 524 | export function __resetClientSettingsPersistenceForTests(): void { | 566 | export function __resetClientSettingsPersistenceForTests(): void { |
| 525 | clientSettingsHydrationGeneration += 1; | 567 | clientSettingsHydrationGeneration += 1; |
| 526 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | 568 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; |
| 527 | clientSettingsHydrated = false; | 569 | clientSettingsHydrationStatus = "pending"; |
| 528 | clientSettingsHydrationPromise = null; | 570 | clientSettingsHydrationPromise = null; |
| 529 | clientSettingsPersistenceQueue = Promise.resolve(); | 571 | clientSettingsPersistenceQueue = Promise.resolve(); |
| 572 | deferredClientSettingsPatchCount = 0; | ||
| 530 | clientSettingsListeners.clear(); | 573 | clientSettingsListeners.clear(); |
| 531 | clientSettingsHydrationListeners.clear(); | 574 | clientSettingsHydrationListeners.clear(); |
| 532 | } | 575 | } |
| ... | @@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void { | ... | @@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void { |
| 534 | export function __setClientSettingsForTests(settings: ClientSettings): void { | 577 | export function __setClientSettingsForTests(settings: ClientSettings): void { |
| 535 | clientSettingsHydrationGeneration += 1; | 578 | clientSettingsHydrationGeneration += 1; |
| 536 | clientSettingsSnapshot = settings; | 579 | clientSettingsSnapshot = settings; |
| 537 | clientSettingsHydrated = true; | 580 | clientSettingsHydrationStatus = "ready"; |
| 538 | clientSettingsHydrationPromise = null; | 581 | clientSettingsHydrationPromise = null; |
| 539 | } | 582 | } |
apps/web/src/hooks/useTheme.test.ts+197| ... | @@ -204,3 +204,200 @@ describe("theme failure handling", () => { | ... | @@ -204,3 +204,200 @@ describe("theme failure handling", () => { |
| 204 | } | 204 | } |
| 205 | }); | 205 | }); |
| 206 | }); | 206 | }); |
| 207 | |||
| 208 | describe("onboarding theme", () => { | ||
| 209 | it("clears custom palettes and restores the latest selected theme", async () => { | ||
| 210 | const storage = createStorage(); | ||
| 211 | const classes = new Set<string>(); | ||
| 212 | const styleValues = new Map<string, string>(); | ||
| 213 | const root = { | ||
| 214 | classList: { | ||
| 215 | add: (name: string) => classes.add(name), | ||
| 216 | remove: (name: string) => classes.delete(name), | ||
| 217 | toggle: (name: string, force?: boolean) => { | ||
| 218 | const next = force ?? !classes.has(name); | ||
| 219 | if (next) classes.add(name); | ||
| 220 | else classes.delete(name); | ||
| 221 | return next; | ||
| 222 | }, | ||
| 223 | }, | ||
| 224 | dataset: {} as Record<string, string>, | ||
| 225 | offsetHeight: 0, | ||
| 226 | style: { | ||
| 227 | backgroundColor: "", | ||
| 228 | removeProperty: (name: string) => styleValues.delete(name), | ||
| 229 | setProperty: (name: string, value: string) => styleValues.set(name, value), | ||
| 230 | }, | ||
| 231 | }; | ||
| 232 | vi.doMock("react", () => ({ | ||
| 233 | useCallback: <A>(callback: A) => callback, | ||
| 234 | useEffect: () => undefined, | ||
| 235 | useSyncExternalStore: ( | ||
| 236 | subscribe: (listener: () => void) => () => void, | ||
| 237 | getSnapshot: () => unknown, | ||
| 238 | ) => { | ||
| 239 | subscribe(() => undefined); | ||
| 240 | return getSnapshot(); | ||
| 241 | }, | ||
| 242 | })); | ||
| 243 | vi.stubGlobal("window", { | ||
| 244 | addEventListener: () => undefined, | ||
| 245 | localStorage: storage, | ||
| 246 | matchMedia: () => ({ | ||
| 247 | matches: false, | ||
| 248 | addEventListener: () => undefined, | ||
| 249 | removeEventListener: () => undefined, | ||
| 250 | }), | ||
| 251 | removeEventListener: () => undefined, | ||
| 252 | }); | ||
| 253 | vi.stubGlobal("document", { | ||
| 254 | body: { style: { backgroundColor: "" } }, | ||
| 255 | createElement: () => ({ name: "", setAttribute: () => undefined }), | ||
| 256 | documentElement: root, | ||
| 257 | head: { append: () => undefined }, | ||
| 258 | querySelector: () => null, | ||
| 259 | querySelectorAll: () => [], | ||
| 260 | }); | ||
| 261 | vi.stubGlobal("getComputedStyle", () => ({ | ||
| 262 | backgroundColor: "rgb(0, 0, 0)", | ||
| 263 | getPropertyValue: () => "", | ||
| 264 | })); | ||
| 265 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | ||
| 266 | callback(0); | ||
| 267 | return 0; | ||
| 268 | }); | ||
| 269 | |||
| 270 | const { EMBER_THEME, installCustomTheme } = await import("../themePalette"); | ||
| 271 | const firstTheme = installCustomTheme({ | ||
| 272 | ...EMBER_THEME, | ||
| 273 | id: "first-custom", | ||
| 274 | label: "First Custom", | ||
| 275 | }); | ||
| 276 | const secondTheme = installCustomTheme({ | ||
| 277 | ...EMBER_THEME, | ||
| 278 | id: "second-custom", | ||
| 279 | label: "Second Custom", | ||
| 280 | colors: { ...EMBER_THEME.colors, error: "#123456" }, | ||
| 281 | }); | ||
| 282 | storage.setItem("t3code:theme", firstTheme.id); | ||
| 283 | |||
| 284 | const { mountOnboardingTheme, useTheme } = await import("./useTheme"); | ||
| 285 | expect(root.dataset.themeId).toBe(firstTheme.id); | ||
| 286 | expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error); | ||
| 287 | |||
| 288 | const cleanup = mountOnboardingTheme(); | ||
| 289 | expect(root.dataset.themeId).toBeUndefined(); | ||
| 290 | expect(styleValues.size).toBe(0); | ||
| 291 | |||
| 292 | expect(useTheme().setTheme(secondTheme.id)).toBe(true); | ||
| 293 | expect(root.dataset.themeId).toBeUndefined(); | ||
| 294 | expect(styleValues.size).toBe(0); | ||
| 295 | |||
| 296 | cleanup(); | ||
| 297 | expect(root.dataset.themeId).toBe(secondTheme.id); | ||
| 298 | expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error); | ||
| 299 | }); | ||
| 300 | |||
| 301 | it("stays dark during storage changes and restores the latest saved theme", async () => { | ||
| 302 | const storage = createStorage(); | ||
| 303 | storage.setItem("t3code:theme", "light"); | ||
| 304 | const classes = new Set<string>(); | ||
| 305 | const styleValues = new Map<string, string>(); | ||
| 306 | const style = { | ||
| 307 | backgroundColor: "", | ||
| 308 | removeProperty: (name: string) => styleValues.delete(name), | ||
| 309 | setProperty: (name: string, value: string) => styleValues.set(name, value), | ||
| 310 | }; | ||
| 311 | const root = { | ||
| 312 | classList: { | ||
| 313 | add: (name: string) => classes.add(name), | ||
| 314 | contains: (name: string) => classes.has(name), | ||
| 315 | remove: (name: string) => classes.delete(name), | ||
| 316 | toggle: (name: string, force?: boolean) => { | ||
| 317 | const next = force ?? !classes.has(name); | ||
| 318 | if (next) classes.add(name); | ||
| 319 | else classes.delete(name); | ||
| 320 | return next; | ||
| 321 | }, | ||
| 322 | }, | ||
| 323 | dataset: {} as Record<string, string>, | ||
| 324 | offsetHeight: 0, | ||
| 325 | style, | ||
| 326 | }; | ||
| 327 | const body = { style: { backgroundColor: "" } }; | ||
| 328 | let storageHandler: ((event: StorageEvent) => void) | undefined; | ||
| 329 | const setDesktopTheme = vi.fn().mockResolvedValue(undefined); | ||
| 330 | vi.doMock("react", () => ({ | ||
| 331 | useCallback: <A>(callback: A) => callback, | ||
| 332 | useEffect: () => undefined, | ||
| 333 | useSyncExternalStore: ( | ||
| 334 | subscribe: (listener: () => void) => () => void, | ||
| 335 | getSnapshot: () => unknown, | ||
| 336 | ) => { | ||
| 337 | subscribe(() => undefined); | ||
| 338 | return getSnapshot(); | ||
| 339 | }, | ||
| 340 | })); | ||
| 341 | vi.stubGlobal("window", { | ||
| 342 | addEventListener: (type: string, listener: (event: StorageEvent) => void) => { | ||
| 343 | if (type === "storage") storageHandler = listener; | ||
| 344 | }, | ||
| 345 | localStorage: storage, | ||
| 346 | matchMedia: () => ({ | ||
| 347 | matches: false, | ||
| 348 | addEventListener: () => undefined, | ||
| 349 | removeEventListener: () => undefined, | ||
| 350 | }), | ||
| 351 | removeEventListener: () => undefined, | ||
| 352 | desktopBridge: { setTheme: setDesktopTheme }, | ||
| 353 | }); | ||
| 354 | vi.stubGlobal("document", { | ||
| 355 | body, | ||
| 356 | createElement: () => ({ name: "", setAttribute: () => undefined }), | ||
| 357 | documentElement: root, | ||
| 358 | head: { append: () => undefined }, | ||
| 359 | querySelector: () => null, | ||
| 360 | querySelectorAll: () => [], | ||
| 361 | }); | ||
| 362 | vi.stubGlobal("getComputedStyle", () => ({ | ||
| 363 | backgroundColor: | ||
| 364 | root.dataset.onboardingSurface !== undefined | ||
| 365 | ? "rgb(0, 0, 0)" | ||
| 366 | : classes.has("dark") | ||
| 367 | ? "rgb(10, 10, 10)" | ||
| 368 | : "rgb(255, 255, 255)", | ||
| 369 | getPropertyValue: () => "", | ||
| 370 | })); | ||
| 371 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | ||
| 372 | callback(0); | ||
| 373 | return 0; | ||
| 374 | }); | ||
| 375 | |||
| 376 | const { mountOnboardingTheme, useTheme } = await import("./useTheme"); | ||
| 377 | expect(useTheme().resolvedTheme).toBe("light"); | ||
| 378 | const cleanup = mountOnboardingTheme(); | ||
| 379 | |||
| 380 | expect(root.dataset.onboardingSurface).toBe(""); | ||
| 381 | expect(classes.has("dark")).toBe(true); | ||
| 382 | expect(root.style.backgroundColor).toBe("#000"); | ||
| 383 | expect(body.style.backgroundColor).toBe("#000"); | ||
| 384 | expect(useTheme().resolvedTheme).toBe("dark"); | ||
| 385 | expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); | ||
| 386 | |||
| 387 | storage.setItem("t3code:theme", "dark"); | ||
| 388 | storageHandler?.({ key: "t3code:theme" } as StorageEvent); | ||
| 389 | storage.setItem("t3code:theme", "light"); | ||
| 390 | storageHandler?.({ key: "t3code:theme" } as StorageEvent); | ||
| 391 | expect(classes.has("dark")).toBe(true); | ||
| 392 | expect(useTheme().resolvedTheme).toBe("dark"); | ||
| 393 | |||
| 394 | cleanup(); | ||
| 395 | expect(root.dataset.onboardingSurface).toBeUndefined(); | ||
| 396 | expect(classes.has("dark")).toBe(false); | ||
| 397 | expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)"); | ||
| 398 | expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)"); | ||
| 399 | expect(storage.getItem("t3code:theme")).toBe("light"); | ||
| 400 | expect(useTheme().resolvedTheme).toBe("light"); | ||
| 401 | expect(setDesktopTheme).toHaveBeenLastCalledWith("light"); | ||
| 402 | }); | ||
| 403 | }); |
apps/web/src/hooks/useTheme.ts+67-17| ... | @@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { | ... | @@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { |
| 98 | function themeHalvesSignature(halves: ThemeHalves | null): string { | 98 | function themeHalvesSignature(halves: ThemeHalves | null): string { |
| 99 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; | 99 | return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; |
| 100 | } | 100 | } |
| 101 | |||
| 102 | function isOnboardingThemeActive(): boolean { | ||
| 103 | return ( | ||
| 104 | typeof document !== "undefined" && | ||
| 105 | document.documentElement.dataset?.onboardingSurface !== undefined | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | |||
| 101 | const THEME_COLOR_META_NAME = "theme-color"; | 109 | const THEME_COLOR_META_NAME = "theme-color"; |
| 102 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; | 110 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; |
| 103 | 111 | ||
| ... | @@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement { | ... | @@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement { |
| 292 | 300 | ||
| 293 | export function syncBrowserChromeTheme() { | 301 | export function syncBrowserChromeTheme() { |
| 294 | if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; | 302 | if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; |
| 303 | const onboardingActive = isOnboardingThemeActive(); | ||
| 295 | const rootStyles = getComputedStyle(document.documentElement); | 304 | const rootStyles = getComputedStyle(document.documentElement); |
| 296 | const themeChromeColor = document.documentElement.dataset.themeId | 305 | const themeChromeColor = |
| 297 | ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) | 306 | !onboardingActive && document.documentElement.dataset.themeId |
| 298 | : null; | 307 | ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) |
| 308 | : null; | ||
| 299 | const surfaceColor = normalizeThemeColor( | 309 | const surfaceColor = normalizeThemeColor( |
| 300 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, | 310 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, |
| 301 | ); | 311 | ); |
| 302 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); | 312 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); |
| 303 | const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; | 313 | const backgroundColor = onboardingActive |
| 314 | ? "#000" | ||
| 315 | : (themeChromeColor ?? surfaceColor ?? fallbackColor); | ||
| 304 | if (!backgroundColor) return; | 316 | if (!backgroundColor) return; |
| 305 | 317 | ||
| 306 | document.documentElement.style.backgroundColor = backgroundColor; | 318 | document.documentElement.style.backgroundColor = backgroundColor; |
| ... | @@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() { | ... | @@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() { |
| 321 | 333 | ||
| 322 | function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { | 334 | function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { |
| 323 | if (typeof document === "undefined" || typeof window === "undefined") return; | 335 | if (typeof document === "undefined" || typeof window === "undefined") return; |
| 336 | const onboardingActive = isOnboardingThemeActive(); | ||
| 324 | // Keep the editor's draft visible until an explicit refresh restores the selection. | 337 | // Keep the editor's draft visible until an explicit refresh restores the selection. |
| 325 | if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; | 338 | if ( |
| 339 | preservePreview && | ||
| 340 | !onboardingActive && | ||
| 341 | document.documentElement.dataset?.themeId === THEME_PREVIEW_ID | ||
| 342 | ) { | ||
| 343 | return; | ||
| 344 | } | ||
| 326 | const appearanceMode = readAppearanceModePreference(theme); | 345 | const appearanceMode = readAppearanceModePreference(theme); |
| 327 | const followSystem = appearanceMode === "system"; | 346 | const followSystem = appearanceMode === "system"; |
| 328 | const systemDark = followSystem ? getSystemDark() : false; | 347 | const systemDark = followSystem ? getSystemDark() : false; |
| ... | @@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview | ... | @@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview |
| 334 | lastAppliedTheme.appearanceMode === appearanceMode && | 353 | lastAppliedTheme.appearanceMode === appearanceMode && |
| 335 | themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) | 354 | themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) |
| 336 | ) { | 355 | ) { |
| 337 | syncDesktopTheme(theme, followSystem, appearanceMode); | 356 | if (onboardingActive) { |
| 357 | document.documentElement.classList.add("dark"); | ||
| 358 | syncBrowserChromeTheme(); | ||
| 359 | syncDesktopTheme("dark", false, "dark"); | ||
| 360 | } else { | ||
| 361 | syncDesktopTheme(theme, followSystem, appearanceMode); | ||
| 362 | } | ||
| 338 | return; | 363 | return; |
| 339 | } | 364 | } |
| 340 | 365 | ||
| ... | @@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview | ... | @@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview |
| 348 | appearanceMode, | 373 | appearanceMode, |
| 349 | themeHalves, | 374 | themeHalves, |
| 350 | ); | 375 | ); |
| 351 | applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); | 376 | if (onboardingActive) { |
| 352 | const isDark = resolvedAppearance === "dark"; | 377 | document.documentElement.classList.add("dark"); |
| 353 | document.documentElement.classList.toggle("dark", isDark); | 378 | } else { |
| 379 | applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); | ||
| 380 | document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); | ||
| 381 | } | ||
| 354 | lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; | 382 | lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 355 | syncBrowserChromeTheme(); | 383 | syncBrowserChromeTheme(); |
| 356 | syncDesktopTheme(theme, followSystem, appearanceMode); | 384 | if (onboardingActive) { |
| 385 | syncDesktopTheme("dark", false, "dark"); | ||
| 386 | } else { | ||
| 387 | syncDesktopTheme(theme, followSystem, appearanceMode); | ||
| 388 | } | ||
| 357 | if (suppressTransitions) { | 389 | if (suppressTransitions) { |
| 358 | // Force a reflow so the no-transitions class takes effect before removal | 390 | // Force a reflow so the no-transitions class takes effect before removal |
| 359 | void document.documentElement.offsetHeight; | 391 | void document.documentElement.offsetHeight; |
| ... | @@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview | ... | @@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview |
| 363 | } | 395 | } |
| 364 | } | 396 | } |
| 365 | 397 | ||
| 398 | /** Own the document-wide dark palette used by the first-run wizard and its portals. */ | ||
| 399 | export function mountOnboardingTheme(): () => void { | ||
| 400 | if (typeof document === "undefined" || typeof window === "undefined") return () => {}; | ||
| 401 | |||
| 402 | const root = document.documentElement; | ||
| 403 | applyThemePalette("dark", "dark"); | ||
| 404 | root.dataset.onboardingSurface = ""; | ||
| 405 | root.classList.add("dark"); | ||
| 406 | syncBrowserChromeTheme(); | ||
| 407 | syncDesktopTheme("dark", false, "dark"); | ||
| 408 | emitChange(); | ||
| 409 | |||
| 410 | return () => { | ||
| 411 | delete root.dataset.onboardingSurface; | ||
| 412 | root.style.backgroundColor = ""; | ||
| 413 | document.body.style.backgroundColor = ""; | ||
| 414 | lastAppliedTheme = null; | ||
| 415 | applyTheme(getStored(), { suppressTransitions: true, preservePreview: false }); | ||
| 416 | emitChange(); | ||
| 417 | }; | ||
| 418 | } | ||
| 419 | |||
| 366 | export async function syncDesktopThemePreference( | 420 | export async function syncDesktopThemePreference( |
| 367 | bridge: DesktopThemeBridge, | 421 | bridge: DesktopThemeBridge, |
| 368 | theme: Theme, | 422 | theme: Theme, |
| ... | @@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot { | ... | @@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot { |
| 424 | const systemDark = followSystem ? getSystemDark() : false; | 478 | const systemDark = followSystem ? getSystemDark() : false; |
| 425 | const themeHalves = readStoredThemeHalves(); | 479 | const themeHalves = readStoredThemeHalves(); |
| 426 | 480 | ||
| 427 | const resolvedTheme = resolveThemeAppearance( | 481 | const resolvedTheme = isOnboardingThemeActive() |
| 428 | theme, | 482 | ? "dark" |
| 429 | systemDark, | 483 | : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves); |
| 430 | followSystem, | ||
| 431 | appearanceMode, | ||
| 432 | themeHalves, | ||
| 433 | ); | ||
| 434 | if ( | 484 | if ( |
| 435 | lastSnapshot && | 485 | lastSnapshot && |
| 436 | lastSnapshot.theme === theme && | 486 | lastSnapshot.theme === theme && |
apps/web/src/index.css+36-4| ... | @@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) { | ... | @@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) { |
| 1184 | --terminal-selection-background: var(--app-theme-terminal-selection-background); | 1184 | --terminal-selection-background: var(--app-theme-terminal-selection-background); |
| 1185 | } | 1185 | } |
| 1186 | 1186 | ||
| 1187 | /* The first-run flow owns the whole document so portaled menus and tooltips | ||
| 1188 | use the same fixed palette as the wizard. This follows the theme mapping so | ||
| 1189 | saved custom themes cannot override it while onboarding is mounted. */ | ||
| 1190 | html[data-onboarding-surface]:root { | ||
| 1191 | color-scheme: dark; | ||
| 1192 | --accent: #262626; | ||
| 1193 | --accent-foreground: #fff; | ||
| 1194 | --appearance-contrast-target: #fff; | ||
| 1195 | --app-chrome-background: #000; | ||
| 1196 | --background: #000; | ||
| 1197 | --border: #262626; | ||
| 1198 | --card: #000; | ||
| 1199 | --card-foreground: #fff; | ||
| 1200 | --destructive: var(--color-red-400); | ||
| 1201 | --foreground: #fff; | ||
| 1202 | --icon-muted: #a1a1aa; | ||
| 1203 | --input: #262626; | ||
| 1204 | --muted: #171717; | ||
| 1205 | --muted-foreground: #a1a1aa; | ||
| 1206 | --placeholder: #71717a; | ||
| 1207 | --popover: #171717; | ||
| 1208 | --popover-foreground: #fff; | ||
| 1209 | --ring: #737373; | ||
| 1210 | --secondary: #171717; | ||
| 1211 | --secondary-foreground: #fff; | ||
| 1212 | --secondary-label: #a1a1aa; | ||
| 1213 | --success-foreground: var(--color-emerald-400); | ||
| 1214 | --terminal-background: #000; | ||
| 1215 | --terminal-cursor: #fff; | ||
| 1216 | --terminal-foreground: #fff; | ||
| 1217 | --terminal-selection-background: rgb(255 255 255 / 20%); | ||
| 1218 | } | ||
| 1219 | |||
| 1187 | /* Theme-token dependency probes are restored synchronously, before paint. Keep | 1220 | /* Theme-token dependency probes are restored synchronously, before paint. Keep |
| 1188 | transitions from observing the temporary sentinel color in between. */ | 1221 | transitions from observing the temporary sentinel color in between. */ |
| 1189 | html[data-theme-token-probe], | 1222 | html[data-theme-token-probe], |
| ... | @@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] { | ... | @@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] { |
| 1385 | } | 1418 | } |
| 1386 | } | 1419 | } |
| 1387 | 1420 | ||
| 1388 | /* Contrast stays in ordinary custom properties so both Tailwind utilities and | 1421 | /* Recompute contrast wherever a subtree owns its own semantic color palette. */ |
| 1389 | global/imperative chrome styles resolve the same adjusted role. Redeclare on | ||
| 1390 | the sidebar because it owns a local semantic palette. */ | ||
| 1391 | :root, | 1422 | :root, |
| 1392 | [data-app-sidebar] { | 1423 | [data-app-sidebar], |
| 1424 | [data-onboarding-surface] { | ||
| 1393 | --contrast-toolbar-foreground: color-mix( | 1425 | --contrast-toolbar-foreground: color-mix( |
| 1394 | in oklab, | 1426 | in oklab, |
| 1395 | color-mix( | 1427 | color-mix( |
apps/web/src/onboarding/firstRun.logic.test.ts created+514| ... | @@ -0,0 +1,514 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isFirstRunWorkspaceProvenanceAuthoritative, | ||
| 5 | isFreshFirstRunWorkspace, | ||
| 6 | resolveFirstRunDecision, | ||
| 7 | resolveHostedFirstRunDecision, | ||
| 8 | transitionFirstRunGateState, | ||
| 9 | } from "./firstRun.logic"; | ||
| 10 | |||
| 11 | const freshWorkspace = { | ||
| 12 | enabled: true, | ||
| 13 | hydrated: true, | ||
| 14 | completed: false, | ||
| 15 | bootstrapped: true, | ||
| 16 | authoritative: true, | ||
| 17 | workspaceAuthoritative: true, | ||
| 18 | workspaceProvenanceAuthoritative: true, | ||
| 19 | catalogReady: true, | ||
| 20 | serverConfigAvailable: true, | ||
| 21 | workspaceFresh: true, | ||
| 22 | projectCount: 1, | ||
| 23 | threadCount: 1, | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | describe("resolveFirstRunDecision", () => { | ||
| 27 | it("opens the wizard for an authoritative fresh workspace", () => { | ||
| 28 | expect(resolveFirstRunDecision(freshWorkspace)).toEqual({ | ||
| 29 | decision: "wizard", | ||
| 30 | persistCompletion: false, | ||
| 31 | }); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("does not permanently complete onboarding from cached project counts", () => { | ||
| 35 | expect( | ||
| 36 | resolveFirstRunDecision({ | ||
| 37 | ...freshWorkspace, | ||
| 38 | authoritative: false, | ||
| 39 | projectCount: 3, | ||
| 40 | workspaceFresh: false, | ||
| 41 | }), | ||
| 42 | ).toEqual({ | ||
| 43 | decision: "app", | ||
| 44 | persistCompletion: false, | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("backfills completion once existing projects are confirmed by the server", () => { | ||
| 49 | expect( | ||
| 50 | resolveFirstRunDecision({ | ||
| 51 | ...freshWorkspace, | ||
| 52 | projectCount: 3, | ||
| 53 | workspaceFresh: false, | ||
| 54 | }), | ||
| 55 | ).toEqual({ | ||
| 56 | decision: "app", | ||
| 57 | persistCompletion: true, | ||
| 58 | }); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("does not complete onboarding while another environment is still bootstrapping", () => { | ||
| 62 | expect( | ||
| 63 | resolveFirstRunDecision({ | ||
| 64 | ...freshWorkspace, | ||
| 65 | bootstrapped: false, | ||
| 66 | projectCount: 3, | ||
| 67 | workspaceFresh: false, | ||
| 68 | }), | ||
| 69 | ).toEqual({ | ||
| 70 | decision: "app", | ||
| 71 | persistCompletion: false, | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("waits for managed environments before treating a workspace as new", () => { | ||
| 76 | expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({ | ||
| 77 | decision: "pending", | ||
| 78 | persistCompletion: false, | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("does not complete onboarding before the environment catalog is ready", () => { | ||
| 83 | expect( | ||
| 84 | resolveFirstRunDecision({ | ||
| 85 | ...freshWorkspace, | ||
| 86 | catalogReady: false, | ||
| 87 | projectCount: 3, | ||
| 88 | workspaceFresh: false, | ||
| 89 | }), | ||
| 90 | ).toEqual({ | ||
| 91 | decision: "app", | ||
| 92 | persistCompletion: false, | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("does not complete onboarding before the server configuration is available", () => { | ||
| 97 | expect( | ||
| 98 | resolveFirstRunDecision({ | ||
| 99 | ...freshWorkspace, | ||
| 100 | projectCount: 3, | ||
| 101 | serverConfigAvailable: false, | ||
| 102 | workspaceFresh: false, | ||
| 103 | }), | ||
| 104 | ).toEqual({ | ||
| 105 | decision: "app", | ||
| 106 | persistCompletion: false, | ||
| 107 | }); | ||
| 108 | }); | ||
| 109 | |||
| 110 | it("does not complete onboarding from cached remote projects", () => { | ||
| 111 | expect( | ||
| 112 | resolveFirstRunDecision({ | ||
| 113 | ...freshWorkspace, | ||
| 114 | projectCount: 3, | ||
| 115 | workspaceAuthoritative: false, | ||
| 116 | workspaceFresh: false, | ||
| 117 | }), | ||
| 118 | ).toEqual({ | ||
| 119 | decision: "app", | ||
| 120 | persistCompletion: false, | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("does not complete onboarding from a single cached remote project", () => { | ||
| 125 | expect( | ||
| 126 | resolveFirstRunDecision({ | ||
| 127 | ...freshWorkspace, | ||
| 128 | workspaceAuthoritative: false, | ||
| 129 | workspaceFresh: false, | ||
| 130 | }), | ||
| 131 | ).toEqual({ | ||
| 132 | decision: "app", | ||
| 133 | persistCompletion: false, | ||
| 134 | }); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("waits for live data before judging a single cached project", () => { | ||
| 138 | expect( | ||
| 139 | resolveFirstRunDecision({ | ||
| 140 | ...freshWorkspace, | ||
| 141 | authoritative: false, | ||
| 142 | workspaceFresh: false, | ||
| 143 | }), | ||
| 144 | ).toEqual({ | ||
| 145 | decision: "pending", | ||
| 146 | persistCompletion: false, | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => { | ||
| 151 | expect( | ||
| 152 | resolveFirstRunDecision({ | ||
| 153 | ...freshWorkspace, | ||
| 154 | workspaceProvenanceAuthoritative: false, | ||
| 155 | }), | ||
| 156 | ).toEqual({ | ||
| 157 | decision: "pending", | ||
| 158 | persistCompletion: false, | ||
| 159 | }); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("waits when the initial welcome is pending and opens the wizard after completion", () => { | ||
| 163 | const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 164 | welcomeReceived: true, | ||
| 165 | bootstrapStatus: "pending", | ||
| 166 | }); | ||
| 167 | expect( | ||
| 168 | resolveFirstRunDecision({ | ||
| 169 | ...freshWorkspace, | ||
| 170 | workspaceProvenanceAuthoritative: pendingProvenance, | ||
| 171 | }), | ||
| 172 | ).toEqual({ decision: "pending", persistCompletion: false }); | ||
| 173 | |||
| 174 | const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 175 | welcomeReceived: true, | ||
| 176 | bootstrapStatus: "complete", | ||
| 177 | }); | ||
| 178 | expect( | ||
| 179 | resolveFirstRunDecision({ | ||
| 180 | ...freshWorkspace, | ||
| 181 | workspaceProvenanceAuthoritative: completedProvenance, | ||
| 182 | }), | ||
| 183 | ).toEqual({ decision: "wizard", persistCompletion: false }); | ||
| 184 | }); | ||
| 185 | |||
| 186 | it("does not wait for server data after onboarding is already complete", () => { | ||
| 187 | expect( | ||
| 188 | resolveFirstRunDecision({ | ||
| 189 | ...freshWorkspace, | ||
| 190 | authoritative: false, | ||
| 191 | bootstrapped: false, | ||
| 192 | completed: true, | ||
| 193 | serverConfigAvailable: false, | ||
| 194 | }), | ||
| 195 | ).toEqual({ | ||
| 196 | decision: "app", | ||
| 197 | persistCompletion: false, | ||
| 198 | }); | ||
| 199 | }); | ||
| 200 | }); | ||
| 201 | |||
| 202 | describe("isFirstRunWorkspaceProvenanceAuthoritative", () => { | ||
| 203 | it("waits for cwd bootstrap when the initial catalog is empty", () => { | ||
| 204 | expect( | ||
| 205 | isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 206 | welcomeReceived: true, | ||
| 207 | bootstrapStatus: "pending", | ||
| 208 | }), | ||
| 209 | ).toBe(false); | ||
| 210 | }); | ||
| 211 | |||
| 212 | it("accepts an empty catalog after cwd bootstrap completes", () => { | ||
| 213 | expect( | ||
| 214 | isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 215 | welcomeReceived: true, | ||
| 216 | bootstrapStatus: "complete", | ||
| 217 | }), | ||
| 218 | ).toBe(true); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("waits for a welcome before treating an empty catalog as final", () => { | ||
| 222 | expect( | ||
| 223 | isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 224 | welcomeReceived: false, | ||
| 225 | bootstrapStatus: null, | ||
| 226 | }), | ||
| 227 | ).toBe(false); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it("accepts a legacy welcome without bootstrap status", () => { | ||
| 231 | expect( | ||
| 232 | isFirstRunWorkspaceProvenanceAuthoritative({ | ||
| 233 | welcomeReceived: true, | ||
| 234 | bootstrapStatus: null, | ||
| 235 | }), | ||
| 236 | ).toBe(true); | ||
| 237 | }); | ||
| 238 | }); | ||
| 239 | |||
| 240 | describe("transitionFirstRunGateState", () => { | ||
| 241 | it("shows recovery without mounting the app when evidence stalls", () => { | ||
| 242 | expect( | ||
| 243 | transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }), | ||
| 244 | ).toEqual({ decision: "pending", stalled: true }); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it.each(["app", "wizard"] as const)( | ||
| 248 | "resolves stalled recovery to %s only after authoritative evidence", | ||
| 249 | (decision) => { | ||
| 250 | expect( | ||
| 251 | transitionFirstRunGateState( | ||
| 252 | { decision: "pending", stalled: true }, | ||
| 253 | { type: "evidence", decision }, | ||
| 254 | ), | ||
| 255 | ).toEqual({ decision, stalled: false }); | ||
| 256 | }, | ||
| 257 | ); | ||
| 258 | |||
| 259 | it("keeps recovery visible while evidence remains pending", () => { | ||
| 260 | const state = { decision: "pending", stalled: true } as const; | ||
| 261 | expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( | ||
| 262 | state, | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | |||
| 266 | it("allows authoritative wizard evidence to replace an app decision", () => { | ||
| 267 | expect( | ||
| 268 | transitionFirstRunGateState( | ||
| 269 | { decision: "app", stalled: false }, | ||
| 270 | { type: "evidence", decision: "wizard" }, | ||
| 271 | ), | ||
| 272 | ).toEqual({ decision: "wizard", stalled: false }); | ||
| 273 | }); | ||
| 274 | }); | ||
| 275 | |||
| 276 | describe("resolveHostedFirstRunDecision", () => { | ||
| 277 | it("keeps the shell hidden until client settings are hydrated", () => { | ||
| 278 | expect( | ||
| 279 | resolveHostedFirstRunDecision({ | ||
| 280 | hydrated: false, | ||
| 281 | completed: false, | ||
| 282 | catalogReady: true, | ||
| 283 | environmentCount: 0, | ||
| 284 | }), | ||
| 285 | ).toEqual({ | ||
| 286 | decision: "pending", | ||
| 287 | persistCompletion: false, | ||
| 288 | }); | ||
| 289 | }); | ||
| 290 | |||
| 291 | it("waits for the saved environment catalog before judging a hosted install", () => { | ||
| 292 | expect( | ||
| 293 | resolveHostedFirstRunDecision({ | ||
| 294 | hydrated: true, | ||
| 295 | completed: false, | ||
| 296 | catalogReady: false, | ||
| 297 | environmentCount: 0, | ||
| 298 | }), | ||
| 299 | ).toEqual({ | ||
| 300 | decision: "pending", | ||
| 301 | persistCompletion: false, | ||
| 302 | }); | ||
| 303 | }); | ||
| 304 | |||
| 305 | it("opens onboarding when a hosted install has no saved environments", () => { | ||
| 306 | expect( | ||
| 307 | resolveHostedFirstRunDecision({ | ||
| 308 | hydrated: true, | ||
| 309 | completed: false, | ||
| 310 | catalogReady: true, | ||
| 311 | environmentCount: 0, | ||
| 312 | }), | ||
| 313 | ).toEqual({ | ||
| 314 | decision: "wizard", | ||
| 315 | persistCompletion: false, | ||
| 316 | }); | ||
| 317 | }); | ||
| 318 | |||
| 319 | it("backfills onboarding for a hosted install with saved environments", () => { | ||
| 320 | expect( | ||
| 321 | resolveHostedFirstRunDecision({ | ||
| 322 | hydrated: true, | ||
| 323 | completed: false, | ||
| 324 | catalogReady: true, | ||
| 325 | environmentCount: 1, | ||
| 326 | }), | ||
| 327 | ).toEqual({ | ||
| 328 | decision: "app", | ||
| 329 | persistCompletion: true, | ||
| 330 | }); | ||
| 331 | }); | ||
| 332 | |||
| 333 | it("opens the app immediately after hosted onboarding is complete", () => { | ||
| 334 | expect( | ||
| 335 | resolveHostedFirstRunDecision({ | ||
| 336 | hydrated: true, | ||
| 337 | completed: true, | ||
| 338 | catalogReady: false, | ||
| 339 | environmentCount: 0, | ||
| 340 | }), | ||
| 341 | ).toEqual({ | ||
| 342 | decision: "app", | ||
| 343 | persistCompletion: false, | ||
| 344 | }); | ||
| 345 | }); | ||
| 346 | }); | ||
| 347 | |||
| 348 | const primaryEnvironmentId = "primary-environment"; | ||
| 349 | const bootstrapProject = { | ||
| 350 | id: "bootstrap-project", | ||
| 351 | environmentId: primaryEnvironmentId, | ||
| 352 | workspaceRoot: "/projects/current", | ||
| 353 | }; | ||
| 354 | const bootstrapThread = { | ||
| 355 | id: "bootstrap-thread", | ||
| 356 | projectId: bootstrapProject.id, | ||
| 357 | environmentId: primaryEnvironmentId, | ||
| 358 | latestTurn: null, | ||
| 359 | latestUserMessageAt: null, | ||
| 360 | session: null, | ||
| 361 | }; | ||
| 362 | |||
| 363 | describe("isFreshFirstRunWorkspace", () => { | ||
| 364 | it("accepts an empty workspace", () => { | ||
| 365 | expect( | ||
| 366 | isFreshFirstRunWorkspace({ | ||
| 367 | primaryEnvironmentId, | ||
| 368 | serverCwd: "/projects/current", | ||
| 369 | projects: [], | ||
| 370 | threads: [], | ||
| 371 | }), | ||
| 372 | ).toBe(true); | ||
| 373 | }); | ||
| 374 | |||
| 375 | it("accepts only the unused project and thread created from the server cwd", () => { | ||
| 376 | expect( | ||
| 377 | isFreshFirstRunWorkspace({ | ||
| 378 | primaryEnvironmentId, | ||
| 379 | serverCwd: "/projects/current/", | ||
| 380 | bootstrapProjectId: bootstrapProject.id, | ||
| 381 | bootstrapThreadId: bootstrapThread.id, | ||
| 382 | bootstrapProjectCreated: true, | ||
| 383 | bootstrapThreadCreated: true, | ||
| 384 | projects: [bootstrapProject], | ||
| 385 | threads: [bootstrapThread], | ||
| 386 | }), | ||
| 387 | ).toBe(true); | ||
| 388 | }); | ||
| 389 | |||
| 390 | it("rejects an existing unused cwd project and thread reused by startup", () => { | ||
| 391 | expect( | ||
| 392 | isFreshFirstRunWorkspace({ | ||
| 393 | primaryEnvironmentId, | ||
| 394 | serverCwd: "/projects/current", | ||
| 395 | bootstrapProjectId: bootstrapProject.id, | ||
| 396 | bootstrapThreadId: bootstrapThread.id, | ||
| 397 | bootstrapProjectCreated: false, | ||
| 398 | bootstrapThreadCreated: false, | ||
| 399 | projects: [bootstrapProject], | ||
| 400 | threads: [bootstrapThread], | ||
| 401 | }), | ||
| 402 | ).toBe(false); | ||
| 403 | }); | ||
| 404 | |||
| 405 | it("rejects a nonempty workspace when an older server omits creation provenance", () => { | ||
| 406 | expect( | ||
| 407 | isFreshFirstRunWorkspace({ | ||
| 408 | primaryEnvironmentId, | ||
| 409 | serverCwd: "/projects/current", | ||
| 410 | bootstrapProjectId: bootstrapProject.id, | ||
| 411 | bootstrapThreadId: bootstrapThread.id, | ||
| 412 | projects: [bootstrapProject], | ||
| 413 | threads: [bootstrapThread], | ||
| 414 | }), | ||
| 415 | ).toBe(false); | ||
| 416 | }); | ||
| 417 | |||
| 418 | it("normalizes Windows project paths before checking the bootstrap workspace", () => { | ||
| 419 | expect( | ||
| 420 | isFreshFirstRunWorkspace({ | ||
| 421 | primaryEnvironmentId, | ||
| 422 | serverCwd: "C:\\Projects\\Current\\", | ||
| 423 | bootstrapProjectId: bootstrapProject.id, | ||
| 424 | bootstrapThreadId: bootstrapThread.id, | ||
| 425 | bootstrapProjectCreated: true, | ||
| 426 | bootstrapThreadCreated: true, | ||
| 427 | projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }], | ||
| 428 | threads: [bootstrapThread], | ||
| 429 | }), | ||
| 430 | ).toBe(true); | ||
| 431 | }); | ||
| 432 | |||
| 433 | it("rejects projects from another environment even when their paths match", () => { | ||
| 434 | expect( | ||
| 435 | isFreshFirstRunWorkspace({ | ||
| 436 | primaryEnvironmentId, | ||
| 437 | serverCwd: "/projects/current", | ||
| 438 | projects: [{ ...bootstrapProject, environmentId: "remote-environment" }], | ||
| 439 | threads: [], | ||
| 440 | }), | ||
| 441 | ).toBe(false); | ||
| 442 | }); | ||
| 443 | |||
| 444 | it("rejects threads from another environment", () => { | ||
| 445 | expect( | ||
| 446 | isFreshFirstRunWorkspace({ | ||
| 447 | primaryEnvironmentId, | ||
| 448 | serverCwd: "/projects/current", | ||
| 449 | projects: [bootstrapProject], | ||
| 450 | threads: [{ ...bootstrapThread, environmentId: "remote-environment" }], | ||
| 451 | }), | ||
| 452 | ).toBe(false); | ||
| 453 | }); | ||
| 454 | |||
| 455 | it("rejects a thread that does not belong to the bootstrap project", () => { | ||
| 456 | expect( | ||
| 457 | isFreshFirstRunWorkspace({ | ||
| 458 | primaryEnvironmentId, | ||
| 459 | serverCwd: "/projects/current", | ||
| 460 | projects: [bootstrapProject], | ||
| 461 | threads: [{ ...bootstrapThread, projectId: "another-project" }], | ||
| 462 | }), | ||
| 463 | ).toBe(false); | ||
| 464 | }); | ||
| 465 | |||
| 466 | it("rejects a thread when there is no bootstrap project", () => { | ||
| 467 | expect( | ||
| 468 | isFreshFirstRunWorkspace({ | ||
| 469 | primaryEnvironmentId, | ||
| 470 | serverCwd: "/projects/current", | ||
| 471 | projects: [], | ||
| 472 | threads: [bootstrapThread], | ||
| 473 | }), | ||
| 474 | ).toBe(false); | ||
| 475 | }); | ||
| 476 | |||
| 477 | it("rejects a bootstrap thread that already has a user message", () => { | ||
| 478 | expect( | ||
| 479 | isFreshFirstRunWorkspace({ | ||
| 480 | primaryEnvironmentId, | ||
| 481 | serverCwd: "/projects/current", | ||
| 482 | projects: [bootstrapProject], | ||
| 483 | threads: [ | ||
| 484 | { | ||
| 485 | ...bootstrapThread, | ||
| 486 | latestUserMessageAt: "2026-08-23T12:00:00.000Z", | ||
| 487 | }, | ||
| 488 | ], | ||
| 489 | }), | ||
| 490 | ).toBe(false); | ||
| 491 | }); | ||
| 492 | |||
| 493 | it("rejects a bootstrap thread that has started a turn", () => { | ||
| 494 | expect( | ||
| 495 | isFreshFirstRunWorkspace({ | ||
| 496 | primaryEnvironmentId, | ||
| 497 | serverCwd: "/projects/current", | ||
| 498 | projects: [bootstrapProject], | ||
| 499 | threads: [{ ...bootstrapThread, latestTurn: { id: "first-turn" } }], | ||
| 500 | }), | ||
| 501 | ).toBe(false); | ||
| 502 | }); | ||
| 503 | |||
| 504 | it("rejects a bootstrap thread that has a provider session", () => { | ||
| 505 | expect( | ||
| 506 | isFreshFirstRunWorkspace({ | ||
| 507 | primaryEnvironmentId, | ||
| 508 | serverCwd: "/projects/current", | ||
| 509 | projects: [bootstrapProject], | ||
| 510 | threads: [{ ...bootstrapThread, session: { status: "ready" } }], | ||
| 511 | }), | ||
| 512 | ).toBe(false); | ||
| 513 | }); | ||
| 514 | }); | ||
apps/web/src/onboarding/firstRun.logic.ts created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; | ||
| 2 | |||
| 3 | export type FirstRunDecision = "pending" | "app" | "wizard"; | ||
| 4 | |||
| 5 | export interface FirstRunGateState { | ||
| 6 | readonly decision: FirstRunDecision; | ||
| 7 | readonly stalled: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | type FirstRunGateEvent = | ||
| 11 | | { readonly type: "evidence"; readonly decision: FirstRunDecision } | ||
| 12 | | { readonly type: "timeout" }; | ||
| 13 | |||
| 14 | interface FirstRunWorkspaceInput { | ||
| 15 | readonly primaryEnvironmentId: string | null; | ||
| 16 | readonly serverCwd: string | null; | ||
| 17 | readonly bootstrapProjectId?: string | undefined; | ||
| 18 | readonly bootstrapThreadId?: string | undefined; | ||
| 19 | readonly bootstrapProjectCreated?: boolean | undefined; | ||
| 20 | readonly bootstrapThreadCreated?: boolean | undefined; | ||
| 21 | readonly projects: ReadonlyArray<{ | ||
| 22 | readonly id: string; | ||
| 23 | readonly environmentId: string; | ||
| 24 | readonly workspaceRoot: string; | ||
| 25 | }>; | ||
| 26 | readonly threads: ReadonlyArray<{ | ||
| 27 | readonly id: string; | ||
| 28 | readonly projectId: string; | ||
| 29 | readonly environmentId: string; | ||
| 30 | readonly latestTurn: unknown; | ||
| 31 | readonly latestUserMessageAt: string | null; | ||
| 32 | readonly session: unknown; | ||
| 33 | }>; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface FirstRunDecisionInput { | ||
| 37 | readonly enabled: boolean; | ||
| 38 | readonly hydrated: boolean; | ||
| 39 | readonly completed: boolean; | ||
| 40 | readonly bootstrapped: boolean; | ||
| 41 | readonly authoritative: boolean; | ||
| 42 | readonly workspaceAuthoritative: boolean; | ||
| 43 | readonly workspaceProvenanceAuthoritative: boolean; | ||
| 44 | readonly catalogReady: boolean; | ||
| 45 | readonly serverConfigAvailable: boolean; | ||
| 46 | readonly workspaceFresh: boolean; | ||
| 47 | readonly projectCount: number; | ||
| 48 | readonly threadCount: number; | ||
| 49 | } | ||
| 50 | |||
| 51 | interface HostedFirstRunDecisionInput { | ||
| 52 | readonly hydrated: boolean; | ||
| 53 | readonly completed: boolean; | ||
| 54 | readonly catalogReady: boolean; | ||
| 55 | readonly environmentCount: number; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function isFirstRunWorkspaceProvenanceAuthoritative(input: { | ||
| 59 | readonly welcomeReceived: boolean; | ||
| 60 | readonly bootstrapStatus: "pending" | "complete" | null; | ||
| 61 | }): boolean { | ||
| 62 | // An empty catalog is not final while cwd auto-bootstrap is pending. Older | ||
| 63 | // servers omit bootstrapStatus, so a received welcome with null stays valid. | ||
| 64 | return input.welcomeReceived && input.bootstrapStatus !== "pending"; | ||
| 65 | } | ||
| 66 | |||
| 67 | /** Keeps the authenticated app unmounted until workspace evidence settles. */ | ||
| 68 | export function transitionFirstRunGateState( | ||
| 69 | state: FirstRunGateState, | ||
| 70 | event: FirstRunGateEvent, | ||
| 71 | ): FirstRunGateState { | ||
| 72 | if (event.type === "timeout") { | ||
| 73 | return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; | ||
| 74 | } | ||
| 75 | |||
| 76 | if ( | ||
| 77 | state.decision === "wizard" || | ||
| 78 | event.decision === "pending" || | ||
| 79 | (state.decision === "app" && event.decision !== "wizard") | ||
| 80 | ) { | ||
| 81 | return state; | ||
| 82 | } | ||
| 83 | |||
| 84 | return { decision: event.decision, stalled: false }; | ||
| 85 | } | ||
| 86 | |||
| 87 | /** Only a project and thread created by this startup count as a fresh nonempty workspace. */ | ||
| 88 | export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean { | ||
| 89 | if (input.projects.length > 1 || input.threads.length > 1) { | ||
| 90 | return false; | ||
| 91 | } | ||
| 92 | |||
| 93 | const bootstrapProject = input.projects[0]; | ||
| 94 | if (bootstrapProject !== undefined) { | ||
| 95 | if ( | ||
| 96 | input.bootstrapProjectCreated !== true || | ||
| 97 | input.bootstrapProjectId !== bootstrapProject.id || | ||
| 98 | input.serverCwd === null || | ||
| 99 | bootstrapProject.environmentId !== input.primaryEnvironmentId || | ||
| 100 | normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !== | ||
| 101 | normalizeProjectPathForComparison(input.serverCwd) | ||
| 102 | ) { | ||
| 103 | return false; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | const bootstrapThread = input.threads[0]; | ||
| 108 | if (bootstrapThread === undefined) { | ||
| 109 | return true; | ||
| 110 | } | ||
| 111 | |||
| 112 | return ( | ||
| 113 | bootstrapProject !== undefined && | ||
| 114 | input.bootstrapThreadCreated === true && | ||
| 115 | input.bootstrapThreadId === bootstrapThread.id && | ||
| 116 | bootstrapThread.environmentId === input.primaryEnvironmentId && | ||
| 117 | bootstrapThread.projectId === bootstrapProject.id && | ||
| 118 | bootstrapThread.latestTurn === null && | ||
| 119 | bootstrapThread.latestUserMessageAt === null && | ||
| 120 | bootstrapThread.session === null | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | |||
| 124 | /** Cached projects may open the app, but only live workspace data may complete onboarding. */ | ||
| 125 | export function resolveFirstRunDecision(input: FirstRunDecisionInput): { | ||
| 126 | readonly decision: FirstRunDecision; | ||
| 127 | readonly persistCompletion: boolean; | ||
| 128 | } { | ||
| 129 | if (!input.enabled || (input.hydrated && input.completed)) { | ||
| 130 | return { decision: "app", persistCompletion: false }; | ||
| 131 | } | ||
| 132 | |||
| 133 | if (!input.hydrated) { | ||
| 134 | return { decision: "pending", persistCompletion: false }; | ||
| 135 | } | ||
| 136 | |||
| 137 | if (input.projectCount > 1 || input.threadCount > 1) { | ||
| 138 | return { | ||
| 139 | decision: "app", | ||
| 140 | persistCompletion: | ||
| 141 | input.bootstrapped && | ||
| 142 | input.authoritative && | ||
| 143 | input.workspaceAuthoritative && | ||
| 144 | input.catalogReady && | ||
| 145 | input.serverConfigAvailable, | ||
| 146 | }; | ||
| 147 | } | ||
| 148 | |||
| 149 | if ( | ||
| 150 | !input.bootstrapped || | ||
| 151 | !input.authoritative || | ||
| 152 | !input.workspaceProvenanceAuthoritative || | ||
| 153 | !input.catalogReady || | ||
| 154 | !input.serverConfigAvailable | ||
| 155 | ) { | ||
| 156 | return { decision: "pending", persistCompletion: false }; | ||
| 157 | } | ||
| 158 | |||
| 159 | return input.workspaceFresh | ||
| 160 | ? { decision: "wizard", persistCompletion: false } | ||
| 161 | : { decision: "app", persistCompletion: input.workspaceAuthoritative }; | ||
| 162 | } | ||
| 163 | |||
| 164 | /** Hosted onboarding depends on saved environments because there is no primary server. */ | ||
| 165 | export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { | ||
| 166 | readonly decision: FirstRunDecision; | ||
| 167 | readonly persistCompletion: boolean; | ||
| 168 | } { | ||
| 169 | if (!input.hydrated) { | ||
| 170 | return { decision: "pending", persistCompletion: false }; | ||
| 171 | } | ||
| 172 | |||
| 173 | if (input.completed) { | ||
| 174 | return { decision: "app", persistCompletion: false }; | ||
| 175 | } | ||
| 176 | |||
| 177 | if (!input.catalogReady) { | ||
| 178 | return { decision: "pending", persistCompletion: false }; | ||
| 179 | } | ||
| 180 | |||
| 181 | return input.environmentCount === 0 | ||
| 182 | ? { decision: "wizard", persistCompletion: false } | ||
| 183 | : { decision: "app", persistCompletion: true }; | ||
| 184 | } | ||
apps/web/src/onboarding/firstRun.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { useCallback } from "react"; | ||
| 2 | |||
| 3 | import { ensureClientSettingsHydrated, persistClientSettingsUpdate } from "../hooks/useSettings"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Marks first-run onboarding finished (or skipped) so FirstRunGate never | ||
| 7 | * routes to the welcome wizard again. The gate itself lives in | ||
| 8 | * `components/onboarding/FirstRunGate.tsx`. | ||
| 9 | */ | ||
| 10 | export function useCompleteOnboarding(): () => Promise<void> { | ||
| 11 | return useCallback(async () => { | ||
| 12 | await ensureClientSettingsHydrated(); | ||
| 13 | const onboardingCompletedAt = new Date().toISOString(); | ||
| 14 | await persistClientSettingsUpdate((current) => ({ ...current, onboardingCompletedAt })); | ||
| 15 | }, []); | ||
| 16 | } | ||
apps/web/src/onboarding/projectImport.logic.test.ts created+245| ... | @@ -0,0 +1,245 @@ | ||
| 1 | import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | partitionOnboardingProjects, | ||
| 6 | resolveOnboardingLandingProject, | ||
| 7 | resolveOnboardingProjectId, | ||
| 8 | } from "./projectImport.logic"; | ||
| 9 | |||
| 10 | const now = Date.parse("2026-08-22T12:00:00.000Z"); | ||
| 11 | |||
| 12 | function candidate( | ||
| 13 | path: string, | ||
| 14 | overrides: Partial<AgentSessionProjectCandidate> = {}, | ||
| 15 | ): AgentSessionProjectCandidate { | ||
| 16 | return { | ||
| 17 | title: path.split("/").at(-1) ?? path, | ||
| 18 | path, | ||
| 19 | sources: ["codex"], | ||
| 20 | threadCount: 1, | ||
| 21 | lastActiveAt: "2026-08-20T12:00:00.000Z", | ||
| 22 | alreadyImported: false, | ||
| 23 | ...overrides, | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | describe("partitionOnboardingProjects", () => { | ||
| 28 | it("keeps existing projects available for thread history import", () => { | ||
| 29 | const imported = candidate("/projects/current", { alreadyImported: true }); | ||
| 30 | const available = candidate("/projects/other"); | ||
| 31 | |||
| 32 | expect(partitionOnboardingProjects([imported, available], now)).toEqual({ | ||
| 33 | available: [imported, available], | ||
| 34 | recent: [imported, available], | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("keeps projects older than 30 days out of the default selection", () => { | ||
| 39 | const recent = candidate("/projects/recent"); | ||
| 40 | const older = candidate("/projects/older", { | ||
| 41 | lastActiveAt: "2026-07-01T12:00:00.000Z", | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(partitionOnboardingProjects([recent, older], now)).toEqual({ | ||
| 45 | available: [recent, older], | ||
| 46 | recent: [recent], | ||
| 47 | }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("keeps future activity out of the default selection", () => { | ||
| 51 | const recent = candidate("/projects/recent"); | ||
| 52 | const future = candidate("/projects/future", { | ||
| 53 | lastActiveAt: "2026-08-23T12:00:00.000Z", | ||
| 54 | }); | ||
| 55 | |||
| 56 | expect(partitionOnboardingProjects([recent, future], now)).toEqual({ | ||
| 57 | available: [recent, future], | ||
| 58 | recent: [recent], | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | describe("resolveOnboardingProjectId", () => { | ||
| 64 | const localEnvironmentId = EnvironmentId.make("local"); | ||
| 65 | const remoteEnvironmentId = EnvironmentId.make("remote"); | ||
| 66 | const localProjectId = ProjectId.make("local-project"); | ||
| 67 | |||
| 68 | it("uses the scanned project ID before the project reaches the client", () => { | ||
| 69 | expect( | ||
| 70 | resolveOnboardingProjectId( | ||
| 71 | [], | ||
| 72 | localEnvironmentId, | ||
| 73 | candidate("/projects/repo", { projectId: localProjectId }), | ||
| 74 | ), | ||
| 75 | ).toBe(localProjectId); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("uses the scanned project ID when the client still has an older project at that root", () => { | ||
| 79 | expect( | ||
| 80 | resolveOnboardingProjectId( | ||
| 81 | [ | ||
| 82 | { | ||
| 83 | id: ProjectId.make("stale-project"), | ||
| 84 | environmentId: localEnvironmentId, | ||
| 85 | workspaceRoot: "/projects/repo", | ||
| 86 | }, | ||
| 87 | ], | ||
| 88 | localEnvironmentId, | ||
| 89 | candidate("/projects/repo", { projectId: localProjectId }), | ||
| 90 | ), | ||
| 91 | ).toBe(localProjectId); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("returns null to create a project when neither the scan nor the client has a project ID", () => { | ||
| 95 | expect( | ||
| 96 | resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")), | ||
| 97 | ).toBeNull(); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("finds an existing project by normalized root in the target environment", () => { | ||
| 101 | expect( | ||
| 102 | resolveOnboardingProjectId( | ||
| 103 | [ | ||
| 104 | { | ||
| 105 | id: ProjectId.make("remote-project"), | ||
| 106 | environmentId: remoteEnvironmentId, | ||
| 107 | workspaceRoot: "C:\\Work\\Repo", | ||
| 108 | }, | ||
| 109 | { | ||
| 110 | id: localProjectId, | ||
| 111 | environmentId: localEnvironmentId, | ||
| 112 | workspaceRoot: "C:\\Work\\Repo\\", | ||
| 113 | }, | ||
| 114 | ], | ||
| 115 | localEnvironmentId, | ||
| 116 | candidate("c:/work/repo"), | ||
| 117 | ), | ||
| 118 | ).toBe(localProjectId); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("does not reuse a project from another environment", () => { | ||
| 122 | expect( | ||
| 123 | resolveOnboardingProjectId( | ||
| 124 | [ | ||
| 125 | { | ||
| 126 | id: ProjectId.make("remote-project"), | ||
| 127 | environmentId: remoteEnvironmentId, | ||
| 128 | workspaceRoot: "/projects/repo", | ||
| 129 | }, | ||
| 130 | ], | ||
| 131 | localEnvironmentId, | ||
| 132 | candidate("/projects/repo"), | ||
| 133 | ), | ||
| 134 | ).toBeNull(); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("finds an alias after the scanner returns its persisted project root", () => { | ||
| 138 | expect( | ||
| 139 | resolveOnboardingProjectId( | ||
| 140 | [ | ||
| 141 | { | ||
| 142 | id: localProjectId, | ||
| 143 | environmentId: localEnvironmentId, | ||
| 144 | workspaceRoot: "/real/projects/repo", | ||
| 145 | }, | ||
| 146 | ], | ||
| 147 | localEnvironmentId, | ||
| 148 | candidate("/real/projects/repo"), | ||
| 149 | ), | ||
| 150 | ).toBe(localProjectId); | ||
| 151 | }); | ||
| 152 | |||
| 153 | it("finds the current root owner when the scan has no project ID", () => { | ||
| 154 | const recreatedProjectId = ProjectId.make("recreated-project"); | ||
| 155 | expect( | ||
| 156 | resolveOnboardingProjectId( | ||
| 157 | [ | ||
| 158 | { | ||
| 159 | id: localProjectId, | ||
| 160 | environmentId: localEnvironmentId, | ||
| 161 | workspaceRoot: "/projects/other", | ||
| 162 | }, | ||
| 163 | { | ||
| 164 | id: recreatedProjectId, | ||
| 165 | environmentId: localEnvironmentId, | ||
| 166 | workspaceRoot: "/projects/repo", | ||
| 167 | }, | ||
| 168 | ], | ||
| 169 | localEnvironmentId, | ||
| 170 | candidate("/projects/repo"), | ||
| 171 | ), | ||
| 172 | ).toBe(recreatedProjectId); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("does not reuse a moved project when the scan has no project ID", () => { | ||
| 176 | expect( | ||
| 177 | resolveOnboardingProjectId( | ||
| 178 | [ | ||
| 179 | { | ||
| 180 | id: localProjectId, | ||
| 181 | environmentId: localEnvironmentId, | ||
| 182 | workspaceRoot: "/projects/moved", | ||
| 183 | }, | ||
| 184 | ], | ||
| 185 | localEnvironmentId, | ||
| 186 | candidate("/projects/repo"), | ||
| 187 | ), | ||
| 188 | ).toBeNull(); | ||
| 189 | }); | ||
| 190 | }); | ||
| 191 | |||
| 192 | describe("resolveOnboardingLandingProject", () => { | ||
| 193 | it("skips a failed first project for a later project with imported history", () => { | ||
| 194 | expect( | ||
| 195 | resolveOnboardingLandingProject( | ||
| 196 | ["/projects/failed", "/projects/imported"], | ||
| 197 | new Map([["/projects/imported", "imported"]]), | ||
| 198 | new Map([["/projects/imported", "imported"]]), | ||
| 199 | ), | ||
| 200 | ).toBe("imported"); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("prefers a partial first import that added history", () => { | ||
| 204 | expect( | ||
| 205 | resolveOnboardingLandingProject( | ||
| 206 | ["/projects/partial", "/projects/complete"], | ||
| 207 | new Map([["/projects/partial", "partial"]]), | ||
| 208 | new Map([["/projects/complete", "complete"]]), | ||
| 209 | ), | ||
| 210 | ).toBe("partial"); | ||
| 211 | }); | ||
| 212 | |||
| 213 | it("uses a completed zero-history project when no import added history", () => { | ||
| 214 | expect( | ||
| 215 | resolveOnboardingLandingProject( | ||
| 216 | ["/projects/empty", "/projects/failed"], | ||
| 217 | new Map(), | ||
| 218 | new Map([["/projects/empty", "empty"]]), | ||
| 219 | ), | ||
| 220 | ).toBe("empty"); | ||
| 221 | }); | ||
| 222 | |||
| 223 | it("keeps an earlier successful import available on retry", () => { | ||
| 224 | expect( | ||
| 225 | resolveOnboardingLandingProject( | ||
| 226 | ["/projects/imported", "/projects/retry"], | ||
| 227 | new Map([["/projects/imported", "imported"]]), | ||
| 228 | new Map([["/projects/imported", "imported"]]), | ||
| 229 | ), | ||
| 230 | ).toBe("imported"); | ||
| 231 | }); | ||
| 232 | |||
| 233 | it("ignores cached successes outside the current retry selection", () => { | ||
| 234 | expect( | ||
| 235 | resolveOnboardingLandingProject( | ||
| 236 | ["/projects/current"], | ||
| 237 | new Map([["/projects/previous", "previous"]]), | ||
| 238 | new Map([ | ||
| 239 | ["/projects/previous", "previous"], | ||
| 240 | ["/projects/current", "current"], | ||
| 241 | ]), | ||
| 242 | ), | ||
| 243 | ).toBe("current"); | ||
| 244 | }); | ||
| 245 | }); | ||
apps/web/src/onboarding/projectImport.logic.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; | ||
| 2 | import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; | ||
| 5 | |||
| 6 | /** Existing projects still need their agent history imported, so every scan candidate is offered. */ | ||
| 7 | export function partitionOnboardingProjects( | ||
| 8 | candidates: ReadonlyArray<AgentSessionProjectCandidate>, | ||
| 9 | now = Date.now(), | ||
| 10 | ) { | ||
| 11 | const cutoff = now - RECENT_PROJECT_WINDOW_MS; | ||
| 12 | |||
| 13 | return { | ||
| 14 | available: candidates, | ||
| 15 | recent: candidates.filter((candidate) => { | ||
| 16 | if (candidate.lastActiveAt === null) return false; | ||
| 17 | const lastActiveAt = Date.parse(candidate.lastActiveAt); | ||
| 18 | return lastActiveAt >= cutoff && lastActiveAt <= now; | ||
| 19 | }), | ||
| 20 | }; | ||
| 21 | } | ||
| 22 | |||
| 23 | /** Use the server's project match before the client snapshot, which can lag behind the scan. */ | ||
| 24 | export function resolveOnboardingProjectId( | ||
| 25 | projects: ReadonlyArray<{ | ||
| 26 | readonly id: ProjectId; | ||
| 27 | readonly environmentId: EnvironmentId; | ||
| 28 | readonly workspaceRoot: string; | ||
| 29 | }>, | ||
| 30 | environmentId: EnvironmentId, | ||
| 31 | candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">, | ||
| 32 | ): ProjectId | null { | ||
| 33 | if (candidate.projectId !== undefined) return candidate.projectId; | ||
| 34 | const environmentProjects = projects.filter((project) => project.environmentId === environmentId); | ||
| 35 | const currentRootMatch = findProjectByPath(environmentProjects, candidate.path); | ||
| 36 | if (currentRootMatch !== undefined) return currentRootMatch.id; | ||
| 37 | return null; | ||
| 38 | } | ||
| 39 | |||
| 40 | /** Prefer a selected project with imported history, then a completed empty import. */ | ||
| 41 | export function resolveOnboardingLandingProject<T>( | ||
| 42 | selection: ReadonlyArray<string>, | ||
| 43 | projectsWithImportedHistory: ReadonlyMap<string, T>, | ||
| 44 | completedProjects: ReadonlyMap<string, T>, | ||
| 45 | ): T | undefined { | ||
| 46 | for (const path of selection) { | ||
| 47 | const project = projectsWithImportedHistory.get(path); | ||
| 48 | if (project !== undefined) return project; | ||
| 49 | } | ||
| 50 | for (const path of selection) { | ||
| 51 | const project = completedProjects.get(path); | ||
| 52 | if (project !== undefined) return project; | ||
| 53 | } | ||
| 54 | return undefined; | ||
| 55 | } | ||
apps/web/src/onboarding/providerReadiness.logic.test.ts created+317| ... | @@ -0,0 +1,317 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | getOnboardingProviderState, | ||
| 11 | resolveOnboardingProviderLoginCommand, | ||
| 12 | selectOnboardingProvidersByDriver, | ||
| 13 | } from "./providerReadiness.logic"; | ||
| 14 | |||
| 15 | const readyCodex: ServerProvider = { | ||
| 16 | instanceId: ProviderInstanceId.make("codex"), | ||
| 17 | driver: ProviderDriverKind.make("codex"), | ||
| 18 | enabled: true, | ||
| 19 | installed: true, | ||
| 20 | version: "1.0.0", | ||
| 21 | status: "ready", | ||
| 22 | auth: { status: "unknown" }, | ||
| 23 | checkedAt: "2026-08-23T00:00:00.000Z", | ||
| 24 | models: [], | ||
| 25 | slashCommands: [], | ||
| 26 | skills: [], | ||
| 27 | }; | ||
| 28 | |||
| 29 | describe("getOnboardingProviderState", () => { | ||
| 30 | it("treats an enabled Codex provider with ready status and unknown authentication as ready", () => { | ||
| 31 | expect(getOnboardingProviderState(readyCodex)).toBe("ready"); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("treats authenticated providers as ready only when their provider status is ready", () => { | ||
| 35 | expect(getOnboardingProviderState({ ...readyCodex, auth: { status: "authenticated" } })).toBe( | ||
| 36 | "ready", | ||
| 37 | ); | ||
| 38 | expect( | ||
| 39 | getOnboardingProviderState({ | ||
| 40 | ...readyCodex, | ||
| 41 | auth: { status: "authenticated" }, | ||
| 42 | status: "error", | ||
| 43 | }), | ||
| 44 | ).toBe("attention"); | ||
| 45 | expect( | ||
| 46 | getOnboardingProviderState({ | ||
| 47 | ...readyCodex, | ||
| 48 | auth: { status: "authenticated" }, | ||
| 49 | status: "warning", | ||
| 50 | }), | ||
| 51 | ).toBe("attention"); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("offers sign-in only when the server reports an authentication failure", () => { | ||
| 55 | expect( | ||
| 56 | getOnboardingProviderState({ | ||
| 57 | ...readyCodex, | ||
| 58 | status: "error", | ||
| 59 | auth: { status: "unauthenticated" }, | ||
| 60 | }), | ||
| 61 | ).toBe("signIn"); | ||
| 62 | expect(getOnboardingProviderState({ ...readyCodex, status: "error" })).toBe("attention"); | ||
| 63 | expect(getOnboardingProviderState({ ...readyCodex, status: "warning" })).toBe("attention"); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("does not offer installation or sign-in for disabled providers", () => { | ||
| 67 | expect(getOnboardingProviderState({ ...readyCodex, enabled: false, installed: false })).toBe( | ||
| 68 | "disabled", | ||
| 69 | ); | ||
| 70 | expect(getOnboardingProviderState({ ...readyCodex, status: "disabled" })).toBe("disabled"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("offers installation only when an enabled provider is missing", () => { | ||
| 74 | expect(getOnboardingProviderState({ ...readyCodex, installed: false, status: "error" })).toBe( | ||
| 75 | "install", | ||
| 76 | ); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("waits for a provider snapshot before offering an action", () => { | ||
| 80 | expect(getOnboardingProviderState(undefined)).toBe("checking"); | ||
| 81 | }); | ||
| 82 | }); | ||
| 83 | |||
| 84 | describe("selectOnboardingProvidersByDriver", () => { | ||
| 85 | it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => { | ||
| 86 | const signedOutCodex: ServerProvider = { | ||
| 87 | ...readyCodex, | ||
| 88 | instanceId: ProviderInstanceId.make("codex_work"), | ||
| 89 | status: "error", | ||
| 90 | auth: { status: "unauthenticated" }, | ||
| 91 | }; | ||
| 92 | |||
| 93 | expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe( | ||
| 94 | readyCodex, | ||
| 95 | ); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("prefers a provider with an actionable sign-in over a failed provider", () => { | ||
| 99 | const failedCodex: ServerProvider = { ...readyCodex, status: "error" }; | ||
| 100 | const signedOutCodex: ServerProvider = { | ||
| 101 | ...readyCodex, | ||
| 102 | instanceId: ProviderInstanceId.make("codex_work"), | ||
| 103 | status: "error", | ||
| 104 | auth: { status: "unauthenticated" }, | ||
| 105 | }; | ||
| 106 | |||
| 107 | expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe( | ||
| 108 | signedOutCodex, | ||
| 109 | ); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("prefers installed providers over missing or disabled instances", () => { | ||
| 113 | const disabledCodex: ServerProvider = { ...readyCodex, enabled: false }; | ||
| 114 | const missingCodex: ServerProvider = { | ||
| 115 | ...readyCodex, | ||
| 116 | instanceId: ProviderInstanceId.make("codex_work"), | ||
| 117 | installed: false, | ||
| 118 | status: "error", | ||
| 119 | }; | ||
| 120 | |||
| 121 | expect( | ||
| 122 | selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"), | ||
| 123 | ).toBe(readyCodex); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("handles provider snapshots that have not arrived", () => { | ||
| 127 | expect(selectOnboardingProvidersByDriver(undefined).size).toBe(0); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("keeps a ready custom account when the default account is signed out", () => { | ||
| 131 | const signedOutDefault: ServerProvider = { | ||
| 132 | ...readyCodex, | ||
| 133 | status: "error", | ||
| 134 | auth: { status: "unauthenticated" }, | ||
| 135 | }; | ||
| 136 | const readyCustom: ServerProvider = { | ||
| 137 | ...readyCodex, | ||
| 138 | instanceId: ProviderInstanceId.make("codex_work"), | ||
| 139 | }; | ||
| 140 | |||
| 141 | expect(selectOnboardingProvidersByDriver([signedOutDefault, readyCustom]).get("codex")).toBe( | ||
| 142 | readyCustom, | ||
| 143 | ); | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | |||
| 147 | describe("resolveOnboardingProviderLoginCommand", () => { | ||
| 148 | it("uses the selected Codex account binary", () => { | ||
| 149 | const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") }; | ||
| 150 | |||
| 151 | expect( | ||
| 152 | resolveOnboardingProviderLoginCommand( | ||
| 153 | provider, | ||
| 154 | { | ||
| 155 | ...DEFAULT_SERVER_SETTINGS, | ||
| 156 | providerInstances: { | ||
| 157 | [provider.instanceId]: { | ||
| 158 | driver: provider.driver, | ||
| 159 | config: { binaryPath: "/opt/codex-work/bin/codex" }, | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | }, | ||
| 163 | "linux", | ||
| 164 | ), | ||
| 165 | ).toBe("/opt/codex-work/bin/codex login"); | ||
| 166 | }); | ||
| 167 | |||
| 168 | it("uses the selected Claude account binary", () => { | ||
| 169 | const provider: ServerProvider = { | ||
| 170 | ...readyCodex, | ||
| 171 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 172 | instanceId: ProviderInstanceId.make("claude_work"), | ||
| 173 | }; | ||
| 174 | |||
| 175 | expect( | ||
| 176 | resolveOnboardingProviderLoginCommand( | ||
| 177 | provider, | ||
| 178 | { | ||
| 179 | ...DEFAULT_SERVER_SETTINGS, | ||
| 180 | providerInstances: { | ||
| 181 | [provider.instanceId]: { | ||
| 182 | driver: provider.driver, | ||
| 183 | config: { binaryPath: "/opt/claude-work/bin/claude" }, | ||
| 184 | }, | ||
| 185 | }, | ||
| 186 | }, | ||
| 187 | "linux", | ||
| 188 | ), | ||
| 189 | ).toBe("/opt/claude-work/bin/claude auth login"); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("quotes a Codex path with spaces for PowerShell", () => { | ||
| 193 | expect( | ||
| 194 | resolveOnboardingProviderLoginCommand( | ||
| 195 | readyCodex, | ||
| 196 | { | ||
| 197 | ...DEFAULT_SERVER_SETTINGS, | ||
| 198 | providers: { | ||
| 199 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 200 | codex: { | ||
| 201 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | ||
| 202 | binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", | ||
| 203 | }, | ||
| 204 | }, | ||
| 205 | }, | ||
| 206 | "windows", | ||
| 207 | ), | ||
| 208 | ).toBe("& 'C:\\Program Files\\Codex & Tools\\codex.exe' login"); | ||
| 209 | }); | ||
| 210 | |||
| 211 | it("quotes a Claude path with shell metacharacters on POSIX", () => { | ||
| 212 | const provider: ServerProvider = { | ||
| 213 | ...readyCodex, | ||
| 214 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 215 | instanceId: ProviderInstanceId.make("claude"), | ||
| 216 | }; | ||
| 217 | |||
| 218 | expect( | ||
| 219 | resolveOnboardingProviderLoginCommand( | ||
| 220 | provider, | ||
| 221 | { | ||
| 222 | ...DEFAULT_SERVER_SETTINGS, | ||
| 223 | providers: { | ||
| 224 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 225 | claudeAgent: { | ||
| 226 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | ||
| 227 | binaryPath: "/opt/Claude Tools/$current/claude", | ||
| 228 | }, | ||
| 229 | }, | ||
| 230 | }, | ||
| 231 | "linux", | ||
| 232 | ), | ||
| 233 | ).toBe("'/opt/Claude Tools/$current/claude' auth login"); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it.each([ | ||
| 237 | ["~/my tools/codex", "~/'my tools/codex' login"], | ||
| 238 | ["~\\my tools/codex", "~/'my tools/codex' login"], | ||
| 239 | ["~/tools/codex's build", `~/'tools/codex'"'"'s build' login`], | ||
| 240 | ["~\\tools\\codex's build", `~/'tools\\codex'"'"'s build' login`], | ||
| 241 | ["~/tools/codex; echo unsafe", "~/'tools/codex; echo unsafe' login"], | ||
| 242 | ])("keeps the home prefix expandable while quoting %s", (binaryPath, expectedCommand) => { | ||
| 243 | expect( | ||
| 244 | resolveOnboardingProviderLoginCommand( | ||
| 245 | readyCodex, | ||
| 246 | { | ||
| 247 | ...DEFAULT_SERVER_SETTINGS, | ||
| 248 | providers: { | ||
| 249 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 250 | codex: { | ||
| 251 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | ||
| 252 | binaryPath, | ||
| 253 | }, | ||
| 254 | }, | ||
| 255 | }, | ||
| 256 | "linux", | ||
| 257 | ), | ||
| 258 | ).toBe(expectedCommand); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it.each(["darwin", "linux"] as const)("quotes backslashes in a Codex path on %s", (platform) => { | ||
| 262 | expect( | ||
| 263 | resolveOnboardingProviderLoginCommand( | ||
| 264 | readyCodex, | ||
| 265 | { | ||
| 266 | ...DEFAULT_SERVER_SETTINGS, | ||
| 267 | providers: { | ||
| 268 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 269 | codex: { | ||
| 270 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | ||
| 271 | binaryPath: "/opt/codex\\work/codex", | ||
| 272 | }, | ||
| 273 | }, | ||
| 274 | }, | ||
| 275 | platform, | ||
| 276 | ), | ||
| 277 | ).toBe("'/opt/codex\\work/codex' login"); | ||
| 278 | }); | ||
| 279 | |||
| 280 | it("keeps a plain Windows path unquoted", () => { | ||
| 281 | expect( | ||
| 282 | resolveOnboardingProviderLoginCommand( | ||
| 283 | readyCodex, | ||
| 284 | { | ||
| 285 | ...DEFAULT_SERVER_SETTINGS, | ||
| 286 | providers: { | ||
| 287 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 288 | codex: { | ||
| 289 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | ||
| 290 | binaryPath: "C:\\Tools\\codex.exe", | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | }, | ||
| 294 | "windows", | ||
| 295 | ), | ||
| 296 | ).toBe("C:\\Tools\\codex.exe login"); | ||
| 297 | }); | ||
| 298 | |||
| 299 | it("uses the default command when an old server reports an unknown shell", () => { | ||
| 300 | expect( | ||
| 301 | resolveOnboardingProviderLoginCommand( | ||
| 302 | readyCodex, | ||
| 303 | { | ||
| 304 | ...DEFAULT_SERVER_SETTINGS, | ||
| 305 | providers: { | ||
| 306 | ...DEFAULT_SERVER_SETTINGS.providers, | ||
| 307 | codex: { | ||
| 308 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | ||
| 309 | binaryPath: "/opt/Codex Tools/codex", | ||
| 310 | }, | ||
| 311 | }, | ||
| 312 | }, | ||
| 313 | "unknown", | ||
| 314 | ), | ||
| 315 | ).toBe("codex login"); | ||
| 316 | }); | ||
| 317 | }); | ||
apps/web/src/onboarding/providerReadiness.logic.ts created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | import { | ||
| 2 | ClaudeSettings, | ||
| 3 | CodexSettings, | ||
| 4 | type ExecutionEnvironmentPlatformOs, | ||
| 5 | type ServerProvider, | ||
| 6 | type ServerSettings, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); | ||
| 12 | const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); | ||
| 13 | const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; | ||
| 14 | |||
| 15 | function quoteProviderBinary( | ||
| 16 | binaryPath: string, | ||
| 17 | fallback: string, | ||
| 18 | platform: ExecutionEnvironmentPlatformOs, | ||
| 19 | ): string { | ||
| 20 | if ( | ||
| 21 | SAFE_SHELL_BINARY_PATTERN.test(binaryPath) && | ||
| 22 | (platform === "windows" || !binaryPath.includes("\\")) | ||
| 23 | ) { | ||
| 24 | return binaryPath; | ||
| 25 | } | ||
| 26 | if (platform === "windows") return `& '${binaryPath.replaceAll("'", "''")}'`; | ||
| 27 | if (platform === "darwin" || platform === "linux") { | ||
| 28 | if (binaryPath.startsWith("~/") || binaryPath.startsWith("~\\")) { | ||
| 29 | return `~/'${binaryPath.slice(2).replaceAll("'", `'"'"'`)}'`; | ||
| 30 | } | ||
| 31 | return `'${binaryPath.replaceAll("'", `'"'"'`)}'`; | ||
| 32 | } | ||
| 33 | return fallback; | ||
| 34 | } | ||
| 35 | |||
| 36 | export function getOnboardingProviderState(provider: ServerProvider | undefined) { | ||
| 37 | if (provider === undefined) return "checking"; | ||
| 38 | if (!provider.enabled || provider.status === "disabled") return "disabled"; | ||
| 39 | if (!provider.installed) return "install"; | ||
| 40 | if (provider.auth.status === "unauthenticated") return "signIn"; | ||
| 41 | if (provider.status === "ready") return "ready"; | ||
| 42 | return "attention"; | ||
| 43 | } | ||
| 44 | |||
| 45 | const PROVIDER_STATE_PRIORITY = { | ||
| 46 | checking: 0, | ||
| 47 | disabled: 1, | ||
| 48 | install: 2, | ||
| 49 | attention: 3, | ||
| 50 | signIn: 4, | ||
| 51 | ready: 5, | ||
| 52 | } as const; | ||
| 53 | |||
| 54 | /** Select the most usable configured instance for each provider driver. */ | ||
| 55 | export function selectOnboardingProvidersByDriver( | ||
| 56 | providers: ReadonlyArray<ServerProvider> | null | undefined, | ||
| 57 | ) { | ||
| 58 | const providersByDriver = new Map<string, ServerProvider>(); | ||
| 59 | |||
| 60 | for (const provider of providers ?? []) { | ||
| 61 | const existing = providersByDriver.get(provider.driver); | ||
| 62 | if ( | ||
| 63 | existing === undefined || | ||
| 64 | PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] > | ||
| 65 | PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)] | ||
| 66 | ) { | ||
| 67 | providersByDriver.set(provider.driver, provider); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | return providersByDriver; | ||
| 72 | } | ||
| 73 | |||
| 74 | /** Use the selected provider instance's binary when the setup terminal opens its login flow. */ | ||
| 75 | export function resolveOnboardingProviderLoginCommand( | ||
| 76 | provider: ServerProvider, | ||
| 77 | settings: ServerSettings, | ||
| 78 | platform: ExecutionEnvironmentPlatformOs, | ||
| 79 | ): string { | ||
| 80 | const instance = settings.providerInstances[provider.instanceId]; | ||
| 81 | |||
| 82 | if (provider.driver === "claudeAgent") { | ||
| 83 | const config = decodeClaudeSettings( | ||
| 84 | instance ? (instance.config ?? {}) : settings.providers.claudeAgent, | ||
| 85 | ); | ||
| 86 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; | ||
| 87 | return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; | ||
| 88 | } | ||
| 89 | |||
| 90 | if (provider.driver === "codex") { | ||
| 91 | const config = decodeCodexSettings( | ||
| 92 | instance ? (instance.config ?? {}) : settings.providers.codex, | ||
| 93 | ); | ||
| 94 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; | ||
| 95 | return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; | ||
| 96 | } | ||
| 97 | |||
| 98 | return provider.driver; | ||
| 99 | } | ||
apps/web/src/onboarding/targetEnvironment.logic.test.ts created+211| ... | @@ -0,0 +1,211 @@ | ||
| 1 | import { | ||
| 2 | BearerConnectionTarget, | ||
| 3 | PrimaryConnectionTarget, | ||
| 4 | RelayConnectionTarget, | ||
| 5 | SshConnectionTarget, | ||
| 6 | } from "@t3tools/client-runtime/connection"; | ||
| 7 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | isOnboardingRelayEnvironment, | ||
| 12 | resolveOnboardingTargetEnvironment, | ||
| 13 | } from "./targetEnvironment.logic"; | ||
| 14 | |||
| 15 | const primaryEnvironment = { | ||
| 16 | environmentId: EnvironmentId.make("primary"), | ||
| 17 | connection: { phase: "connected" }, | ||
| 18 | entry: { | ||
| 19 | target: new PrimaryConnectionTarget({ | ||
| 20 | environmentId: EnvironmentId.make("primary"), | ||
| 21 | label: "This computer", | ||
| 22 | httpBaseUrl: "http://127.0.0.1:3773", | ||
| 23 | wsBaseUrl: "ws://127.0.0.1:3773", | ||
| 24 | }), | ||
| 25 | }, | ||
| 26 | label: "This computer", | ||
| 27 | } as const; | ||
| 28 | |||
| 29 | const olderRelay = { | ||
| 30 | environmentId: EnvironmentId.make("older-remote"), | ||
| 31 | connection: { phase: "connected" }, | ||
| 32 | entry: { | ||
| 33 | target: new RelayConnectionTarget({ | ||
| 34 | environmentId: EnvironmentId.make("older-remote"), | ||
| 35 | label: "Older computer", | ||
| 36 | }), | ||
| 37 | }, | ||
| 38 | label: "Older computer", | ||
| 39 | } as const; | ||
| 40 | |||
| 41 | const newerRelay = { | ||
| 42 | environmentId: EnvironmentId.make("newer-relay"), | ||
| 43 | connection: { phase: "connected" }, | ||
| 44 | entry: { | ||
| 45 | target: new RelayConnectionTarget({ | ||
| 46 | environmentId: EnvironmentId.make("newer-relay"), | ||
| 47 | label: "New computer", | ||
| 48 | }), | ||
| 49 | }, | ||
| 50 | label: "New computer", | ||
| 51 | } as const; | ||
| 52 | |||
| 53 | const pairedRemote = { | ||
| 54 | environmentId: EnvironmentId.make("paired-remote"), | ||
| 55 | connection: { phase: "connected" }, | ||
| 56 | entry: { | ||
| 57 | target: new BearerConnectionTarget({ | ||
| 58 | environmentId: EnvironmentId.make("paired-remote"), | ||
| 59 | label: "Direct computer", | ||
| 60 | connectionId: "paired-remote", | ||
| 61 | }), | ||
| 62 | }, | ||
| 63 | label: "Direct computer", | ||
| 64 | } as const; | ||
| 65 | |||
| 66 | const sshEnvironment = { | ||
| 67 | environmentId: EnvironmentId.make("ssh-remote"), | ||
| 68 | connection: { phase: "connected" }, | ||
| 69 | entry: { | ||
| 70 | target: new SshConnectionTarget({ | ||
| 71 | environmentId: EnvironmentId.make("ssh-remote"), | ||
| 72 | label: "SSH computer", | ||
| 73 | connectionId: "ssh-remote", | ||
| 74 | }), | ||
| 75 | }, | ||
| 76 | label: "SSH computer", | ||
| 77 | } as const; | ||
| 78 | |||
| 79 | const desktopLocalEnvironment = { | ||
| 80 | environmentId: EnvironmentId.make("desktop-local-wsl"), | ||
| 81 | connection: { phase: "connected" }, | ||
| 82 | entry: { | ||
| 83 | target: new BearerConnectionTarget({ | ||
| 84 | environmentId: EnvironmentId.make("desktop-local-wsl"), | ||
| 85 | label: "WSL", | ||
| 86 | connectionId: "local:wsl:Ubuntu", | ||
| 87 | }), | ||
| 88 | }, | ||
| 89 | label: "WSL", | ||
| 90 | } as const; | ||
| 91 | |||
| 92 | describe("resolveOnboardingTargetEnvironment", () => { | ||
| 93 | it("waits for the exact paired machine instead of using an older connected machine", () => { | ||
| 94 | const pendingPairedRemote = { ...pairedRemote, connection: { phase: "connecting" } }; | ||
| 95 | |||
| 96 | expect( | ||
| 97 | resolveOnboardingTargetEnvironment({ | ||
| 98 | mode: "direct", | ||
| 99 | environments: [primaryEnvironment, olderRelay, pendingPairedRemote], | ||
| 100 | primaryEnvironment, | ||
| 101 | pairedEnvironmentId: pairedRemote.environmentId, | ||
| 102 | }), | ||
| 103 | ).toBeNull(); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it("uses the exact paired machine once it connects", () => { | ||
| 107 | expect( | ||
| 108 | resolveOnboardingTargetEnvironment({ | ||
| 109 | mode: "direct", | ||
| 110 | environments: [primaryEnvironment, olderRelay, pairedRemote], | ||
| 111 | primaryEnvironment, | ||
| 112 | pairedEnvironmentId: pairedRemote.environmentId, | ||
| 113 | }), | ||
| 114 | ).toBe(pairedRemote); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("waits for a newly paired machine that has not appeared in the catalog", () => { | ||
| 118 | expect( | ||
| 119 | resolveOnboardingTargetEnvironment({ | ||
| 120 | mode: "direct", | ||
| 121 | environments: [primaryEnvironment, olderRelay], | ||
| 122 | primaryEnvironment, | ||
| 123 | pairedEnvironmentId: pairedRemote.environmentId, | ||
| 124 | }), | ||
| 125 | ).toBeNull(); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("uses the primary machine for local onboarding", () => { | ||
| 129 | expect( | ||
| 130 | resolveOnboardingTargetEnvironment({ | ||
| 131 | mode: "local", | ||
| 132 | environments: [primaryEnvironment, olderRelay], | ||
| 133 | primaryEnvironment, | ||
| 134 | pairedEnvironmentId: null, | ||
| 135 | }), | ||
| 136 | ).toBe(primaryEnvironment); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("does not substitute a remote machine when the local primary is offline", () => { | ||
| 140 | const offlinePrimary = { ...primaryEnvironment, connection: { phase: "disconnected" } }; | ||
| 141 | |||
| 142 | expect( | ||
| 143 | resolveOnboardingTargetEnvironment({ | ||
| 144 | mode: "local", | ||
| 145 | environments: [offlinePrimary, olderRelay], | ||
| 146 | primaryEnvironment: offlinePrimary, | ||
| 147 | pairedEnvironmentId: null, | ||
| 148 | }), | ||
| 149 | ).toBeNull(); | ||
| 150 | }); | ||
| 151 | |||
| 152 | it("uses the newest connected remote when no exact machine was selected", () => { | ||
| 153 | expect( | ||
| 154 | resolveOnboardingTargetEnvironment({ | ||
| 155 | mode: "connect", | ||
| 156 | environments: [primaryEnvironment, olderRelay, newerRelay], | ||
| 157 | primaryEnvironment, | ||
| 158 | pairedEnvironmentId: null, | ||
| 159 | }), | ||
| 160 | ).toBe(newerRelay); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it("ignores direct, SSH, and desktop-managed connections in Connect mode", () => { | ||
| 164 | expect( | ||
| 165 | resolveOnboardingTargetEnvironment({ | ||
| 166 | mode: "connect", | ||
| 167 | environments: [ | ||
| 168 | primaryEnvironment, | ||
| 169 | olderRelay, | ||
| 170 | pairedRemote, | ||
| 171 | sshEnvironment, | ||
| 172 | desktopLocalEnvironment, | ||
| 173 | ], | ||
| 174 | primaryEnvironment, | ||
| 175 | pairedEnvironmentId: null, | ||
| 176 | }), | ||
| 177 | ).toBe(olderRelay); | ||
| 178 | }); | ||
| 179 | |||
| 180 | it("uses the primary computer when no relay connection exists", () => { | ||
| 181 | expect( | ||
| 182 | resolveOnboardingTargetEnvironment({ | ||
| 183 | mode: "connect", | ||
| 184 | environments: [primaryEnvironment, pairedRemote, sshEnvironment, desktopLocalEnvironment], | ||
| 185 | primaryEnvironment, | ||
| 186 | pairedEnvironmentId: null, | ||
| 187 | }), | ||
| 188 | ).toBe(primaryEnvironment); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("falls back to the connected primary when no remote is available", () => { | ||
| 192 | expect( | ||
| 193 | resolveOnboardingTargetEnvironment({ | ||
| 194 | mode: "connect", | ||
| 195 | environments: [primaryEnvironment], | ||
| 196 | primaryEnvironment, | ||
| 197 | pairedEnvironmentId: null, | ||
| 198 | }), | ||
| 199 | ).toBe(primaryEnvironment); | ||
| 200 | }); | ||
| 201 | }); | ||
| 202 | |||
| 203 | describe("isOnboardingRelayEnvironment", () => { | ||
| 204 | it("includes only T3 Connect relay targets", () => { | ||
| 205 | expect( | ||
| 206 | [olderRelay, pairedRemote, sshEnvironment, desktopLocalEnvironment].filter( | ||
| 207 | isOnboardingRelayEnvironment, | ||
| 208 | ), | ||
| 209 | ).toEqual([olderRelay]); | ||
| 210 | }); | ||
| 211 | }); | ||
apps/web/src/onboarding/targetEnvironment.logic.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | interface OnboardingEnvironment { | ||
| 5 | readonly environmentId: EnvironmentId; | ||
| 6 | readonly connection: { readonly phase: string }; | ||
| 7 | readonly entry: { readonly target: ConnectionTarget }; | ||
| 8 | } | ||
| 9 | |||
| 10 | export function isOnboardingRelayEnvironment( | ||
| 11 | environment: Pick<OnboardingEnvironment, "entry">, | ||
| 12 | ): boolean { | ||
| 13 | return environment.entry.target._tag === "RelayConnectionTarget"; | ||
| 14 | } | ||
| 15 | |||
| 16 | /** Keep a directly paired machine pinned while its initial connection completes. */ | ||
| 17 | export function resolveOnboardingTargetEnvironment<TEnvironment extends OnboardingEnvironment>({ | ||
| 18 | mode, | ||
| 19 | environments, | ||
| 20 | primaryEnvironment, | ||
| 21 | pairedEnvironmentId, | ||
| 22 | }: { | ||
| 23 | readonly mode: "local" | "connect" | "direct"; | ||
| 24 | readonly environments: ReadonlyArray<TEnvironment>; | ||
| 25 | readonly primaryEnvironment: TEnvironment | null; | ||
| 26 | readonly pairedEnvironmentId: EnvironmentId | null; | ||
| 27 | }): TEnvironment | null { | ||
| 28 | if (mode === "direct" && pairedEnvironmentId !== null) { | ||
| 29 | const pairedEnvironment = environments.find( | ||
| 30 | (environment) => environment.environmentId === pairedEnvironmentId, | ||
| 31 | ); | ||
| 32 | return pairedEnvironment?.connection.phase === "connected" ? pairedEnvironment : null; | ||
| 33 | } | ||
| 34 | |||
| 35 | const connectedRelayEnvironments = environments.filter( | ||
| 36 | (environment) => | ||
| 37 | environment.connection.phase === "connected" && isOnboardingRelayEnvironment(environment), | ||
| 38 | ); | ||
| 39 | |||
| 40 | if (mode === "connect" && connectedRelayEnvironments.length > 0) { | ||
| 41 | return connectedRelayEnvironments[connectedRelayEnvironments.length - 1] ?? null; | ||
| 42 | } | ||
| 43 | |||
| 44 | if (primaryEnvironment?.connection.phase === "connected") { | ||
| 45 | return primaryEnvironment; | ||
| 46 | } | ||
| 47 | |||
| 48 | return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null); | ||
| 49 | } | ||
apps/web/src/routeTree.gen.ts+21| ... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
| 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. | 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. |
| 10 | 10 | ||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as WelcomeRouteImport } from './routes/welcome' | ||
| 12 | import { Route as UsageRouteImport } from './routes/usage' | 13 | import { Route as UsageRouteImport } from './routes/usage' |
| 13 | import { Route as SettingsRouteImport } from './routes/settings' | 14 | import { Route as SettingsRouteImport } from './routes/settings' |
| 14 | import { Route as PairRouteImport } from './routes/pair' | 15 | import { Route as PairRouteImport } from './routes/pair' |
| ... | @@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques | ... | @@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques |
| 30 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | 31 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 31 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | 32 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| 32 | 33 | ||
| 34 | const WelcomeRoute = WelcomeRouteImport.update({ | ||
| 35 | id: '/welcome', | ||
| 36 | path: '/welcome', | ||
| 37 | getParentRoute: () => rootRouteImport, | ||
| 38 | } as any) | ||
| 33 | const UsageRoute = UsageRouteImport.update({ | 39 | const UsageRoute = UsageRouteImport.update({ |
| 34 | id: '/usage', | 40 | id: '/usage', |
| 35 | path: '/usage', | 41 | path: '/usage', |
| ... | @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { | ... | @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { |
| 137 | '/pair': typeof PairRoute | 143 | '/pair': typeof PairRoute |
| 138 | '/settings': typeof SettingsRouteWithChildren | 144 | '/settings': typeof SettingsRouteWithChildren |
| 139 | '/usage': typeof UsageRoute | 145 | '/usage': typeof UsageRoute |
| 146 | '/welcome': typeof WelcomeRoute | ||
| 140 | '/pull-requests': typeof ChatPullRequestsRoute | 147 | '/pull-requests': typeof ChatPullRequestsRoute |
| 141 | '/connect/callback': typeof ConnectCallbackRoute | 148 | '/connect/callback': typeof ConnectCallbackRoute |
| 142 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute | 149 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| ... | @@ -157,6 +164,7 @@ export interface FileRoutesByTo { | ... | @@ -157,6 +164,7 @@ export interface FileRoutesByTo { |
| 157 | '/pair': typeof PairRoute | 164 | '/pair': typeof PairRoute |
| 158 | '/settings': typeof SettingsRouteWithChildren | 165 | '/settings': typeof SettingsRouteWithChildren |
| 159 | '/usage': typeof UsageRoute | 166 | '/usage': typeof UsageRoute |
| 167 | '/welcome': typeof WelcomeRoute | ||
| 160 | '/pull-requests': typeof ChatPullRequestsRoute | 168 | '/pull-requests': typeof ChatPullRequestsRoute |
| 161 | '/connect/callback': typeof ConnectCallbackRoute | 169 | '/connect/callback': typeof ConnectCallbackRoute |
| 162 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute | 170 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| ... | @@ -180,6 +188,7 @@ export interface FileRoutesById { | ... | @@ -180,6 +188,7 @@ export interface FileRoutesById { |
| 180 | '/pair': typeof PairRoute | 188 | '/pair': typeof PairRoute |
| 181 | '/settings': typeof SettingsRouteWithChildren | 189 | '/settings': typeof SettingsRouteWithChildren |
| 182 | '/usage': typeof UsageRoute | 190 | '/usage': typeof UsageRoute |
| 191 | '/welcome': typeof WelcomeRoute | ||
| 183 | '/_chat/pull-requests': typeof ChatPullRequestsRoute | 192 | '/_chat/pull-requests': typeof ChatPullRequestsRoute |
| 184 | '/connect_/callback': typeof ConnectCallbackRoute | 193 | '/connect_/callback': typeof ConnectCallbackRoute |
| 185 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute | 194 | '/projects/$projectKey': typeof ProjectsProjectKeyRoute |
| ... | @@ -204,6 +213,7 @@ export interface FileRouteTypes { | ... | @@ -204,6 +213,7 @@ export interface FileRouteTypes { |
| 204 | | '/pair' | 213 | | '/pair' |
| 205 | | '/settings' | 214 | | '/settings' |
| 206 | | '/usage' | 215 | | '/usage' |
| 216 | | '/welcome' | ||
| 207 | | '/pull-requests' | 217 | | '/pull-requests' |
| 208 | | '/connect/callback' | 218 | | '/connect/callback' |
| 209 | | '/projects/$projectKey' | 219 | | '/projects/$projectKey' |
| ... | @@ -224,6 +234,7 @@ export interface FileRouteTypes { | ... | @@ -224,6 +234,7 @@ export interface FileRouteTypes { |
| 224 | | '/pair' | 234 | | '/pair' |
| 225 | | '/settings' | 235 | | '/settings' |
| 226 | | '/usage' | 236 | | '/usage' |
| 237 | | '/welcome' | ||
| 227 | | '/pull-requests' | 238 | | '/pull-requests' |
| 228 | | '/connect/callback' | 239 | | '/connect/callback' |
| 229 | | '/projects/$projectKey' | 240 | | '/projects/$projectKey' |
| ... | @@ -246,6 +257,7 @@ export interface FileRouteTypes { | ... | @@ -246,6 +257,7 @@ export interface FileRouteTypes { |
| 246 | | '/pair' | 257 | | '/pair' |
| 247 | | '/settings' | 258 | | '/settings' |
| 248 | | '/usage' | 259 | | '/usage' |
| 260 | | '/welcome' | ||
| 249 | | '/_chat/pull-requests' | 261 | | '/_chat/pull-requests' |
| 250 | | '/connect_/callback' | 262 | | '/connect_/callback' |
| 251 | | '/projects/$projectKey' | 263 | | '/projects/$projectKey' |
| ... | @@ -269,12 +281,20 @@ export interface RootRouteChildren { | ... | @@ -269,12 +281,20 @@ export interface RootRouteChildren { |
| 269 | PairRoute: typeof PairRoute | 281 | PairRoute: typeof PairRoute |
| 270 | SettingsRoute: typeof SettingsRouteWithChildren | 282 | SettingsRoute: typeof SettingsRouteWithChildren |
| 271 | UsageRoute: typeof UsageRoute | 283 | UsageRoute: typeof UsageRoute |
| 284 | WelcomeRoute: typeof WelcomeRoute | ||
| 272 | ConnectCallbackRoute: typeof ConnectCallbackRoute | 285 | ConnectCallbackRoute: typeof ConnectCallbackRoute |
| 273 | ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute | 286 | ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute |
| 274 | } | 287 | } |
| 275 | 288 | ||
| 276 | declare module '@tanstack/react-router' { | 289 | declare module '@tanstack/react-router' { |
| 277 | interface FileRoutesByPath { | 290 | interface FileRoutesByPath { |
| 291 | '/welcome': { | ||
| 292 | id: '/welcome' | ||
| 293 | path: '/welcome' | ||
| 294 | fullPath: '/welcome' | ||
| 295 | preLoaderRoute: typeof WelcomeRouteImport | ||
| 296 | parentRoute: typeof rootRouteImport | ||
| 297 | } | ||
| 278 | '/usage': { | 298 | '/usage': { |
| 279 | id: '/usage' | 299 | id: '/usage' |
| 280 | path: '/usage' | 300 | path: '/usage' |
| ... | @@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = { | ... | @@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = { |
| 468 | PairRoute: PairRoute, | 488 | PairRoute: PairRoute, |
| 469 | SettingsRoute: SettingsRouteWithChildren, | 489 | SettingsRoute: SettingsRouteWithChildren, |
| 470 | UsageRoute: UsageRoute, | 490 | UsageRoute: UsageRoute, |
| 491 | WelcomeRoute: WelcomeRoute, | ||
| 471 | ConnectCallbackRoute: ConnectCallbackRoute, | 492 | ConnectCallbackRoute: ConnectCallbackRoute, |
| 472 | ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, | 493 | ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, |
| 473 | } | 494 | } |
apps/web/src/routes/__root.tsx+58-16| ... | @@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; | ... | @@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 16 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 16 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 17 | import { CommandPalette } from "../components/CommandPalette"; | 17 | import { CommandPalette } from "../components/CommandPalette"; |
| 18 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; | 18 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; |
| 19 | import { FirstRunGate } from "../components/onboarding/FirstRunGate"; | ||
| 19 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; | 20 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; |
| 20 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; | 21 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; |
| 21 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 22 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| ... | @@ -97,6 +98,13 @@ function RootRouteView() { | ... | @@ -97,6 +98,13 @@ function RootRouteView() { |
| 97 | const pathname = useLocation({ select: (location) => location.pathname }); | 98 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 98 | const { authGateState } = Route.useRouteContext(); | 99 | const { authGateState } = Route.useRouteContext(); |
| 99 | const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; | 100 | const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; |
| 101 | const returningFromWelcomeRef = useRef(pathname === "/welcome"); | ||
| 102 | |||
| 103 | useEffect(() => { | ||
| 104 | if (pathname === "/welcome") { | ||
| 105 | returningFromWelcomeRef.current = true; | ||
| 106 | } | ||
| 107 | }, [pathname]); | ||
| 100 | 108 | ||
| 101 | useEffect(() => { | 109 | useEffect(() => { |
| 102 | const frame = window.requestAnimationFrame(() => { | 110 | const frame = window.requestAnimationFrame(() => { |
| ... | @@ -116,6 +124,19 @@ function RootRouteView() { | ... | @@ -116,6 +124,19 @@ function RootRouteView() { |
| 116 | ); | 124 | ); |
| 117 | } | 125 | } |
| 118 | 126 | ||
| 127 | // The welcome wizard is full-screen like /pair, but keeps toasts so its | ||
| 128 | // connect/import actions can report failures. | ||
| 129 | if (pathname === "/welcome") { | ||
| 130 | return ( | ||
| 131 | <ToastProvider> | ||
| 132 | <DocumentTitleSync /> | ||
| 133 | <ContrastAppearanceSync /> | ||
| 134 | <FontAppearanceSync /> | ||
| 135 | <Outlet /> | ||
| 136 | </ToastProvider> | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 119 | if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { | 140 | if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { |
| 120 | return ( | 141 | return ( |
| 121 | <> | 142 | <> |
| ... | @@ -133,6 +154,10 @@ function RootRouteView() { | ... | @@ -133,6 +154,10 @@ function RootRouteView() { |
| 133 | </CommandPalette> | 154 | </CommandPalette> |
| 134 | ); | 155 | ); |
| 135 | 156 | ||
| 157 | // FirstRunGate holds back everything below it — including EventRouter, | ||
| 158 | // whose welcome payload navigates into a thread — until the first-run | ||
| 159 | // decision is known, so a fresh install renders nothing (not the shell, | ||
| 160 | // not a flash of threads) before landing on the welcome wizard. | ||
| 136 | return ( | 161 | return ( |
| 137 | <ToastProvider> | 162 | <ToastProvider> |
| 138 | <AnchoredToastProvider> | 163 | <AnchoredToastProvider> |
| ... | @@ -141,21 +166,28 @@ function RootRouteView() { | ... | @@ -141,21 +166,28 @@ function RootRouteView() { |
| 141 | <EnvironmentThemeSync /> | 166 | <EnvironmentThemeSync /> |
| 142 | <GlassAppearanceSync /> | 167 | <GlassAppearanceSync /> |
| 143 | <FontAppearanceSync /> | 168 | <FontAppearanceSync /> |
| 144 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} | 169 | <FirstRunGate |
| 145 | {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null} | 170 | enabled={primaryEnvironmentAuthenticated} |
| 146 | <RelayClientInstallDialog /> | 171 | hostedStatic={authGateState.status === "hosted-static"} |
| 147 | <ConnectOnboardingDialog /> | 172 | > |
| 148 | <SshPasswordPromptDialog /> | 173 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 149 | <ConfirmDialogHost /> | 174 | {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null} |
| 150 | <SlowRpcRequestToastCoordinator /> | 175 | <RelayClientInstallDialog /> |
| 151 | <HostedStaticEnvironmentBootstrap /> | 176 | <ConnectOnboardingDialog /> |
| 152 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} | 177 | <SshPasswordPromptDialog /> |
| 153 | {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null} | 178 | <ConfirmDialogHost /> |
| 154 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | 179 | <SlowRpcRequestToastCoordinator /> |
| 155 | {appShell} | 180 | <HostedStaticEnvironmentBootstrap /> |
| 156 | {/* Above the router: a theme draft is judged by walking the app, so the | 181 | {primaryEnvironmentAuthenticated ? ( |
| 157 | editor has to survive navigation away from settings. */} | 182 | <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} /> |
| 158 | <ThemeEditorHost /> | 183 | ) : null} |
| 184 | {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null} | ||
| 185 | {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} | ||
| 186 | {appShell} | ||
| 187 | {/* Above the router: a theme draft is judged by walking the app, so the | ||
| 188 | editor has to survive navigation away from settings. */} | ||
| 189 | <ThemeEditorHost /> | ||
| 190 | </FirstRunGate> | ||
| 159 | </AnchoredToastProvider> | 191 | </AnchoredToastProvider> |
| 160 | </ToastProvider> | 192 | </ToastProvider> |
| 161 | ); | 193 | ); |
| ... | @@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() { | ... | @@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() { |
| 381 | return null; | 413 | return null; |
| 382 | } | 414 | } |
| 383 | 415 | ||
| 384 | function EventRouter() { | 416 | function EventRouter({ |
| 417 | skipInitialBootstrapNavigation, | ||
| 418 | }: { | ||
| 419 | readonly skipInitialBootstrapNavigation: boolean; | ||
| 420 | }) { | ||
| 385 | const navigate = useNavigate(); | 421 | const navigate = useNavigate(); |
| 386 | const pathname = useLocation({ select: (loc) => loc.pathname }); | 422 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 387 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 423 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| ... | @@ -394,6 +430,7 @@ function EventRouter() { | ... | @@ -394,6 +430,7 @@ function EventRouter() { |
| 394 | const serverWelcome = useAtomValue(primaryServerWelcomeAtom); | 430 | const serverWelcome = useAtomValue(primaryServerWelcomeAtom); |
| 395 | const readPathname = useEffectEvent(() => pathname); | 431 | const readPathname = useEffectEvent(() => pathname); |
| 396 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 432 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 433 | const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation); | ||
| 397 | const handledConfigEventRef = useRef(serverConfigEvent); | 434 | const handledConfigEventRef = useRef(serverConfigEvent); |
| 398 | const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() => | 435 | const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() => |
| 399 | createKeybindingsUpdateToastController({}), | 436 | createKeybindingsUpdateToastController({}), |
| ... | @@ -425,6 +462,11 @@ function EventRouter() { | ... | @@ -425,6 +462,11 @@ function EventRouter() { |
| 425 | if (readPathname() !== "/") { | 462 | if (readPathname() !== "/") { |
| 426 | return; | 463 | return; |
| 427 | } | 464 | } |
| 465 | if (skipInitialBootstrapNavigationRef.current) { | ||
| 466 | skipInitialBootstrapNavigationRef.current = false; | ||
| 467 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | ||
| 468 | return; | ||
| 469 | } | ||
| 428 | if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { | 470 | if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { |
| 429 | return; | 471 | return; |
| 430 | } | 472 | } |
apps/web/src/routes/_chat.index.tsx+6-3| ... | @@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | ... | @@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 21 | 21 | ||
| 22 | function ChatIndexRouteView() { | 22 | function ChatIndexRouteView() { |
| 23 | const { authGateState } = Route.useRouteContext(); | 23 | const { authGateState } = Route.useRouteContext(); |
| 24 | const { environments } = useEnvironments(); | 24 | const { environments, isReady } = useEnvironments(); |
| 25 | 25 | ||
| 26 | if (authGateState.status === "hosted-static" && environments.length === 0) { | 26 | if (authGateState.status === "hosted-static") { |
| 27 | return <HostedStaticOnboardingState />; | 27 | if (!isReady) return null; |
| 28 | if (environments.length === 0) return <HostedStaticOnboardingState />; | ||
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | return <IndexDraftLanding />; | 31 | return <IndexDraftLanding />; |
| ... | @@ -79,6 +80,8 @@ function IndexDraftLanding() { | ... | @@ -79,6 +80,8 @@ function IndexDraftLanding() { |
| 79 | /> | 80 | /> |
| 80 | ) : null; | 81 | ) : null; |
| 81 | } | 82 | } |
| 83 | // First-run routing to the welcome wizard happens in FirstRunGate at the | ||
| 84 | // root, before this route ever renders. | ||
| 82 | return <NoProjectsHero />; | 85 | return <NoProjectsHero />; |
| 83 | } | 86 | } |
| 84 | 87 |
apps/web/src/routes/welcome.tsx created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; | ||
| 4 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * First-run welcome wizard. Full-screen, outside the sidebar shell (the root | ||
| 8 | * route mounts this path bare, like /pair). Reached only via the first-run | ||
| 9 | * gate on the index route; visiting it directly after onboarding is harmless — | ||
| 10 | * finishing again just refreshes the completion flag. | ||
| 11 | */ | ||
| 12 | export const Route = createFileRoute("/welcome")({ | ||
| 13 | beforeLoad: ({ context }) => { | ||
| 14 | const { authGateState } = context; | ||
| 15 | if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { | ||
| 16 | throw redirect({ to: "/pair", replace: true }); | ||
| 17 | } | ||
| 18 | }, | ||
| 19 | component: WelcomeRouteView, | ||
| 20 | }); | ||
| 21 | |||
| 22 | function WelcomeRouteView() { | ||
| 23 | const { authGateState } = Route.useRouteContext(); | ||
| 24 | const navigate = useNavigate(); | ||
| 25 | const openNewThread = useNewThreadHandler(); | ||
| 26 | // An authenticated gate means a primary server is serving this app — | ||
| 27 | // desktop, `npx t3`, or a dev server — and that server is "this machine" | ||
| 28 | // no matter what hostname the browser used. Only hosted-static has no | ||
| 29 | // local server to offer. | ||
| 30 | const localAvailable = authGateState.status === "authenticated"; | ||
| 31 | return ( | ||
| 32 | <WelcomeWizard | ||
| 33 | localAvailable={localAvailable} | ||
| 34 | onDone={(projectRef) => { | ||
| 35 | if (projectRef !== undefined) { | ||
| 36 | void openNewThread(projectRef, { replace: true }).catch(() => { | ||
| 37 | void navigate({ to: "/", replace: true }); | ||
| 38 | }); | ||
| 39 | return; | ||
| 40 | } | ||
| 41 | void navigate({ to: "/", replace: true }); | ||
| 42 | }} | ||
| 43 | /> | ||
| 44 | ); | ||
| 45 | } | ||
apps/web/src/state/agentSessions.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | createEnvironmentRpcCommand, | ||
| 4 | createEnvironmentRpcQueryAtomFamily, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | |||
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Scan of Claude Code / Codex home directories on an environment, surfacing | ||
| 11 | * project candidates for the welcome wizard's import step. The scan walks the | ||
| 12 | * filesystem server-side, so results are cached briefly and refreshed when the | ||
| 13 | * import step remounts. | ||
| 14 | */ | ||
| 15 | export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { | ||
| 16 | label: "environment-data:agent-sessions:scan", | ||
| 17 | tag: WS_METHODS.agentSessionsScan, | ||
| 18 | staleTimeMs: 30_000, | ||
| 19 | idleTtlMs: 5 * 60_000, | ||
| 20 | }); | ||
| 21 | |||
| 22 | export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRuntime, { | ||
| 23 | label: "environment-data:agent-sessions:import", | ||
| 24 | tag: WS_METHODS.agentSessionsImport, | ||
| 25 | }); | ||
docs/user/welcome-wizard.md created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | # Welcome wizard | ||
| 2 | |||
| 3 | T3 Code shows a setup flow when you open a new installation or connect to the | ||
| 4 | hosted app for the first time. Existing workspaces skip this flow. | ||
| 5 | |||
| 6 | ## Choose a connection | ||
| 7 | |||
| 8 | - **This computer** runs agents on the computer that hosts T3 Code. It does not | ||
| 9 | require an account. | ||
| 10 | - **T3 Connect** connects computers that are signed in to your account. Run | ||
| 11 | `npx t3 connect` on each computer you want to add, then start T3 Code or run | ||
| 12 | `npx t3 serve` so the computer stays available. | ||
| 13 | - **Pair a server** connects directly to a server on your network or tailnet. | ||
| 14 | Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and | ||
| 15 | paste the pairing link. You can also run `npx t3 serve --host <address>` and | ||
| 16 | use `npx t3 pair` when the server is already reachable on your network. | ||
| 17 | |||
| 18 | If T3 Code cannot confirm the workspace during startup, the setup flow shows | ||
| 19 | **Still connecting** instead of opening the app. Select **Reload** to try again. | ||
| 20 | |||
| 21 | If T3 Code cannot read your saved settings, it shows **Could not read settings**. | ||
| 22 | Select **Retry** after storage becomes available. Setup does not replace | ||
| 23 | unreadable settings with defaults. | ||
| 24 | |||
| 25 | ## Check your agents | ||
| 26 | |||
| 27 | T3 Code checks the selected computer for Claude Code and Codex. If an agent is | ||
| 28 | not installed or signed in, select its action to open a terminal with the | ||
| 29 | correct command ready to run. Other providers can be enabled in Settings. | ||
| 30 | |||
| 31 | The setup terminal uses the home directory and environment configured for the | ||
| 32 | selected provider instance. Sensitive values remain redacted in Settings and | ||
| 33 | terminal metadata while the terminal process can use them. | ||
| 34 | |||
| 35 | ## Import your projects | ||
| 36 | |||
| 37 | T3 Code finds directories that Claude Code or Codex has used. The default | ||
| 38 | selection includes projects active within the last 30 days. Select **Choose** | ||
| 39 | to include older projects or change the selection. | ||
| 40 | |||
| 41 | A large or malformed history can reach the scan limit. T3 Code keeps the | ||
| 42 | projects it found and warns when projects or conversations may be missing. | ||
| 43 | |||
| 44 | Imported projects include Codex and Claude conversations active within the last | ||
| 45 | 30 days. You can continue those conversations in T3 Code. | ||
| 46 | |||
| 47 | Conversation import is best effort. T3 Code keeps the first user prompt and the | ||
| 48 | newest remaining visible user and assistant messages, with 200 messages total. | ||
| 49 | It omits tool activity and attachments. For Codex, it omits generated setup | ||
| 50 | context only when a canonical user event and a valid shared turn ID identify the | ||
| 51 | same user turn. Ambiguous legacy or response-only context stays in the imported | ||
| 52 | conversation so T3 Code does not remove user text. It reads one conversation at | ||
| 53 | a time and skips files larger than 16 MiB. It ignores malformed records and skips | ||
| 54 | unreadable or unparseable conversations. | ||
| 55 | |||
| 56 | Each import attempt reads up to 100 conversation files and 64 MiB per project, | ||
| 57 | with up to 100,000 input records. Run import again to continue a large batch. | ||
| 58 | Completed conversations are not imported again. You can continue without the | ||
| 59 | remaining history. | ||
| 60 | |||
| 61 | You can skip agent setup and project import. Select **Back** to return to a | ||
| 62 | previous step. | ||
packages/client-runtime/src/rpc/client.test.ts+75-1| ... | @@ -3,10 +3,12 @@ import { | ... | @@ -3,10 +3,12 @@ import { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | type RelayClientInstallProgressEvent, | 4 | type RelayClientInstallProgressEvent, |
| 5 | type ServerConfigStreamEvent, | 5 | type ServerConfigStreamEvent, |
| 6 | type ServerLifecycleStreamEvent, | ||
| 6 | WS_METHODS, | 7 | WS_METHODS, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { describe, expect, it } from "@effect/vitest"; | 9 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import * as Cause from "effect/Cause"; | 10 | import * as Cause from "effect/Cause"; |
| 11 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| 12 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -27,7 +29,13 @@ import { | ... | @@ -27,7 +29,13 @@ import { |
| 27 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 29 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 28 | import * as RpcSession from "../rpc/session.ts"; | 30 | import * as RpcSession from "../rpc/session.ts"; |
| 29 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 31 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 30 | import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; | 32 | import { |
| 33 | EnvironmentRpcRequestObserver, | ||
| 34 | request, | ||
| 35 | runStream, | ||
| 36 | subscribe, | ||
| 37 | subscribeDynamicWithSession, | ||
| 38 | } from "./client.ts"; | ||
| 31 | 39 | ||
| 32 | const TARGET = new PrimaryConnectionTarget({ | 40 | const TARGET = new PrimaryConnectionTarget({ |
| 33 | environmentId: EnvironmentId.make("environment-1"), | 41 | environmentId: EnvironmentId.make("environment-1"), |
| ... | @@ -221,6 +229,72 @@ describe("environment RPC", () => { | ... | @@ -221,6 +229,72 @@ describe("environment RPC", () => { |
| 221 | }), | 229 | }), |
| 222 | ); | 230 | ); |
| 223 | 231 | ||
| 232 | it.effect("keeps the producer session on an old value buffered across a session switch", () => | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const firstSubscribed = yield* Deferred.make<void>(); | ||
| 235 | const secondSubscribed = yield* Deferred.make<void>(); | ||
| 236 | const firstValueBlocked = yield* Deferred.make<void>(); | ||
| 237 | const releaseFirstValue = yield* Deferred.make<void>(); | ||
| 238 | const firstValue = { source: "first", index: 1 } as unknown as ServerLifecycleStreamEvent; | ||
| 239 | const bufferedFirstValue = { | ||
| 240 | source: "first", | ||
| 241 | index: 2, | ||
| 242 | } as unknown as ServerLifecycleStreamEvent; | ||
| 243 | const secondValue = { source: "second", index: 1 } as unknown as ServerLifecycleStreamEvent; | ||
| 244 | const firstClient = { | ||
| 245 | [WS_METHODS.subscribeServerLifecycle]: () => | ||
| 246 | Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( | ||
| 247 | Stream.drain, | ||
| 248 | Stream.concat(Stream.fromIterable([firstValue, bufferedFirstValue])), | ||
| 249 | Stream.concat(Stream.never), | ||
| 250 | ), | ||
| 251 | } as unknown as WsRpcProtocolClient; | ||
| 252 | const secondClient = { | ||
| 253 | [WS_METHODS.subscribeServerLifecycle]: () => | ||
| 254 | Stream.fromEffect(Deferred.succeed(secondSubscribed, undefined)).pipe( | ||
| 255 | Stream.drain, | ||
| 256 | Stream.concat(Stream.make(secondValue)), | ||
| 257 | Stream.concat(Stream.never), | ||
| 258 | ), | ||
| 259 | } as unknown as WsRpcProtocolClient; | ||
| 260 | const firstSession = session(firstClient); | ||
| 261 | const secondSession = session(secondClient); | ||
| 262 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 263 | |||
| 264 | const resultFiber = yield* subscribeDynamicWithSession( | ||
| 265 | WS_METHODS.subscribeServerLifecycle, | ||
| 266 | () => Effect.succeed({}), | ||
| 267 | ).pipe( | ||
| 268 | Stream.mapEffect(([producerSession, value]) => | ||
| 269 | value === firstValue | ||
| 270 | ? Deferred.succeed(firstValueBlocked, undefined).pipe( | ||
| 271 | Effect.andThen(Deferred.await(releaseFirstValue)), | ||
| 272 | Effect.as([producerSession, value] as const), | ||
| 273 | ) | ||
| 274 | : Effect.succeed([producerSession, value] as const), | ||
| 275 | ), | ||
| 276 | Stream.take(3), | ||
| 277 | Stream.runCollect, | ||
| 278 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 279 | Effect.forkChild, | ||
| 280 | ); | ||
| 281 | |||
| 282 | yield* SubscriptionRef.set(activeSession, Option.some(firstSession)); | ||
| 283 | yield* Deferred.await(firstSubscribed); | ||
| 284 | yield* Deferred.await(firstValueBlocked); | ||
| 285 | yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); | ||
| 286 | yield* Deferred.await(secondSubscribed); | ||
| 287 | yield* Deferred.succeed(releaseFirstValue, undefined); | ||
| 288 | |||
| 289 | const result = yield* Fiber.join(resultFiber); | ||
| 290 | expect(result).toEqual([ | ||
| 291 | [firstSession, firstValue], | ||
| 292 | [firstSession, bufferedFirstValue], | ||
| 293 | [secondSession, secondValue], | ||
| 294 | ]); | ||
| 295 | }), | ||
| 296 | ); | ||
| 297 | |||
| 224 | it.effect("keeps durable subscriptions alive across a transport failure and new session", () => | 298 | it.effect("keeps durable subscriptions alive across a transport failure and new session", () => |
| 225 | Effect.gen(function* () { | 299 | Effect.gen(function* () { |
| 226 | const subscriptions: string[] = []; | 300 | const subscriptions: string[] = []; |
packages/client-runtime/src/rpc/client.ts+38-11| ... | @@ -178,15 +178,15 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> { | ... | @@ -178,15 +178,15 @@ interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> { |
| 178 | readonly resubscribe?: Stream.Stream<unknown, never, never>; | 178 | readonly resubscribe?: Stream.Stream<unknown, never, never>; |
| 179 | } | 179 | } |
| 180 | 180 | ||
| 181 | export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | 181 | function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>( |
| 182 | tag: TTag, | 182 | tag: TTag, |
| 183 | makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>, | 183 | makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>, |
| 184 | mapStream: ( | ||
| 185 | session: RpcSession, | ||
| 186 | stream: Stream.Stream<EnvironmentRpcStreamValue<TTag>, EnvironmentRpcStreamFailure<TTag>>, | ||
| 187 | ) => Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>>, | ||
| 184 | options?: SubscriptionOptions<TTag>, | 188 | options?: SubscriptionOptions<TTag>, |
| 185 | ): Stream.Stream< | 189 | ): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>, EnvironmentSupervisor> { |
| 186 | EnvironmentRpcStreamValue<TTag>, | ||
| 187 | EnvironmentRpcStreamFailure<TTag>, | ||
| 188 | EnvironmentSupervisor | ||
| 189 | > { | ||
| 190 | return Stream.unwrap( | 190 | return Stream.unwrap( |
| 191 | Effect.gen(function* () { | 191 | Effect.gen(function* () { |
| 192 | const supervisor = yield* EnvironmentSupervisor; | 192 | const supervisor = yield* EnvironmentSupervisor; |
| ... | @@ -216,10 +216,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | ... | @@ -216,10 +216,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( |
| 216 | EnvironmentRpcStreamValue<TTag>, | 216 | EnvironmentRpcStreamValue<TTag>, |
| 217 | EnvironmentRpcStreamFailure<TTag> | 217 | EnvironmentRpcStreamFailure<TTag> |
| 218 | >; | 218 | >; |
| 219 | const subscribeToSession = (): Stream.Stream< | 219 | const subscribeToSession = (): Stream.Stream<A, EnvironmentRpcStreamFailure<TTag>> => |
| 220 | EnvironmentRpcStreamValue<TTag>, | ||
| 221 | EnvironmentRpcStreamFailure<TTag> | ||
| 222 | > => | ||
| 223 | Stream.suspend(() => | 220 | Stream.suspend(() => |
| 224 | Stream.unwrap( | 221 | Stream.unwrap( |
| 225 | Effect.gen(function* () { | 222 | Effect.gen(function* () { |
| ... | @@ -229,7 +226,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | ... | @@ -229,7 +226,7 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( |
| 229 | method: tag, | 226 | method: tag, |
| 230 | input, | 227 | input, |
| 231 | }); | 228 | }); |
| 232 | return method(input).pipe( | 229 | return mapStream(session, method(input)).pipe( |
| 233 | Stream.ensuring(completeObservation), | 230 | Stream.ensuring(completeObservation), |
| 234 | Stream.catchCause((cause) => { | 231 | Stream.catchCause((cause) => { |
| 235 | const hasOnlyExpectedFailures = | 232 | const hasOnlyExpectedFailures = |
| ... | @@ -287,6 +284,36 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | ... | @@ -287,6 +284,36 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( |
| 287 | ); | 284 | ); |
| 288 | } | 285 | } |
| 289 | 286 | ||
| 287 | export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | ||
| 288 | tag: TTag, | ||
| 289 | makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>, | ||
| 290 | options?: SubscriptionOptions<TTag>, | ||
| 291 | ): Stream.Stream< | ||
| 292 | EnvironmentRpcStreamValue<TTag>, | ||
| 293 | EnvironmentRpcStreamFailure<TTag>, | ||
| 294 | EnvironmentSupervisor | ||
| 295 | > { | ||
| 296 | return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options); | ||
| 297 | } | ||
| 298 | |||
| 299 | /** Tags each value before `switchMap` can buffer it across a session change. */ | ||
| 300 | export function subscribeDynamicWithSession<TTag extends EnvironmentSubscriptionRpcTag>( | ||
| 301 | tag: TTag, | ||
| 302 | makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>, | ||
| 303 | options?: SubscriptionOptions<TTag>, | ||
| 304 | ): Stream.Stream< | ||
| 305 | readonly [session: RpcSession, value: EnvironmentRpcStreamValue<TTag>], | ||
| 306 | EnvironmentRpcStreamFailure<TTag>, | ||
| 307 | EnvironmentSupervisor | ||
| 308 | > { | ||
| 309 | return subscribeDynamicMapped( | ||
| 310 | tag, | ||
| 311 | makeInput, | ||
| 312 | (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)), | ||
| 313 | options, | ||
| 314 | ); | ||
| 315 | } | ||
| 316 | |||
| 290 | export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( | 317 | export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( |
| 291 | tag: TTag, | 318 | tag: TTag, |
| 292 | input: EnvironmentRpcInput<TTag>, | 319 | input: EnvironmentRpcInput<TTag>, |
packages/client-runtime/src/state/server.test.ts+211-7| ... | @@ -7,8 +7,8 @@ import { | ... | @@ -7,8 +7,8 @@ import { |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import * as Cause from "effect/Cause"; | 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Duration from "effect/Duration"; | ||
| 11 | import * as Deferred from "effect/Deferred"; | 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| ... | @@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts"; | ... | @@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts"; |
| 31 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 31 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 32 | import type { RpcSession } from "../rpc/session.ts"; | 32 | import type { RpcSession } from "../rpc/session.ts"; |
| 33 | import { | 33 | import { |
| 34 | applyServerWelcomeEvent, | ||
| 35 | makeEnvironmentServerWelcomeState, | ||
| 34 | makeEnvironmentServerConfigState, | 36 | makeEnvironmentServerConfigState, |
| 35 | isLegacyUpdateHandoffLoss, | 37 | isLegacyUpdateHandoffLoss, |
| 36 | matchesServerUpdateReadyEvent, | 38 | matchesServerUpdateReadyEvent, |
| 37 | matchesServerUpdateResumeEvent, | 39 | matchesServerUpdateResumeEvent, |
| 38 | nudgeReconnectDuringUpdateRestart, | 40 | nudgeReconnectDuringUpdateRestart, |
| 39 | projectServerWelcome, | ||
| 40 | resolveServerConfigValue, | 41 | resolveServerConfigValue, |
| 42 | resolveServerWelcomeState, | ||
| 41 | resolveServerUpdateProgressResult, | 43 | resolveServerUpdateProgressResult, |
| 42 | serverUpdateStateForProgressEvent, | 44 | serverUpdateStateForProgressEvent, |
| 43 | serverUpdateStateForServerVersion, | 45 | serverUpdateStateForServerVersion, |
| ... | @@ -494,25 +496,227 @@ describe("server state projection", () => { | ... | @@ -494,25 +496,227 @@ describe("server state projection", () => { |
| 494 | expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); | 496 | expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); |
| 495 | }); | 497 | }); |
| 496 | 498 | ||
| 497 | it("retains welcome when a ready event follows in the same stream chunk", () => { | 499 | it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => { |
| 500 | const firstSession = session({} as WsRpcProtocolClient); | ||
| 501 | const secondSession = session({} as WsRpcProtocolClient); | ||
| 498 | const welcome = { | 502 | const welcome = { |
| 499 | environment: {} as ServerLifecycleWelcomePayload["environment"], | 503 | environment: {} as ServerLifecycleWelcomePayload["environment"], |
| 500 | cwd: "/repo", | 504 | cwd: "/repo", |
| 501 | projectName: "repo", | 505 | projectName: "repo", |
| 502 | } as ServerLifecycleWelcomePayload; | 506 | } as ServerLifecycleWelcomePayload; |
| 503 | const [afterWelcome] = projectServerWelcome(Option.none(), { | 507 | const initial = { |
| 508 | currentSession: firstSession, | ||
| 509 | welcomeSession: firstSession, | ||
| 510 | welcome: null, | ||
| 511 | }; | ||
| 512 | const afterWelcome = applyServerWelcomeEvent(initial, firstSession, { | ||
| 504 | type: "welcome", | 513 | type: "welcome", |
| 505 | payload: welcome, | 514 | payload: welcome, |
| 506 | }); | 515 | }); |
| 507 | const [afterReady, emitted] = projectServerWelcome(afterWelcome, { | 516 | const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, { |
| 508 | type: "ready", | 517 | type: "ready", |
| 509 | payload: {}, | 518 | payload: {}, |
| 510 | }); | 519 | }); |
| 520 | const afterSwitch = { ...afterReady, currentSession: secondSession }; | ||
| 521 | const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, { | ||
| 522 | type: "welcome", | ||
| 523 | payload: { ...welcome, cwd: "/stale" }, | ||
| 524 | }); | ||
| 511 | 525 | ||
| 512 | expect(Option.getOrThrow(afterReady)).toBe(welcome); | 526 | expect(afterReady).toBe(afterWelcome); |
| 513 | expect(emitted).toEqual([]); | 527 | expect(resolveServerWelcomeState(afterReady)).toBe(welcome); |
| 528 | expect(afterBufferedOldWelcome).toBe(afterSwitch); | ||
| 529 | expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull(); | ||
| 514 | }); | 530 | }); |
| 515 | 531 | ||
| 532 | it.effect("checks the authoritative session before accepting a buffered welcome", () => | ||
| 533 | Effect.gen(function* () { | ||
| 534 | const firstEvents = yield* Queue.unbounded<{ | ||
| 535 | readonly type: "welcome" | "ready"; | ||
| 536 | readonly payload: unknown; | ||
| 537 | }>(); | ||
| 538 | const firstSubscribed = yield* Deferred.make<void>(); | ||
| 539 | const firstClient = { | ||
| 540 | [WS_METHODS.subscribeServerLifecycle]: () => | ||
| 541 | Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( | ||
| 542 | Stream.drain, | ||
| 543 | Stream.concat(Stream.fromQueue(firstEvents)), | ||
| 544 | ), | ||
| 545 | } as unknown as WsRpcProtocolClient; | ||
| 546 | const firstSession = session(firstClient); | ||
| 547 | const secondSession = session({} as WsRpcProtocolClient); | ||
| 548 | const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); | ||
| 549 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 550 | target: TARGET, | ||
| 551 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 552 | session: supervisorSession, | ||
| 553 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 554 | connect: Effect.void, | ||
| 555 | disconnect: Effect.void, | ||
| 556 | retryNow: Effect.void, | ||
| 557 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 558 | const staleWelcome = { | ||
| 559 | environment: {} as ServerLifecycleWelcomePayload["environment"], | ||
| 560 | cwd: "/stale", | ||
| 561 | projectName: "stale", | ||
| 562 | } as ServerLifecycleWelcomePayload; | ||
| 563 | |||
| 564 | yield* Effect.scoped( | ||
| 565 | Effect.gen(function* () { | ||
| 566 | const state = yield* makeEnvironmentServerWelcomeState().pipe( | ||
| 567 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 568 | ); | ||
| 569 | yield* Deferred.await(firstSubscribed); | ||
| 570 | |||
| 571 | // Model the point after the ref changed but before either subscriber | ||
| 572 | // processed its publication. | ||
| 573 | supervisorSession.value = Option.some(secondSession); | ||
| 574 | const handled = yield* SubscriptionRef.changes(state).pipe( | ||
| 575 | Stream.filter( | ||
| 576 | (value) => value.currentSession === secondSession || value.welcome === staleWelcome, | ||
| 577 | ), | ||
| 578 | Stream.runHead, | ||
| 579 | Effect.map(Option.getOrThrow), | ||
| 580 | Effect.forkChild, | ||
| 581 | ); | ||
| 582 | yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome }); | ||
| 583 | |||
| 584 | const next = yield* Fiber.join(handled); | ||
| 585 | expect(next.currentSession).toBe(secondSession); | ||
| 586 | expect(resolveServerWelcomeState(next)).toBeNull(); | ||
| 587 | }), | ||
| 588 | ); | ||
| 589 | }), | ||
| 590 | ); | ||
| 591 | |||
| 592 | it.effect("reads the authoritative session after waiting for the welcome state lock", () => | ||
| 593 | Effect.gen(function* () { | ||
| 594 | const firstSubscribed = yield* Deferred.make<void>(); | ||
| 595 | const firstClient = { | ||
| 596 | [WS_METHODS.subscribeServerLifecycle]: () => | ||
| 597 | Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain), | ||
| 598 | } as unknown as WsRpcProtocolClient; | ||
| 599 | const secondClient = { | ||
| 600 | [WS_METHODS.subscribeServerLifecycle]: () => Stream.never, | ||
| 601 | } as unknown as WsRpcProtocolClient; | ||
| 602 | const firstSession = session(firstClient); | ||
| 603 | const secondSession = session(secondClient); | ||
| 604 | const thirdSession = session({} as WsRpcProtocolClient); | ||
| 605 | const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); | ||
| 606 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 607 | target: TARGET, | ||
| 608 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 609 | session: supervisorSession, | ||
| 610 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 611 | connect: Effect.void, | ||
| 612 | disconnect: Effect.void, | ||
| 613 | retryNow: Effect.void, | ||
| 614 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 615 | |||
| 616 | yield* Effect.scoped( | ||
| 617 | Effect.gen(function* () { | ||
| 618 | const state = yield* makeEnvironmentServerWelcomeState().pipe( | ||
| 619 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 620 | ); | ||
| 621 | yield* Deferred.await(firstSubscribed); | ||
| 622 | const changed = yield* SubscriptionRef.changes(state).pipe( | ||
| 623 | Stream.filter((value) => value.currentSession !== firstSession), | ||
| 624 | Stream.runHead, | ||
| 625 | Effect.map(Option.getOrThrow), | ||
| 626 | Effect.forkChild, | ||
| 627 | ); | ||
| 628 | |||
| 629 | yield* state.semaphore.withPermit( | ||
| 630 | Effect.gen(function* () { | ||
| 631 | yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); | ||
| 632 | yield* Effect.yieldNow; | ||
| 633 | yield* Effect.yieldNow; | ||
| 634 | yield* Effect.yieldNow; | ||
| 635 | supervisorSession.value = Option.some(thirdSession); | ||
| 636 | }), | ||
| 637 | ); | ||
| 638 | |||
| 639 | expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession); | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | }), | ||
| 643 | ); | ||
| 644 | |||
| 645 | it.effect("clears a welcome until the reconnected session sends its own", () => | ||
| 646 | Effect.gen(function* () { | ||
| 647 | const firstEvents = yield* Queue.unbounded<{ | ||
| 648 | readonly type: "welcome" | "ready"; | ||
| 649 | readonly payload: unknown; | ||
| 650 | }>(); | ||
| 651 | const secondEvents = yield* Queue.unbounded<{ | ||
| 652 | readonly type: "welcome" | "ready"; | ||
| 653 | readonly payload: unknown; | ||
| 654 | }>(); | ||
| 655 | const firstClient = { | ||
| 656 | [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents), | ||
| 657 | } as unknown as WsRpcProtocolClient; | ||
| 658 | const secondClient = { | ||
| 659 | [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents), | ||
| 660 | } as unknown as WsRpcProtocolClient; | ||
| 661 | const firstSession = session(firstClient); | ||
| 662 | const secondSession = session(secondClient); | ||
| 663 | const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); | ||
| 664 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 665 | target: TARGET, | ||
| 666 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 667 | session: supervisorSession, | ||
| 668 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 669 | connect: Effect.void, | ||
| 670 | disconnect: Effect.void, | ||
| 671 | retryNow: Effect.void, | ||
| 672 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 673 | const firstWelcome = { | ||
| 674 | environment: {} as ServerLifecycleWelcomePayload["environment"], | ||
| 675 | cwd: "/first", | ||
| 676 | projectName: "first", | ||
| 677 | } as ServerLifecycleWelcomePayload; | ||
| 678 | const secondWelcome = { | ||
| 679 | environment: {} as ServerLifecycleWelcomePayload["environment"], | ||
| 680 | cwd: "/second", | ||
| 681 | projectName: "second", | ||
| 682 | } as ServerLifecycleWelcomePayload; | ||
| 683 | |||
| 684 | yield* Effect.scoped( | ||
| 685 | Effect.gen(function* () { | ||
| 686 | const state = yield* makeEnvironmentServerWelcomeState().pipe( | ||
| 687 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 688 | ); | ||
| 689 | const nextResolved = ( | ||
| 690 | predicate: (value: ServerLifecycleWelcomePayload | null) => boolean, | ||
| 691 | ) => | ||
| 692 | SubscriptionRef.changes(state).pipe( | ||
| 693 | Stream.map(resolveServerWelcomeState), | ||
| 694 | Stream.filter(predicate), | ||
| 695 | Stream.runHead, | ||
| 696 | Effect.map(Option.getOrThrow), | ||
| 697 | ); | ||
| 698 | |||
| 699 | const first = yield* nextResolved((value) => value === firstWelcome).pipe( | ||
| 700 | Effect.forkChild, | ||
| 701 | ); | ||
| 702 | yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome }); | ||
| 703 | expect(yield* Fiber.join(first)).toBe(firstWelcome); | ||
| 704 | |||
| 705 | const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild); | ||
| 706 | yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); | ||
| 707 | expect(yield* Fiber.join(cleared)).toBeNull(); | ||
| 708 | expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull(); | ||
| 709 | |||
| 710 | const second = yield* nextResolved((value) => value === secondWelcome).pipe( | ||
| 711 | Effect.forkChild, | ||
| 712 | ); | ||
| 713 | yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome }); | ||
| 714 | expect(yield* Fiber.join(second)).toBe(secondWelcome); | ||
| 715 | }), | ||
| 716 | ); | ||
| 717 | }), | ||
| 718 | ); | ||
| 719 | |||
| 516 | it("prefers an active session config over cache until a live event arrives", () => { | 720 | it("prefers an active session config over cache until a live event arrives", () => { |
| 517 | const config = (source: string, serverVersion: string) => | 721 | const config = (source: string, serverVersion: string) => |
| 518 | ({ | 722 | ({ |
packages/client-runtime/src/state/server.ts+133-19| ... | @@ -40,8 +40,10 @@ import { | ... | @@ -40,8 +40,10 @@ import { |
| 40 | request, | 40 | request, |
| 41 | runStream, | 41 | runStream, |
| 42 | subscribe, | 42 | subscribe, |
| 43 | subscribeDynamicWithSession, | ||
| 43 | type EnvironmentRpcInput, | 44 | type EnvironmentRpcInput, |
| 44 | } from "../rpc/client.ts"; | 45 | } from "../rpc/client.ts"; |
| 46 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 45 | import { followStreamInEnvironment } from "./runtime.ts"; | 47 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 46 | import { | 48 | import { |
| 47 | applyServerConfigProjection, | 49 | applyServerConfigProjection, |
| ... | @@ -476,21 +478,119 @@ export function serverConfigStateChanges( | ... | @@ -476,21 +478,119 @@ export function serverConfigStateChanges( |
| 476 | ); | 478 | ); |
| 477 | } | 479 | } |
| 478 | 480 | ||
| 479 | export function projectServerWelcome( | 481 | export function applyServerWelcomeEvent( |
| 480 | current: Option.Option<ServerLifecycleWelcomePayload>, | 482 | current: EnvironmentServerWelcomeState, |
| 483 | session: RpcSession, | ||
| 481 | event: { | 484 | event: { |
| 482 | readonly type: "welcome" | "ready"; | 485 | readonly type: "welcome" | "ready"; |
| 483 | readonly payload: unknown; | 486 | readonly payload: unknown; |
| 484 | }, | 487 | }, |
| 485 | ): readonly [ | 488 | ): EnvironmentServerWelcomeState { |
| 486 | Option.Option<ServerLifecycleWelcomePayload>, | 489 | return event.type === "welcome" && current.currentSession === session |
| 487 | ReadonlyArray<ServerLifecycleWelcomePayload>, | 490 | ? { |
| 488 | ] { | 491 | ...current, |
| 489 | if (event.type !== "welcome") { | 492 | welcomeSession: session, |
| 490 | return [current, []]; | 493 | welcome: event.payload as ServerLifecycleWelcomePayload, |
| 491 | } | 494 | } |
| 492 | const welcome = event.payload as ServerLifecycleWelcomePayload; | 495 | : current; |
| 493 | return [Option.some(welcome), [welcome]]; | 496 | } |
| 497 | |||
| 498 | export interface EnvironmentServerWelcomeState { | ||
| 499 | readonly currentSession: RpcSession | null; | ||
| 500 | readonly welcomeSession: RpcSession | null; | ||
| 501 | readonly welcome: ServerLifecycleWelcomePayload | null; | ||
| 502 | } | ||
| 503 | |||
| 504 | export function resolveServerWelcomeState( | ||
| 505 | state: EnvironmentServerWelcomeState, | ||
| 506 | ): ServerLifecycleWelcomePayload | null { | ||
| 507 | return state.currentSession === state.welcomeSession ? state.welcome : null; | ||
| 508 | } | ||
| 509 | |||
| 510 | export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")( | ||
| 511 | function* () { | ||
| 512 | const supervisor = yield* EnvironmentSupervisor; | ||
| 513 | const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session)); | ||
| 514 | const state = yield* SubscriptionRef.make<EnvironmentServerWelcomeState>({ | ||
| 515 | currentSession: initialSession, | ||
| 516 | welcomeSession: null, | ||
| 517 | welcome: null, | ||
| 518 | }); | ||
| 519 | |||
| 520 | const updateWithCurrentSession = Effect.fn( | ||
| 521 | "EnvironmentServerWelcomeState.updateWithCurrentSession", | ||
| 522 | )(function* ( | ||
| 523 | update: ( | ||
| 524 | current: EnvironmentServerWelcomeState, | ||
| 525 | currentSession: RpcSession | null, | ||
| 526 | ) => EnvironmentServerWelcomeState, | ||
| 527 | ) { | ||
| 528 | return yield* SubscriptionRef.modifyEffect(state, (current) => | ||
| 529 | SubscriptionRef.get(supervisor.session).pipe( | ||
| 530 | Effect.map( | ||
| 531 | (latestSession) => | ||
| 532 | [undefined, update(current, Option.getOrNull(latestSession))] as const, | ||
| 533 | ), | ||
| 534 | ), | ||
| 535 | ); | ||
| 536 | }); | ||
| 537 | |||
| 538 | yield* SubscriptionRef.changes(supervisor.session).pipe( | ||
| 539 | Stream.runForEach(() => | ||
| 540 | updateWithCurrentSession((current, currentSession) => ({ | ||
| 541 | ...current, | ||
| 542 | currentSession, | ||
| 543 | })), | ||
| 544 | ), | ||
| 545 | Effect.forkScoped, | ||
| 546 | ); | ||
| 547 | |||
| 548 | yield* subscribeDynamicWithSession( | ||
| 549 | WS_METHODS.subscribeServerLifecycle, | ||
| 550 | Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) { | ||
| 551 | yield* updateWithCurrentSession((current, currentSession) => | ||
| 552 | currentSession === session | ||
| 553 | ? { | ||
| 554 | ...current, | ||
| 555 | currentSession, | ||
| 556 | welcomeSession: session, | ||
| 557 | welcome: null, | ||
| 558 | } | ||
| 559 | : { ...current, currentSession }, | ||
| 560 | ); | ||
| 561 | return {}; | ||
| 562 | }), | ||
| 563 | ).pipe( | ||
| 564 | Stream.runForEach(([session, event]) => | ||
| 565 | updateWithCurrentSession((current, currentSession) => | ||
| 566 | applyServerWelcomeEvent( | ||
| 567 | { | ||
| 568 | ...current, | ||
| 569 | currentSession, | ||
| 570 | }, | ||
| 571 | session, | ||
| 572 | event, | ||
| 573 | ), | ||
| 574 | ), | ||
| 575 | ), | ||
| 576 | Effect.forkScoped, | ||
| 577 | ); | ||
| 578 | |||
| 579 | return state; | ||
| 580 | }, | ||
| 581 | ); | ||
| 582 | |||
| 583 | export function serverWelcomeStateChanges(environmentId: EnvironmentId) { | ||
| 584 | return followStreamInEnvironment( | ||
| 585 | environmentId, | ||
| 586 | Stream.unwrap( | ||
| 587 | makeEnvironmentServerWelcomeState().pipe( | ||
| 588 | Effect.map((state) => | ||
| 589 | SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)), | ||
| 590 | ), | ||
| 591 | ), | ||
| 592 | ), | ||
| 593 | ); | ||
| 494 | } | 594 | } |
| 495 | 595 | ||
| 496 | export function resolveServerConfigValue( | 596 | export function resolveServerConfigValue( |
| ... | @@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms<R, E>( |
| 833 | Atom.withLabel(`environment-data:server:providers:${environmentId}`), | 933 | Atom.withLabel(`environment-data:server:providers:${environmentId}`), |
| 834 | ), | 934 | ), |
| 835 | ); | 935 | ); |
| 936 | const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) => | ||
| 937 | runtime | ||
| 938 | .atom(serverWelcomeStateChanges(environmentId), { initialValue: null }) | ||
| 939 | .pipe( | ||
| 940 | Atom.setIdleTTL(5 * 60_000), | ||
| 941 | Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`), | ||
| 942 | ), | ||
| 943 | ); | ||
| 944 | const welcomeFamily = Atom.family((environmentId: EnvironmentId) => | ||
| 945 | Atom.make((get) => { | ||
| 946 | const result = get(welcomeStateFamily(environmentId)); | ||
| 947 | if (result._tag !== "Success") return result; | ||
| 948 | return result.value === null | ||
| 949 | ? AsyncResult.initial<ServerLifecycleWelcomePayload, never>(result.waiting) | ||
| 950 | : AsyncResult.success(result.value, result); | ||
| 951 | }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)), | ||
| 952 | ); | ||
| 953 | const welcome = (target: { | ||
| 954 | readonly environmentId: EnvironmentId; | ||
| 955 | readonly input: EnvironmentRpcInput<typeof WS_METHODS.subscribeServerLifecycle>; | ||
| 956 | }) => welcomeFamily(target.environmentId); | ||
| 836 | 957 | ||
| 837 | return { | 958 | return { |
| 838 | configValueAtom, | 959 | configValueAtom, |
| ... | @@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 916 | refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), | 1037 | refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), |
| 917 | }), | 1038 | }), |
| 918 | configProjection, | 1039 | configProjection, |
| 919 | welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | 1040 | welcome, |
| 920 | label: "environment-data:server:welcome", | ||
| 921 | tag: WS_METHODS.subscribeServerLifecycle, | ||
| 922 | transform: (stream) => | ||
| 923 | stream.pipe( | ||
| 924 | Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome), | ||
| 925 | ), | ||
| 926 | }), | ||
| 927 | consumeResetCredit: createEnvironmentRpcCommand(runtime, { | 1041 | consumeResetCredit: createEnvironmentRpcCommand(runtime, { |
| 928 | label: "environment-data:server:consume-reset-credit", | 1042 | label: "environment-data:server:consume-reset-credit", |
| 929 | tag: WS_METHODS.providerConsumeResetCredit, | 1043 | tag: WS_METHODS.providerConsumeResetCredit, |
packages/client-runtime/src/state/threadReducer.test.ts+128| ... | @@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => { | ... | @@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => { |
| 380 | } | 380 | } |
| 381 | }); | 381 | }); |
| 382 | 382 | ||
| 383 | it("keeps imported replies turnless when delivered again", () => { | ||
| 384 | const event = { | ||
| 385 | ...baseEventFields, | ||
| 386 | sequence: 6, | ||
| 387 | occurredAt: "2026-04-01T06:00:00.000Z", | ||
| 388 | aggregateKind: "thread", | ||
| 389 | aggregateId: baseThread.id, | ||
| 390 | type: "thread.message-sent", | ||
| 391 | payload: { | ||
| 392 | threadId: baseThread.id, | ||
| 393 | messageId: MessageId.make("import:codex:session-1:000001"), | ||
| 394 | role: "assistant", | ||
| 395 | text: "Imported reply", | ||
| 396 | turnId: null, | ||
| 397 | streaming: false, | ||
| 398 | createdAt: "2026-03-01T06:00:00.000Z", | ||
| 399 | updatedAt: "2026-03-01T06:00:00.000Z", | ||
| 400 | }, | ||
| 401 | } as const; | ||
| 402 | |||
| 403 | const imported = applyThreadDetailEvent(baseThread, event); | ||
| 404 | expect(imported.kind).toBe("updated"); | ||
| 405 | if (imported.kind !== "updated") return; | ||
| 406 | expect(imported.thread.latestTurn).toBeNull(); | ||
| 407 | expect(imported.thread.checkpoints).toBe(baseThread.checkpoints); | ||
| 408 | |||
| 409 | const repeated = applyThreadDetailEvent(imported.thread, { ...event, sequence: 7 }); | ||
| 410 | expect(repeated.kind).toBe("updated"); | ||
| 411 | if (repeated.kind !== "updated") return; | ||
| 412 | expect(repeated.thread.messages).toEqual(imported.thread.messages); | ||
| 413 | expect(repeated.thread.latestTurn).toBeNull(); | ||
| 414 | expect(repeated.thread.checkpoints).toBe(baseThread.checkpoints); | ||
| 415 | }); | ||
| 416 | |||
| 383 | it("appends text for streaming messages", () => { | 417 | it("appends text for streaming messages", () => { |
| 384 | const threadWithMessage: OrchestrationThread = { | 418 | const threadWithMessage: OrchestrationThread = { |
| 385 | ...baseThread, | 419 | ...baseThread, |
| ... | @@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => { | ... | @@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => { |
| 1178 | }); | 1212 | }); |
| 1179 | 1213 | ||
| 1180 | describe("thread.reverted", () => { | 1214 | describe("thread.reverted", () => { |
| 1215 | it("keeps imported history and removes the first live prompt at checkpoint zero", () => { | ||
| 1216 | const threadWithImportedHistory: OrchestrationThread = { | ||
| 1217 | ...baseThread, | ||
| 1218 | messages: [ | ||
| 1219 | { | ||
| 1220 | id: MessageId.make("import:codex:session-1:000000"), | ||
| 1221 | role: "user", | ||
| 1222 | text: "Imported prompt", | ||
| 1223 | turnId: null, | ||
| 1224 | streaming: false, | ||
| 1225 | createdAt: "2026-03-01T00:00:00.000Z", | ||
| 1226 | updatedAt: "2026-03-01T00:00:00.000Z", | ||
| 1227 | }, | ||
| 1228 | { | ||
| 1229 | id: MessageId.make("import:codex:session-1:000001"), | ||
| 1230 | role: "assistant", | ||
| 1231 | text: "Imported answer", | ||
| 1232 | turnId: null, | ||
| 1233 | streaming: false, | ||
| 1234 | createdAt: "2026-03-01T00:01:00.000Z", | ||
| 1235 | updatedAt: "2026-03-01T00:01:00.000Z", | ||
| 1236 | }, | ||
| 1237 | { | ||
| 1238 | id: MessageId.make("live-user-message"), | ||
| 1239 | role: "user", | ||
| 1240 | text: "New work", | ||
| 1241 | turnId: null, | ||
| 1242 | streaming: false, | ||
| 1243 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 1244 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 1245 | }, | ||
| 1246 | ], | ||
| 1247 | }; | ||
| 1248 | |||
| 1249 | const result = applyThreadDetailEvent(threadWithImportedHistory, { | ||
| 1250 | ...baseEventFields, | ||
| 1251 | sequence: 14, | ||
| 1252 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 1253 | aggregateKind: "thread", | ||
| 1254 | aggregateId: ThreadId.make("thread-1"), | ||
| 1255 | type: "thread.reverted", | ||
| 1256 | payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, | ||
| 1257 | }); | ||
| 1258 | |||
| 1259 | expect(result.kind).toBe("updated"); | ||
| 1260 | if (result.kind === "updated") { | ||
| 1261 | expect(result.thread.messages.map((message) => message.text)).toEqual([ | ||
| 1262 | "Imported prompt", | ||
| 1263 | "Imported answer", | ||
| 1264 | ]); | ||
| 1265 | } | ||
| 1266 | }); | ||
| 1267 | |||
| 1268 | it("fallback-retains the earliest absolute timestamp across offsets", () => { | ||
| 1269 | const threadWithOffsetMessages: OrchestrationThread = { | ||
| 1270 | ...baseThread, | ||
| 1271 | messages: [ | ||
| 1272 | { | ||
| 1273 | id: MessageId.make("earlier-by-offset"), | ||
| 1274 | role: "user", | ||
| 1275 | text: "Earlier", | ||
| 1276 | turnId: null, | ||
| 1277 | streaming: false, | ||
| 1278 | createdAt: "2026-04-01T10:30:00.000+02:00", | ||
| 1279 | updatedAt: "2026-04-01T10:30:00.000+02:00", | ||
| 1280 | }, | ||
| 1281 | { | ||
| 1282 | id: MessageId.make("later-in-utc"), | ||
| 1283 | role: "user", | ||
| 1284 | text: "Later", | ||
| 1285 | turnId: null, | ||
| 1286 | streaming: false, | ||
| 1287 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 1288 | updatedAt: "2026-04-01T09:00:00.000Z", | ||
| 1289 | }, | ||
| 1290 | ], | ||
| 1291 | }; | ||
| 1292 | |||
| 1293 | const result = applyThreadDetailEvent(threadWithOffsetMessages, { | ||
| 1294 | ...baseEventFields, | ||
| 1295 | sequence: 14, | ||
| 1296 | occurredAt: "2026-04-01T10:00:00.000Z", | ||
| 1297 | aggregateKind: "thread", | ||
| 1298 | aggregateId: ThreadId.make("thread-1"), | ||
| 1299 | type: "thread.reverted", | ||
| 1300 | payload: { threadId: ThreadId.make("thread-1"), turnCount: 1 }, | ||
| 1301 | }); | ||
| 1302 | |||
| 1303 | expect(result.kind).toBe("updated"); | ||
| 1304 | if (result.kind === "updated") { | ||
| 1305 | expect(result.thread.messages.map((message) => message.id)).toEqual(["earlier-by-offset"]); | ||
| 1306 | } | ||
| 1307 | }); | ||
| 1308 | |||
| 1181 | it("filters entities to retained turns", () => { | 1309 | it("filters entities to retained turns", () => { |
| 1182 | const threadWithData: OrchestrationThread = { | 1310 | const threadWithData: OrchestrationThread = { |
| 1183 | ...baseThread, | 1311 | ...baseThread, |
packages/client-runtime/src/state/threadReducer.ts+42-10| ... | @@ -12,6 +12,8 @@ import type { | ... | @@ -12,6 +12,8 @@ import type { |
| 12 | OrchestrationThreadActivity, | 12 | OrchestrationThreadActivity, |
| 13 | TurnId, | 13 | TurnId, |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; | ||
| 16 | import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; | ||
| 15 | 17 | ||
| 16 | export type ThreadDetailReducerResult = | 18 | export type ThreadDetailReducerResult = |
| 17 | | { readonly kind: "updated"; readonly thread: OrchestrationThread } | 19 | | { readonly kind: "updated"; readonly thread: OrchestrationThread } |
| ... | @@ -551,7 +553,11 @@ export function applyThreadDetailEvent( | ... | @@ -551,7 +553,11 @@ export function applyThreadDetailEvent( |
| 551 | ); | 553 | ); |
| 552 | 554 | ||
| 553 | const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); | 555 | const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); |
| 554 | const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); | 556 | const messages = retainMessagesAfterRevert( |
| 557 | thread.messages, | ||
| 558 | retainedTurnIds, | ||
| 559 | event.payload.turnCount, | ||
| 560 | ); | ||
| 555 | const proposedPlans = pipe( | 561 | const proposedPlans = pipe( |
| 556 | thread.proposedPlans, | 562 | thread.proposedPlans, |
| 557 | Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), | 563 | Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), |
| ... | @@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage( | ... | @@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage( |
| 744 | function retainMessagesAfterRevert( | 750 | function retainMessagesAfterRevert( |
| 745 | messages: ReadonlyArray<OrchestrationMessage>, | 751 | messages: ReadonlyArray<OrchestrationMessage>, |
| 746 | retainedTurnIds: ReadonlySet<string>, | 752 | retainedTurnIds: ReadonlySet<string>, |
| 753 | turnCount: number, | ||
| 747 | ): OrchestrationMessage[] { | 754 | ): OrchestrationMessage[] { |
| 748 | // Keep messages that belong to a retained turn, plus system messages and | 755 | const retainedMessageIds = new Set<string>(); |
| 749 | // messages without a turn binding (pre-turn-0 user messages). | 756 | for (const message of messages) { |
| 750 | return Arr.filter(messages, (message) => { | 757 | if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { |
| 751 | if (message.role === "system") { | 758 | retainedMessageIds.add(message.id); |
| 752 | return true; | 759 | } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { |
| 760 | retainedMessageIds.add(message.id); | ||
| 753 | } | 761 | } |
| 754 | if (message.turnId === null) { | 762 | } |
| 755 | return true; | 763 | |
| 764 | for (const role of ["user", "assistant"] as const) { | ||
| 765 | const retainedCount = messages.filter( | ||
| 766 | (message) => | ||
| 767 | message.role === role && | ||
| 768 | !isImportedAgentSessionMessageId(message.id) && | ||
| 769 | retainedMessageIds.has(message.id), | ||
| 770 | ).length; | ||
| 771 | const missingCount = Math.max(0, turnCount - retainedCount); | ||
| 772 | const fallbackMessages = messages | ||
| 773 | .filter( | ||
| 774 | (message) => | ||
| 775 | message.role === role && | ||
| 776 | !retainedMessageIds.has(message.id) && | ||
| 777 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | ||
| 778 | ) | ||
| 779 | .toSorted( | ||
| 780 | (left, right) => | ||
| 781 | compareDateTimeStrings(left.createdAt, right.createdAt) || | ||
| 782 | left.id.localeCompare(right.id), | ||
| 783 | ) | ||
| 784 | .slice(0, missingCount); | ||
| 785 | for (const message of fallbackMessages) { | ||
| 786 | retainedMessageIds.add(message.id); | ||
| 756 | } | 787 | } |
| 757 | return retainedTurnIds.has(message.turnId); | 788 | } |
| 758 | }); | 789 | |
| 790 | return Arr.filter(messages, (message) => retainedMessageIds.has(message.id)); | ||
| 759 | } | 791 | } |
packages/contracts/src/agentSessions.ts created+98| ... | @@ -0,0 +1,98 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 3 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 4 | |||
| 5 | /** Coding agent home directories the scanner knows how to read. */ | ||
| 6 | export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); | ||
| 7 | export type AgentSessionSource = typeof AgentSessionSource.Type; | ||
| 8 | |||
| 9 | /** File identity saved with an imported session so bounded retries can skip unchanged history. */ | ||
| 10 | export const AgentSessionImportSource = Schema.Struct({ | ||
| 11 | provider: AgentSessionSource, | ||
| 12 | providerInstanceId: ProviderInstanceId, | ||
| 13 | providerSessionId: TrimmedNonEmptyString, | ||
| 14 | filePath: TrimmedNonEmptyString, | ||
| 15 | size: NonNegativeInt, | ||
| 16 | mtimeMs: Schema.NullOr(Schema.Number), | ||
| 17 | device: Schema.Number, | ||
| 18 | inode: Schema.NullOr(Schema.Number), | ||
| 19 | birthtimeMs: Schema.NullOr(Schema.Number), | ||
| 20 | }); | ||
| 21 | export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; | ||
| 22 | |||
| 23 | /** Imported message ids retain their origin after event metadata is projected into SQLite. */ | ||
| 24 | export function isImportedAgentSessionMessageId(messageId: string): boolean { | ||
| 25 | return messageId.startsWith("import:"); | ||
| 26 | } | ||
| 27 | |||
| 28 | /** | ||
| 29 | * Empty for now. Kept as a struct so future scan options (source filters, | ||
| 30 | * explicit roots) can be added without a new method. | ||
| 31 | */ | ||
| 32 | export const AgentSessionScanInput = Schema.Struct({}); | ||
| 33 | export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; | ||
| 34 | |||
| 35 | /** | ||
| 36 | * A directory that at least one agent CLI has run in, suitable for import as a | ||
| 37 | * T3 Code project. `alreadyImported` marks candidates that already have an | ||
| 38 | * active project rooted at the same path. | ||
| 39 | */ | ||
| 40 | export const AgentSessionProjectCandidate = Schema.Struct({ | ||
| 41 | path: TrimmedNonEmptyString, | ||
| 42 | title: TrimmedNonEmptyString, | ||
| 43 | projectId: Schema.optional(ProjectId), | ||
| 44 | sources: Schema.Array(AgentSessionSource), | ||
| 45 | threadCount: NonNegativeInt, | ||
| 46 | lastActiveAt: Schema.NullOr(IsoDateTime), | ||
| 47 | alreadyImported: Schema.Boolean, | ||
| 48 | }); | ||
| 49 | export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; | ||
| 50 | |||
| 51 | export const AgentSessionScanResult = Schema.Struct({ | ||
| 52 | candidates: Schema.Array(AgentSessionProjectCandidate), | ||
| 53 | scannedAt: IsoDateTime, | ||
| 54 | truncated: Schema.optional(Schema.Boolean), | ||
| 55 | }); | ||
| 56 | export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; | ||
| 57 | |||
| 58 | export const AgentSessionImportInput = Schema.Struct({ | ||
| 59 | projectId: ProjectId, | ||
| 60 | expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), | ||
| 61 | }); | ||
| 62 | export type AgentSessionImportInput = typeof AgentSessionImportInput.Type; | ||
| 63 | |||
| 64 | export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass<AgentSessionImportProjectNotFoundError>()( | ||
| 65 | "AgentSessionImportProjectNotFoundError", | ||
| 66 | { projectId: ProjectId }, | ||
| 67 | ) { | ||
| 68 | override get message(): string { | ||
| 69 | return `Project '${this.projectId}' does not exist.`; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass<AgentSessionImportProjectChangedError>()( | ||
| 74 | "AgentSessionImportProjectChangedError", | ||
| 75 | { projectId: ProjectId }, | ||
| 76 | ) { | ||
| 77 | override get message(): string { | ||
| 78 | return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | export const AgentSessionImportResult = Schema.Struct({ | ||
| 83 | importedCount: NonNegativeInt, | ||
| 84 | skippedCount: NonNegativeInt, | ||
| 85 | }); | ||
| 86 | export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; | ||
| 87 | |||
| 88 | export class AgentSessionScanError extends Schema.TaggedErrorClass<AgentSessionScanError>()( | ||
| 89 | "AgentSessionScanError", | ||
| 90 | { | ||
| 91 | operation: Schema.Literals(["read-settings", "read-projects"]), | ||
| 92 | cause: Schema.Defect(), | ||
| 93 | }, | ||
| 94 | ) { | ||
| 95 | override get message(): string { | ||
| 96 | return `Failed to scan agent sessions during ${this.operation}.`; | ||
| 97 | } | ||
| 98 | } | ||
packages/contracts/src/index.ts+1| ... | @@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts"; | ... | @@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts"; |
| 29 | export * from "./editor.ts"; | 29 | export * from "./editor.ts"; |
| 30 | export * from "./project.ts"; | 30 | export * from "./project.ts"; |
| 31 | export * from "./filesystem.ts"; | 31 | export * from "./filesystem.ts"; |
| 32 | export * from "./agentSessions.ts"; | ||
| 32 | export * from "./assets.ts"; | 33 | export * from "./assets.ts"; |
| 33 | export * from "./review.ts"; | 34 | export * from "./review.ts"; |
| 34 | export * from "./browserImport.ts"; | 35 | export * from "./browserImport.ts"; |
packages/contracts/src/orchestration.test.ts+15| ... | @@ -1135,6 +1135,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => | ... | @@ -1135,6 +1135,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => |
| 1135 | }), | 1135 | }), |
| 1136 | ); | 1136 | ); |
| 1137 | 1137 | ||
| 1138 | it.effect("rejects thread history imports without messages", () => | ||
| 1139 | Effect.gen(function* () { | ||
| 1140 | const result = yield* Effect.exit( | ||
| 1141 | decodeOrchestrationCommand({ | ||
| 1142 | type: "thread.history.import", | ||
| 1143 | commandId: "command-empty-history", | ||
| 1144 | threadId: "thread-1", | ||
| 1145 | messages: [], | ||
| 1146 | }), | ||
| 1147 | ); | ||
| 1148 | |||
| 1149 | assert.strictEqual(result._tag, "Failure"); | ||
| 1150 | }), | ||
| 1151 | ); | ||
| 1152 | |||
| 1138 | it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { | 1153 | it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { |
| 1139 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); | 1154 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); |
| 1140 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); | 1155 | assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); |
packages/contracts/src/orchestration.ts+17| ... | @@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({ | ... | @@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({ |
| 800 | branch: Schema.NullOr(TrimmedNonEmptyString), | 800 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 801 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | 801 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 802 | createdAt: IsoDateTime, | 802 | createdAt: IsoDateTime, |
| 803 | historyImport: Schema.optional(Schema.Literal(true)), | ||
| 803 | }); | 804 | }); |
| 804 | 805 | ||
| 805 | const ThreadDeleteCommand = Schema.Struct({ | 806 | const ThreadDeleteCommand = Schema.Struct({ |
| ... | @@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ | ... | @@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ |
| 1122 | createdAt: IsoDateTime, | 1123 | createdAt: IsoDateTime, |
| 1123 | }); | 1124 | }); |
| 1124 | 1125 | ||
| 1126 | const ThreadHistoryImportCommand = Schema.Struct({ | ||
| 1127 | type: Schema.Literal("thread.history.import"), | ||
| 1128 | commandId: CommandId, | ||
| 1129 | threadId: ThreadId, | ||
| 1130 | messages: Schema.Array( | ||
| 1131 | Schema.Struct({ | ||
| 1132 | messageId: MessageId, | ||
| 1133 | role: Schema.Literals(["user", "assistant"]), | ||
| 1134 | text: Schema.String, | ||
| 1135 | createdAt: IsoDateTime, | ||
| 1136 | }), | ||
| 1137 | ).check(Schema.isNonEmpty()), | ||
| 1138 | }); | ||
| 1139 | |||
| 1125 | const ThreadProposedPlanUpsertCommand = Schema.Struct({ | 1140 | const ThreadProposedPlanUpsertCommand = Schema.Struct({ |
| 1126 | type: Schema.Literal("thread.proposed-plan.upsert"), | 1141 | type: Schema.Literal("thread.proposed-plan.upsert"), |
| 1127 | commandId: CommandId, | 1142 | commandId: CommandId, |
| ... | @@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([ | ... | @@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([ |
| 1173 | ThreadSessionSetCommand, | 1188 | ThreadSessionSetCommand, |
| 1174 | ThreadMessageAssistantDeltaCommand, | 1189 | ThreadMessageAssistantDeltaCommand, |
| 1175 | ThreadMessageAssistantCompleteCommand, | 1190 | ThreadMessageAssistantCompleteCommand, |
| 1191 | ThreadHistoryImportCommand, | ||
| 1176 | ThreadProposedPlanUpsertCommand, | 1192 | ThreadProposedPlanUpsertCommand, |
| 1177 | ThreadTurnDiffCompleteCommand, | 1193 | ThreadTurnDiffCompleteCommand, |
| 1178 | ThreadActivityAppendCommand, | 1194 | ThreadActivityAppendCommand, |
| ... | @@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ | ... | @@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ |
| 1473 | adapterKey: Schema.optional(TrimmedNonEmptyString), | 1489 | adapterKey: Schema.optional(TrimmedNonEmptyString), |
| 1474 | requestId: Schema.optional(ApprovalRequestId), | 1490 | requestId: Schema.optional(ApprovalRequestId), |
| 1475 | ingestedAt: Schema.optional(IsoDateTime), | 1491 | ingestedAt: Schema.optional(IsoDateTime), |
| 1492 | historyImport: Schema.optional(Schema.Boolean), | ||
| 1476 | origin: Schema.optional(OrchestrationClientOrigin), | 1493 | origin: Schema.optional(OrchestrationClientOrigin), |
| 1477 | }); | 1494 | }); |
| 1478 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; | 1495 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; |
packages/contracts/src/rpc.ts+30| ... | @@ -28,6 +28,15 @@ import { | ... | @@ -28,6 +28,15 @@ import { |
| 28 | FilesystemBrowseResult, | 28 | FilesystemBrowseResult, |
| 29 | FilesystemBrowseError, | 29 | FilesystemBrowseError, |
| 30 | } from "./filesystem.ts"; | 30 | } from "./filesystem.ts"; |
| 31 | import { | ||
| 32 | AgentSessionImportInput, | ||
| 33 | AgentSessionImportProjectChangedError, | ||
| 34 | AgentSessionImportProjectNotFoundError, | ||
| 35 | AgentSessionImportResult, | ||
| 36 | AgentSessionScanInput, | ||
| 37 | AgentSessionScanResult, | ||
| 38 | AgentSessionScanError, | ||
| 39 | } from "./agentSessions.ts"; | ||
| 31 | import { | 40 | import { |
| 32 | AssetAccessError, | 41 | AssetAccessError, |
| 33 | AssetCreateUrlInput, | 42 | AssetCreateUrlInput, |
| ... | @@ -240,6 +249,8 @@ export const WS_METHODS = { | ... | @@ -240,6 +249,8 @@ export const WS_METHODS = { |
| 240 | 249 | ||
| 241 | // Filesystem methods | 250 | // Filesystem methods |
| 242 | filesystemBrowse: "filesystem.browse", | 251 | filesystemBrowse: "filesystem.browse", |
| 252 | agentSessionsScan: "agentSessions.scan", | ||
| 253 | agentSessionsImport: "agentSessions.import", | ||
| 243 | assetsCreateUrl: "assets.createUrl", | 254 | assetsCreateUrl: "assets.createUrl", |
| 244 | attachmentsCreateUploadUrl: "attachments.createUploadUrl", | 255 | attachmentsCreateUploadUrl: "attachments.createUploadUrl", |
| 245 | attachmentsDelete: "attachments.delete", | 256 | attachmentsDelete: "attachments.delete", |
| ... | @@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { | ... | @@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 823 | error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), | 834 | error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), |
| 824 | }); | 835 | }); |
| 825 | 836 | ||
| 837 | export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { | ||
| 838 | payload: AgentSessionScanInput, | ||
| 839 | success: AgentSessionScanResult, | ||
| 840 | error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), | ||
| 841 | }); | ||
| 842 | |||
| 843 | export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { | ||
| 844 | payload: AgentSessionImportInput, | ||
| 845 | success: AgentSessionImportResult, | ||
| 846 | error: Schema.Union([ | ||
| 847 | AgentSessionImportProjectChangedError, | ||
| 848 | AgentSessionImportProjectNotFoundError, | ||
| 849 | AgentSessionScanError, | ||
| 850 | EnvironmentAuthorizationError, | ||
| 851 | ]), | ||
| 852 | }); | ||
| 853 | |||
| 826 | export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { | 854 | export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { |
| 827 | payload: AssetCreateUrlInput, | 855 | payload: AssetCreateUrlInput, |
| 828 | success: AssetCreateUrlResult, | 856 | success: AssetCreateUrlResult, |
| ... | @@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1241 | WsProjectsWriteFileRpc, | 1269 | WsProjectsWriteFileRpc, |
| 1242 | WsShellOpenInEditorRpc, | 1270 | WsShellOpenInEditorRpc, |
| 1243 | WsFilesystemBrowseRpc, | 1271 | WsFilesystemBrowseRpc, |
| 1272 | WsAgentSessionsScanRpc, | ||
| 1273 | WsAgentSessionsImportRpc, | ||
| 1244 | WsAssetsCreateUrlRpc, | 1274 | WsAssetsCreateUrlRpc, |
| 1245 | WsAttachmentsCreateUploadUrlRpc, | 1275 | WsAttachmentsCreateUploadUrlRpc, |
| 1246 | WsAttachmentsDeleteRpc, | 1276 | WsAttachmentsDeleteRpc, |
packages/contracts/src/server.ts+3| ... | @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ | ... | @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ |
| 748 | environment: ExecutionEnvironmentDescriptor, | 748 | environment: ExecutionEnvironmentDescriptor, |
| 749 | cwd: TrimmedNonEmptyString, | 749 | cwd: TrimmedNonEmptyString, |
| 750 | projectName: TrimmedNonEmptyString, | 750 | projectName: TrimmedNonEmptyString, |
| 751 | bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])), | ||
| 751 | bootstrapProjectId: Schema.optional(ProjectId), | 752 | bootstrapProjectId: Schema.optional(ProjectId), |
| 752 | bootstrapThreadId: Schema.optional(ThreadId), | 753 | bootstrapThreadId: Schema.optional(ThreadId), |
| 754 | bootstrapProjectCreated: Schema.optional(Schema.Boolean), | ||
| 755 | bootstrapThreadCreated: Schema.optional(Schema.Boolean), | ||
| 753 | }); | 756 | }); |
| 754 | export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; | 757 | export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; |
| 755 | 758 |
packages/contracts/src/settings.ts+8| ... | @@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 286 | // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); | 286 | // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); |
| 287 | // disabling restores the platform's heavier default. No effect off macOS. | 287 | // disabling restores the platform's heavier default. No effect off macOS. |
| 288 | fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 288 | fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 289 | // When the first-run welcome wizard finished (or was skipped), as an ISO | ||
| 290 | // timestamp. `null` alone does not mean "show the wizard" — every install | ||
| 291 | // that predates this field decodes to `null` — so the gate also requires an | ||
| 292 | // empty workspace before it treats the client as a fresh install. | ||
| 293 | onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( | ||
| 294 | Schema.withDecodingDefault(Effect.succeed(null)), | ||
| 295 | ), | ||
| 289 | // Model favorites. Historically keyed by provider kind, now | 296 | // Model favorites. Historically keyed by provider kind, now |
| 290 | // widened to `ProviderInstanceId` so users can favorite a specific model | 297 | // widened to `ProviderInstanceId` so users can favorite a specific model |
| 291 | // on a custom provider instance (e.g. "Codex Personal · gpt-5") without | 298 | // on a custom provider instance (e.g. "Codex Personal · gpt-5") without |
| ... | @@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1188 | diffLayout: Schema.optionalKey(DiffLayout), | 1195 | diffLayout: Schema.optionalKey(DiffLayout), |
| 1189 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), | 1196 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 1190 | glassOpacity: Schema.optionalKey(GlassOpacity), | 1197 | glassOpacity: Schema.optionalKey(GlassOpacity), |
| 1198 | onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 1191 | fontSizeInterface: Schema.optionalKey(InterfaceFontSize), | 1199 | fontSizeInterface: Schema.optionalKey(InterfaceFontSize), |
| 1192 | fontSizePrompt: Schema.optionalKey(PromptFontSize), | 1200 | fontSizePrompt: Schema.optionalKey(PromptFontSize), |
| 1193 | fontSizeCode: Schema.optionalKey(CodeFontSize), | 1201 | fontSizeCode: Schema.optionalKey(CodeFontSize), |
packages/contracts/src/terminal.test.ts+43| ... | @@ -7,12 +7,18 @@ import { | ... | @@ -7,12 +7,18 @@ import { |
| 7 | TerminalClearInput, | 7 | TerminalClearInput, |
| 8 | TerminalCloseInput, | 8 | TerminalCloseInput, |
| 9 | TerminalEvent, | 9 | TerminalEvent, |
| 10 | TerminalError, | ||
| 10 | TerminalOpenInput, | 11 | TerminalOpenInput, |
| 12 | TerminalProviderEnvironmentError, | ||
| 11 | TerminalResizeInput, | 13 | TerminalResizeInput, |
| 12 | TerminalSessionSnapshot, | 14 | TerminalSessionSnapshot, |
| 13 | TerminalThreadInput, | 15 | TerminalThreadInput, |
| 14 | TerminalWriteInput, | 16 | TerminalWriteInput, |
| 15 | } from "./terminal.ts"; | 17 | } from "./terminal.ts"; |
| 18 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 19 | |||
| 20 | const encodeTerminalError = Schema.encodeUnknownSync(TerminalError); | ||
| 21 | const decodeTerminalError = Schema.decodeUnknownSync(TerminalError); | ||
| 16 | 22 | ||
| 17 | function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> { | 23 | function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> { |
| 18 | return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>; | 24 | return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>; |
| ... | @@ -27,6 +33,28 @@ function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean { | ... | @@ -27,6 +33,28 @@ function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean { |
| 27 | } | 33 | } |
| 28 | } | 34 | } |
| 29 | 35 | ||
| 36 | describe("TerminalProviderEnvironmentError", () => { | ||
| 37 | it("round-trips its required cause without exposing it in the message", () => { | ||
| 38 | const cause = { operation: "read-secret", detail: "secret backend unavailable" }; | ||
| 39 | const error = new TerminalProviderEnvironmentError({ | ||
| 40 | providerInstanceId: ProviderInstanceId.make("codex_work"), | ||
| 41 | cause, | ||
| 42 | }); | ||
| 43 | const encoded = encodeTerminalError(error); | ||
| 44 | const decoded = decodeTerminalError(encoded); | ||
| 45 | |||
| 46 | expect(decoded).toMatchObject({ | ||
| 47 | _tag: "TerminalProviderEnvironmentError", | ||
| 48 | providerInstanceId: "codex_work", | ||
| 49 | cause, | ||
| 50 | }); | ||
| 51 | expect(decoded.message).toBe( | ||
| 52 | "Could not prepare the terminal environment for provider instance: codex_work", | ||
| 53 | ); | ||
| 54 | expect(decoded.message).not.toContain("secret backend unavailable"); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 30 | describe("TerminalOpenInput", () => { | 58 | describe("TerminalOpenInput", () => { |
| 31 | it("accepts valid open input", () => { | 59 | it("accepts valid open input", () => { |
| 32 | expect( | 60 | expect( |
| ... | @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { | ... | @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { |
| 87 | T3CODE_PROJECT_ROOT: "/tmp/project", | 115 | T3CODE_PROJECT_ROOT: "/tmp/project", |
| 88 | CUSTOM_FLAG: "1", | 116 | CUSTOM_FLAG: "1", |
| 89 | }, | 117 | }, |
| 118 | providerInstanceId: "codex_work", | ||
| 90 | }); | 119 | }); |
| 91 | expect(parsed.env).toMatchObject({ | 120 | expect(parsed.env).toMatchObject({ |
| 92 | T3CODE_PROJECT_ROOT: "/tmp/project", | 121 | T3CODE_PROJECT_ROOT: "/tmp/project", |
| 93 | CUSTOM_FLAG: "1", | 122 | CUSTOM_FLAG: "1", |
| 94 | }); | 123 | }); |
| 95 | expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); | 124 | expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); |
| 125 | expect(parsed.providerInstanceId).toBe("codex_work"); | ||
| 96 | }); | 126 | }); |
| 97 | 127 | ||
| 98 | it("rejects invalid env keys", () => { | 128 | it("rejects invalid env keys", () => { |
| ... | @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { | ... | @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { |
| 108 | }), | 138 | }), |
| 109 | ).toBe(false); | 139 | ).toBe(false); |
| 110 | }); | 140 | }); |
| 141 | |||
| 142 | it("rejects invalid provider instance ids", () => { | ||
| 143 | for (const providerInstanceId of ["", "1invalid", "invalid id"]) { | ||
| 144 | expect( | ||
| 145 | decodes(TerminalOpenInput, { | ||
| 146 | threadId: "thread-1", | ||
| 147 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 148 | cwd: "/tmp/project", | ||
| 149 | providerInstanceId, | ||
| 150 | }), | ||
| 151 | ).toBe(false); | ||
| 152 | } | ||
| 153 | }); | ||
| 111 | }); | 154 | }); |
| 112 | 155 | ||
| 113 | describe("TerminalAttachInput", () => { | 156 | describe("TerminalAttachInput", () => { |
packages/contracts/src/terminal.ts+32-3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 3 | 4 | ||
| 4 | /** | 5 | /** |
| 5 | * Client-side id for the first shell opened on a thread. Ids are uniformly | 6 | * Client-side id for the first shell opened on a thread. Ids are uniformly |
| ... | @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ | ... | @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ |
| 43 | cols: Schema.optional(TerminalColsSchema), | 44 | cols: Schema.optional(TerminalColsSchema), |
| 44 | rows: Schema.optional(TerminalRowsSchema), | 45 | rows: Schema.optional(TerminalRowsSchema), |
| 45 | env: Schema.optional(TerminalEnvSchema), | 46 | env: Schema.optional(TerminalEnvSchema), |
| 47 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 46 | }); | 48 | }); |
| 47 | export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>; | 49 | export type TerminalOpenInput = typeof TerminalOpenInput.Type; |
| 48 | 50 | ||
| 49 | export const TerminalAttachInput = Schema.Struct({ | 51 | export const TerminalAttachInput = Schema.Struct({ |
| 50 | ...TerminalSessionInput.fields, | 52 | ...TerminalSessionInput.fields, |
| ... | @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ | ... | @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ |
| 53 | cols: Schema.optional(TerminalColsSchema), | 55 | cols: Schema.optional(TerminalColsSchema), |
| 54 | rows: Schema.optional(TerminalRowsSchema), | 56 | rows: Schema.optional(TerminalRowsSchema), |
| 55 | env: Schema.optional(TerminalEnvSchema), | 57 | env: Schema.optional(TerminalEnvSchema), |
| 58 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 56 | restartIfNotRunning: Schema.optional(Schema.Boolean), | 59 | restartIfNotRunning: Schema.optional(Schema.Boolean), |
| 57 | }); | 60 | }); |
| 58 | export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>; | 61 | export type TerminalAttachInput = typeof TerminalAttachInput.Type; |
| 59 | 62 | ||
| 60 | export const TerminalWriteInput = Schema.Struct({ | 63 | export const TerminalWriteInput = Schema.Struct({ |
| 61 | ...TerminalSessionInput.fields, | 64 | ...TerminalSessionInput.fields, |
| ... | @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ | ... | @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ |
| 80 | cols: TerminalColsSchema, | 83 | cols: TerminalColsSchema, |
| 81 | rows: TerminalRowsSchema, | 84 | rows: TerminalRowsSchema, |
| 82 | env: Schema.optional(TerminalEnvSchema), | 85 | env: Schema.optional(TerminalEnvSchema), |
| 86 | providerInstanceId: Schema.optional(ProviderInstanceId), | ||
| 83 | }); | 87 | }); |
| 84 | export type TerminalRestartInput = Schema.Codec.Encoded<typeof TerminalRestartInput>; | 88 | export type TerminalRestartInput = typeof TerminalRestartInput.Type; |
| 85 | 89 | ||
| 86 | export const TerminalCloseInput = Schema.Struct({ | 90 | export const TerminalCloseInput = Schema.Struct({ |
| 87 | ...TerminalThreadInput.fields, | 91 | ...TerminalThreadInput.fields, |
| ... | @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass<Terminal | ... | @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass<Terminal |
| 299 | } | 303 | } |
| 300 | } | 304 | } |
| 301 | 305 | ||
| 306 | export class TerminalProviderInstanceNotFoundError extends Schema.TaggedErrorClass<TerminalProviderInstanceNotFoundError>()( | ||
| 307 | "TerminalProviderInstanceNotFoundError", | ||
| 308 | { | ||
| 309 | providerInstanceId: ProviderInstanceId, | ||
| 310 | }, | ||
| 311 | ) { | ||
| 312 | override get message() { | ||
| 313 | return `Provider instance is not available: ${this.providerInstanceId}`; | ||
| 314 | } | ||
| 315 | } | ||
| 316 | |||
| 317 | export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass<TerminalProviderEnvironmentError>()( | ||
| 318 | "TerminalProviderEnvironmentError", | ||
| 319 | { | ||
| 320 | providerInstanceId: ProviderInstanceId, | ||
| 321 | cause: Schema.Defect(), | ||
| 322 | }, | ||
| 323 | ) { | ||
| 324 | override get message() { | ||
| 325 | return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`; | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 302 | export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()( | 329 | export class TerminalNotRunningError extends Schema.TaggedErrorClass<TerminalNotRunningError>()( |
| 303 | "TerminalNotRunningError", | 330 | "TerminalNotRunningError", |
| 304 | { | 331 | { |
| ... | @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ | ... | @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ |
| 345 | TerminalCwdError, | 372 | TerminalCwdError, |
| 346 | TerminalHistoryError, | 373 | TerminalHistoryError, |
| 347 | TerminalSessionLookupError, | 374 | TerminalSessionLookupError, |
| 375 | TerminalProviderInstanceNotFoundError, | ||
| 376 | TerminalProviderEnvironmentError, | ||
| 348 | TerminalNotRunningError, | 377 | TerminalNotRunningError, |
| 349 | TerminalWriteError, | 378 | TerminalWriteError, |
| 350 | TerminalResizeError, | 379 | TerminalResizeError, |
packages/shared/package.json+4| ... | @@ -270,6 +270,10 @@ | ... | @@ -270,6 +270,10 @@ |
| 270 | "./hostClassification": { | 270 | "./hostClassification": { |
| 271 | "types": "./src/hostClassification.ts", | 271 | "types": "./src/hostClassification.ts", |
| 272 | "import": "./src/hostClassification.ts" | 272 | "import": "./src/hostClassification.ts" |
| 273 | }, | ||
| 274 | "./dateTime": { | ||
| 275 | "types": "./src/dateTime.ts", | ||
| 276 | "import": "./src/dateTime.ts" | ||
| 273 | } | 277 | } |
| 274 | }, | 278 | }, |
| 275 | "scripts": { | 279 | "scripts": { |
packages/shared/src/dateTime.test.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { compareDateTimeStrings } from "./dateTime.ts"; | ||
| 4 | |||
| 5 | describe("compareDateTimeStrings", () => { | ||
| 6 | it("compares valid date-time strings by absolute time", () => { | ||
| 7 | expect( | ||
| 8 | compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), | ||
| 9 | ).toBe(0); | ||
| 10 | expect( | ||
| 11 | compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), | ||
| 12 | ).toBeGreaterThan(0); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it.each([ | ||
| 16 | ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], | ||
| 17 | ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"], | ||
| 18 | ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], | ||
| 19 | ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"], | ||
| 20 | ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], | ||
| 21 | ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"], | ||
| 22 | ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"], | ||
| 23 | ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"], | ||
| 24 | ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"], | ||
| 25 | ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"], | ||
| 26 | ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"], | ||
| 27 | ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"], | ||
| 28 | ])("preserves equal ISO instants %s and %s", (left, right) => { | ||
| 29 | expect(compareDateTimeStrings(left, right)).toBe(0); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("sorts malformed values before valid values", () => { | ||
| 33 | expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0); | ||
| 34 | expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it.each([ | ||
| 38 | "2014-02-30", | ||
| 39 | "2014-03-02", | ||
| 40 | "2014-03-02T00:00:00", | ||
| 41 | "2014-03-02T00:00:00.000", | ||
| 42 | "03/02/2014", | ||
| 43 | "March 2, 2014", | ||
| 44 | "Sun, 02 Mar 2014 00:00:00 GMT", | ||
| 45 | "2014-03-02T00:00:00.000Z\n", | ||
| 46 | "2014-02-30T00:00:00.000Z", | ||
| 47 | "1900-02-29T00:00:00.000-07:00", | ||
| 48 | "2024-04-31T00:00:00.000+05:30", | ||
| 49 | "2024-03-02T12:00:00.000+24:00", | ||
| 50 | "2026-09-01T24:01:00Z", | ||
| 51 | "2026-09-01T24:00:01Z", | ||
| 52 | "2026-09-01T24:00:00.0001Z", | ||
| 53 | "2026-09-01T24:01Z", | ||
| 54 | "2026-09-01T25:00Z", | ||
| 55 | ])("treats %s as malformed without native date guessing", (malformed) => { | ||
| 56 | const valid = "1970-01-01T00:00:00.000Z"; | ||
| 57 | expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); | ||
| 58 | expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); | ||
| 59 | expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); | ||
| 60 | expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); | ||
| 61 | expect(compareDateTimeStrings(malformed, malformed)).toBe(0); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("uses code-unit order for malformed date-time strings", () => { | ||
| 65 | expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0); | ||
| 66 | expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("returns zero for equal malformed date-time strings", () => { | ||
| 70 | expect(compareDateTimeStrings("invalid", "invalid")).toBe(0); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("gives every permutation of mixed values the same order", () => { | ||
| 74 | const early = "2026-09-01T12:00:00.000+14:00"; | ||
| 75 | const late = "2026-09-01T00:00:00.000-12:00"; | ||
| 76 | const malformed = "2026-09-01T06:invalid"; | ||
| 77 | const expected = [malformed, early, late]; | ||
| 78 | |||
| 79 | const permutations = [ | ||
| 80 | [early, late, malformed], | ||
| 81 | [early, malformed, late], | ||
| 82 | [late, early, malformed], | ||
| 83 | [late, malformed, early], | ||
| 84 | [malformed, early, late], | ||
| 85 | [malformed, late, early], | ||
| 86 | ]; | ||
| 87 | |||
| 88 | for (const values of permutations) { | ||
| 89 | expect(values.toSorted(compareDateTimeStrings)).toEqual(expected); | ||
| 90 | } | ||
| 91 | }); | ||
| 92 | }); | ||
packages/shared/src/dateTime.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import * as DateTime from "effect/DateTime"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | const isZonedIsoDateTime = Schema.is( | ||
| 6 | Schema.String.check( | ||
| 7 | Schema.isPattern( | ||
| 8 | /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, | ||
| 9 | ), | ||
| 10 | Schema.isTrimmed(), | ||
| 11 | ), | ||
| 12 | ); | ||
| 13 | |||
| 14 | function parseTimestamp(value: string): number { | ||
| 15 | if (!isZonedIsoDateTime(value)) return Number.NaN; | ||
| 16 | |||
| 17 | // Engines can normalize invalid calendar dates instead of rejecting them. | ||
| 18 | const datePart = value.slice(0, value.indexOf("T")); | ||
| 19 | const date = DateTime.make(`${datePart}T00:00:00.000Z`); | ||
| 20 | if (Option.isNone(date)) return Number.NaN; | ||
| 21 | const parts = DateTime.toPartsUtc(date.value); | ||
| 22 | if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { | ||
| 23 | return Number.NaN; | ||
| 24 | } | ||
| 25 | return Date.parse(value); | ||
| 26 | } | ||
| 27 | |||
| 28 | /** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ | ||
| 29 | export function compareDateTimeStrings(left: string, right: string): number { | ||
| 30 | const leftTimestamp = parseTimestamp(left); | ||
| 31 | const rightTimestamp = parseTimestamp(right); | ||
| 32 | const leftIsValid = !Number.isNaN(leftTimestamp); | ||
| 33 | const rightIsValid = !Number.isNaN(rightTimestamp); | ||
| 34 | |||
| 35 | if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; | ||
| 36 | if (leftIsValid) return leftTimestamp - rightTimestamp; | ||
| 37 | return left < right ? -1 : left > right ? 1 : 0; | ||
| 38 | } | ||