| author | |
| committer | |
| log | ba3cb0773859334fe9f75016295bb3893c9e6044 |
| tree | 2938feb30778205e092884e266ed835c5f91e475 |
| parent | 91c8d4771ccb503a9dde65190b87db642df0a6ea |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>20 files changed, 351 insertions(+), 2 deletions(-)
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+2| ... | @@ -40,6 +40,7 @@ import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; | ... | @@ -40,6 +40,7 @@ import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; |
| 40 | import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; | 40 | import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; |
| 41 | import * as ServerSettings from "../src/serverSettings.ts"; | 41 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 42 | import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; | 42 | import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; |
| 43 | import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; | ||
| 43 | 44 | ||
| 44 | const providerInstanceId = ProviderInstanceId.make("codex"); | 45 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| 45 | const projectId = ProjectId.make("project-startup-orphan"); | 46 | const projectId = ProjectId.make("project-startup-orphan"); |
| ... | @@ -105,6 +106,7 @@ const startupDependencies = Layer.mergeAll( | ... | @@ -105,6 +106,7 @@ const startupDependencies = Layer.mergeAll( |
| 105 | }), | 106 | }), |
| 106 | ), | 107 | ), |
| 107 | AnalyticsService.layerTest, | 108 | AnalyticsService.layerTest, |
| 109 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | ||
| 108 | Layer.succeed(ProviderService.ProviderService, { | 110 | Layer.succeed(ProviderService.ProviderService, { |
| 109 | startSession: () => Effect.die("unused"), | 111 | startSession: () => Effect.die("unused"), |
| 110 | sendTurn: () => Effect.die("unused"), | 112 | sendTurn: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2| ... | @@ -515,6 +515,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -515,6 +515,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 515 | workspaceRoot: event.payload.workspaceRoot, | 515 | workspaceRoot: event.payload.workspaceRoot, |
| 516 | defaultModelSelection: event.payload.defaultModelSelection, | 516 | defaultModelSelection: event.payload.defaultModelSelection, |
| 517 | defaultThreadEnvMode: null, | 517 | defaultThreadEnvMode: null, |
| 518 | autoPull: false, | ||
| 518 | faviconPath: event.payload.faviconPath ?? null, | 519 | faviconPath: event.payload.faviconPath ?? null, |
| 519 | scripts: event.payload.scripts, | 520 | scripts: event.payload.scripts, |
| 520 | createdAt: event.payload.createdAt, | 521 | createdAt: event.payload.createdAt, |
| ... | @@ -542,6 +543,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -542,6 +543,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 542 | ...(event.payload.defaultThreadEnvMode !== undefined | 543 | ...(event.payload.defaultThreadEnvMode !== undefined |
| 543 | ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } | 544 | ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } |
| 544 | : {}), | 545 | : {}), |
| 546 | ...(event.payload.autoPull !== undefined ? { autoPull: event.payload.autoPull } : {}), | ||
| 545 | ...(event.payload.faviconPath !== undefined | 547 | ...(event.payload.faviconPath !== undefined |
| 546 | ? { faviconPath: event.payload.faviconPath } | 548 | ? { faviconPath: event.payload.faviconPath } |
| 547 | : {}), | 549 | : {}), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | @@ -278,6 +278,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -278,6 +278,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 278 | instanceId: ProviderInstanceId.make("codex"), | 278 | instanceId: ProviderInstanceId.make("codex"), |
| 279 | model: "gpt-5-codex", | 279 | model: "gpt-5-codex", |
| 280 | }, | 280 | }, |
| 281 | autoPull: false, | ||
| 281 | faviconPath: null, | 282 | faviconPath: null, |
| 282 | scripts: [ | 283 | scripts: [ |
| 283 | { | 284 | { |
| ... | @@ -405,6 +406,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -405,6 +406,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 405 | instanceId: ProviderInstanceId.make("codex"), | 406 | instanceId: ProviderInstanceId.make("codex"), |
| 406 | model: "gpt-5-codex", | 407 | model: "gpt-5-codex", |
| 407 | }, | 408 | }, |
| 409 | autoPull: false, | ||
| 408 | faviconPath: null, | 410 | faviconPath: null, |
| 409 | scripts: [ | 411 | scripts: [ |
| 410 | { | 412 | { |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+8| ... | @@ -83,6 +83,7 @@ const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; | ... | @@ -83,6 +83,7 @@ const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; |
| 83 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | 83 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 84 | Struct.assign({ | 84 | Struct.assign({ |
| 85 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | 85 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 86 | autoPull: Schema.Number, | ||
| 86 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | 87 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 87 | }), | 88 | }), |
| 88 | ); | 89 | ); |
| ... | @@ -347,6 +348,7 @@ function mapProjectShellRow( | ... | @@ -347,6 +348,7 @@ function mapProjectShellRow( |
| 347 | repositoryIdentity, | 348 | repositoryIdentity, |
| 348 | defaultModelSelection: row.defaultModelSelection, | 349 | defaultModelSelection: row.defaultModelSelection, |
| 349 | defaultThreadEnvMode: row.defaultThreadEnvMode, | 350 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 351 | autoPull: row.autoPull === 1, | ||
| 350 | faviconPath: row.faviconPath ?? null, | 352 | faviconPath: row.faviconPath ?? null, |
| 351 | scripts: row.scripts, | 353 | scripts: row.scripts, |
| 352 | createdAt: row.createdAt, | 354 | createdAt: row.createdAt, |
| ... | @@ -439,6 +441,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -439,6 +441,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 439 | workspace_root AS "workspaceRoot", | 441 | workspace_root AS "workspaceRoot", |
| 440 | default_model_selection_json AS "defaultModelSelection", | 442 | default_model_selection_json AS "defaultModelSelection", |
| 441 | default_thread_env_mode AS "defaultThreadEnvMode", | 443 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 444 | auto_pull AS "autoPull", | ||
| 442 | favicon_path AS "faviconPath", | 445 | favicon_path AS "faviconPath", |
| 443 | scripts_json AS "scripts", | 446 | scripts_json AS "scripts", |
| 444 | created_at AS "createdAt", | 447 | created_at AS "createdAt", |
| ... | @@ -914,6 +917,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -914,6 +917,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 914 | workspace_root AS "workspaceRoot", | 917 | workspace_root AS "workspaceRoot", |
| 915 | default_model_selection_json AS "defaultModelSelection", | 918 | default_model_selection_json AS "defaultModelSelection", |
| 916 | default_thread_env_mode AS "defaultThreadEnvMode", | 919 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 920 | auto_pull AS "autoPull", | ||
| 917 | favicon_path AS "faviconPath", | 921 | favicon_path AS "faviconPath", |
| 918 | scripts_json AS "scripts", | 922 | scripts_json AS "scripts", |
| 919 | created_at AS "createdAt", | 923 | created_at AS "createdAt", |
| ... | @@ -938,6 +942,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -938,6 +942,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 938 | workspace_root AS "workspaceRoot", | 942 | workspace_root AS "workspaceRoot", |
| 939 | default_model_selection_json AS "defaultModelSelection", | 943 | default_model_selection_json AS "defaultModelSelection", |
| 940 | default_thread_env_mode AS "defaultThreadEnvMode", | 944 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 945 | auto_pull AS "autoPull", | ||
| 941 | favicon_path AS "faviconPath", | 946 | favicon_path AS "faviconPath", |
| 942 | scripts_json AS "scripts", | 947 | scripts_json AS "scripts", |
| 943 | created_at AS "createdAt", | 948 | created_at AS "createdAt", |
| ... | @@ -1878,6 +1883,7 @@ pending_approval_requests AS ( | ... | @@ -1878,6 +1883,7 @@ pending_approval_requests AS ( |
| 1878 | repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, | 1883 | repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, |
| 1879 | defaultModelSelection: row.defaultModelSelection, | 1884 | defaultModelSelection: row.defaultModelSelection, |
| 1880 | defaultThreadEnvMode: row.defaultThreadEnvMode, | 1885 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 1886 | autoPull: row.autoPull === 1, | ||
| 1881 | faviconPath: row.faviconPath ?? null, | 1887 | faviconPath: row.faviconPath ?? null, |
| 1882 | scripts: row.scripts, | 1888 | scripts: row.scripts, |
| 1883 | createdAt: row.createdAt, | 1889 | createdAt: row.createdAt, |
| ... | @@ -2013,6 +2019,7 @@ pending_approval_requests AS ( | ... | @@ -2013,6 +2019,7 @@ pending_approval_requests AS ( |
| 2013 | workspaceRoot: row.workspaceRoot, | 2019 | workspaceRoot: row.workspaceRoot, |
| 2014 | defaultModelSelection: row.defaultModelSelection, | 2020 | defaultModelSelection: row.defaultModelSelection, |
| 2015 | defaultThreadEnvMode: row.defaultThreadEnvMode, | 2021 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 2022 | autoPull: row.autoPull === 1, | ||
| 2016 | faviconPath: row.faviconPath ?? null, | 2023 | faviconPath: row.faviconPath ?? null, |
| 2017 | scripts: row.scripts, | 2024 | scripts: row.scripts, |
| 2018 | createdAt: row.createdAt, | 2025 | createdAt: row.createdAt, |
| ... | @@ -2534,6 +2541,7 @@ pending_approval_requests AS ( | ... | @@ -2534,6 +2541,7 @@ pending_approval_requests AS ( |
| 2534 | repositoryIdentity, | 2541 | repositoryIdentity, |
| 2535 | defaultModelSelection: option.value.defaultModelSelection, | 2542 | defaultModelSelection: option.value.defaultModelSelection, |
| 2536 | defaultThreadEnvMode: option.value.defaultThreadEnvMode, | 2543 | defaultThreadEnvMode: option.value.defaultThreadEnvMode, |
| 2544 | autoPull: option.value.autoPull === 1, | ||
| 2537 | faviconPath: option.value.faviconPath ?? null, | 2545 | faviconPath: option.value.faviconPath ?? null, |
| 2538 | scripts: option.value.scripts, | 2546 | scripts: option.value.scripts, |
| 2539 | createdAt: option.value.createdAt, | 2547 | createdAt: option.value.createdAt, |
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts+22| ... | @@ -153,4 +153,26 @@ it.layer(NodeServices.layer)("decider project defaults", (it) => { | ... | @@ -153,4 +153,26 @@ it.layer(NodeServices.layer)("decider project defaults", (it) => { |
| 153 | expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); | 153 | expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); |
| 154 | }), | 154 | }), |
| 155 | ); | 155 | ); |
| 156 | |||
| 157 | it.effect("propagates autoPull through meta.update into the read model", () => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); | ||
| 160 | expect(readModel.projects[0]?.autoPull).toBe(false); | ||
| 161 | |||
| 162 | const result = yield* decideOrchestrationCommand({ | ||
| 163 | command: { | ||
| 164 | type: "project.meta.update", | ||
| 165 | commandId: CommandId.make("cmd-project-auto-pull"), | ||
| 166 | projectId, | ||
| 167 | autoPull: true, | ||
| 168 | }, | ||
| 169 | readModel, | ||
| 170 | }); | ||
| 171 | const event = Array.isArray(result) ? result[0] : result; | ||
| 172 | expect((event.payload as { autoPull?: unknown }).autoPull).toBe(true); | ||
| 173 | |||
| 174 | const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); | ||
| 175 | expect(updated.projects[0]?.autoPull).toBe(true); | ||
| 176 | }), | ||
| 177 | ); | ||
| 156 | }); | 178 | }); |
apps/server/src/orchestration/decider.ts+1| ... | @@ -264,6 +264,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -264,6 +264,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 264 | ...(command.defaultThreadEnvMode !== undefined | 264 | ...(command.defaultThreadEnvMode !== undefined |
| 265 | ? { defaultThreadEnvMode: command.defaultThreadEnvMode } | 265 | ? { defaultThreadEnvMode: command.defaultThreadEnvMode } |
| 266 | : {}), | 266 | : {}), |
| 267 | ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), | ||
| 267 | ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), | 268 | ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), |
| 268 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), | 269 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), |
| 269 | updatedAt: occurredAt, | 270 | updatedAt: occurredAt, |
apps/server/src/orchestration/projector.ts+2| ... | @@ -215,6 +215,7 @@ export function projectEvent( | ... | @@ -215,6 +215,7 @@ export function projectEvent( |
| 215 | workspaceRoot: payload.workspaceRoot, | 215 | workspaceRoot: payload.workspaceRoot, |
| 216 | defaultModelSelection: payload.defaultModelSelection, | 216 | defaultModelSelection: payload.defaultModelSelection, |
| 217 | defaultThreadEnvMode: null, | 217 | defaultThreadEnvMode: null, |
| 218 | autoPull: false, | ||
| 218 | faviconPath: payload.faviconPath ?? null, | 219 | faviconPath: payload.faviconPath ?? null, |
| 219 | scripts: payload.scripts, | 220 | scripts: payload.scripts, |
| 220 | createdAt: payload.createdAt, | 221 | createdAt: payload.createdAt, |
| ... | @@ -251,6 +252,7 @@ export function projectEvent( | ... | @@ -251,6 +252,7 @@ export function projectEvent( |
| 251 | ...(payload.defaultThreadEnvMode !== undefined | 252 | ...(payload.defaultThreadEnvMode !== undefined |
| 252 | ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } | 253 | ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } |
| 253 | : {}), | 254 | : {}), |
| 255 | ...(payload.autoPull !== undefined ? { autoPull: payload.autoPull } : {}), | ||
| 254 | ...(payload.faviconPath !== undefined | 256 | ...(payload.faviconPath !== undefined |
| 255 | ? { faviconPath: payload.faviconPath } | 257 | ? { faviconPath: payload.faviconPath } |
| 256 | : {}), | 258 | : {}), |
apps/server/src/persistence/Layers/ProjectionProjects.ts+9| ... | @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; | ... | @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 6 | import * as Struct from "effect/Struct"; | 7 | import * as Struct from "effect/Struct"; |
| 7 | 8 | ||
| ... | @@ -18,6 +19,7 @@ import { | ... | @@ -18,6 +19,7 @@ import { |
| 18 | const ProjectionProjectDbRow = ProjectionProject.mapFields( | 19 | const ProjectionProjectDbRow = ProjectionProject.mapFields( |
| 19 | Struct.assign({ | 20 | Struct.assign({ |
| 20 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | 21 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 22 | autoPull: Schema.Number, | ||
| 21 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | 23 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 22 | }), | 24 | }), |
| 23 | ); | 25 | ); |
| ... | @@ -36,6 +38,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -36,6 +38,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 36 | workspace_root, | 38 | workspace_root, |
| 37 | default_model_selection_json, | 39 | default_model_selection_json, |
| 38 | default_thread_env_mode, | 40 | default_thread_env_mode, |
| 41 | auto_pull, | ||
| 39 | favicon_path, | 42 | favicon_path, |
| 40 | scripts_json, | 43 | scripts_json, |
| 41 | created_at, | 44 | created_at, |
| ... | @@ -48,6 +51,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -48,6 +51,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 48 | ${row.workspaceRoot}, | 51 | ${row.workspaceRoot}, |
| 49 | ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, | 52 | ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, |
| 50 | ${row.defaultThreadEnvMode}, | 53 | ${row.defaultThreadEnvMode}, |
| 54 | ${row.autoPull ? 1 : 0}, | ||
| 51 | ${row.faviconPath ?? null}, | 55 | ${row.faviconPath ?? null}, |
| 52 | ${JSON.stringify(row.scripts)}, | 56 | ${JSON.stringify(row.scripts)}, |
| 53 | ${row.createdAt}, | 57 | ${row.createdAt}, |
| ... | @@ -60,6 +64,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -60,6 +64,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 60 | workspace_root = excluded.workspace_root, | 64 | workspace_root = excluded.workspace_root, |
| 61 | default_model_selection_json = excluded.default_model_selection_json, | 65 | default_model_selection_json = excluded.default_model_selection_json, |
| 62 | default_thread_env_mode = excluded.default_thread_env_mode, | 66 | default_thread_env_mode = excluded.default_thread_env_mode, |
| 67 | auto_pull = excluded.auto_pull, | ||
| 63 | favicon_path = excluded.favicon_path, | 68 | favicon_path = excluded.favicon_path, |
| 64 | scripts_json = excluded.scripts_json, | 69 | scripts_json = excluded.scripts_json, |
| 65 | created_at = excluded.created_at, | 70 | created_at = excluded.created_at, |
| ... | @@ -79,6 +84,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -79,6 +84,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 79 | workspace_root AS "workspaceRoot", | 84 | workspace_root AS "workspaceRoot", |
| 80 | default_model_selection_json AS "defaultModelSelection", | 85 | default_model_selection_json AS "defaultModelSelection", |
| 81 | default_thread_env_mode AS "defaultThreadEnvMode", | 86 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 87 | auto_pull AS "autoPull", | ||
| 82 | favicon_path AS "faviconPath", | 88 | favicon_path AS "faviconPath", |
| 83 | scripts_json AS "scripts", | 89 | scripts_json AS "scripts", |
| 84 | created_at AS "createdAt", | 90 | created_at AS "createdAt", |
| ... | @@ -100,6 +106,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -100,6 +106,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 100 | workspace_root AS "workspaceRoot", | 106 | workspace_root AS "workspaceRoot", |
| 101 | default_model_selection_json AS "defaultModelSelection", | 107 | default_model_selection_json AS "defaultModelSelection", |
| 102 | default_thread_env_mode AS "defaultThreadEnvMode", | 108 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 109 | auto_pull AS "autoPull", | ||
| 103 | favicon_path AS "faviconPath", | 110 | favicon_path AS "faviconPath", |
| 104 | scripts_json AS "scripts", | 111 | scripts_json AS "scripts", |
| 105 | created_at AS "createdAt", | 112 | created_at AS "createdAt", |
| ... | @@ -126,11 +133,13 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -126,11 +133,13 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 126 | 133 | ||
| 127 | const getById: ProjectionProjectRepositoryShape["getById"] = (input) => | 134 | const getById: ProjectionProjectRepositoryShape["getById"] = (input) => |
| 128 | getProjectionProjectRow(input).pipe( | 135 | getProjectionProjectRow(input).pipe( |
| 136 | Effect.map(Option.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))), | ||
| 129 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), | 137 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), |
| 130 | ); | 138 | ); |
| 131 | 139 | ||
| 132 | const listAll: ProjectionProjectRepositoryShape["listAll"] = () => | 140 | const listAll: ProjectionProjectRepositoryShape["listAll"] = () => |
| 133 | listProjectionProjectRows().pipe( | 141 | listProjectionProjectRows().pipe( |
| 142 | Effect.map((rows) => rows.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))), | ||
| 134 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), | 143 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), |
| 135 | ); | 144 | ); |
| 136 | 145 |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1| ... | @@ -34,6 +34,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -34,6 +34,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 34 | model: "gpt-5.4", | 34 | model: "gpt-5.4", |
| 35 | }, | 35 | }, |
| 36 | defaultThreadEnvMode: null, | 36 | defaultThreadEnvMode: null, |
| 37 | autoPull: false, | ||
| 37 | scripts: [], | 38 | scripts: [], |
| 38 | createdAt: "2026-03-24T00:00:00.000Z", | 39 | createdAt: "2026-03-24T00:00:00.000Z", |
| 39 | updatedAt: "2026-03-24T00:00:00.000Z", | 40 | updatedAt: "2026-03-24T00:00:00.000Z", |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -56,6 +56,7 @@ import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; | ... | @@ -56,6 +56,7 @@ import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; |
| 56 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; | 56 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; |
| 57 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; | 57 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; |
| 58 | import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; | 58 | import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; |
| 59 | import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; | ||
| 59 | 60 | ||
| 60 | /** | 61 | /** |
| 61 | * Migration loader with all migrations defined inline. | 62 | * Migration loader with all migrations defined inline. |
| ... | @@ -112,6 +113,7 @@ export const migrationEntries = [ | ... | @@ -112,6 +113,7 @@ export const migrationEntries = [ |
| 112 | [42, "ProjectionThreadLinkedPullRequest", Migration0042], | 113 | [42, "ProjectionThreadLinkedPullRequest", Migration0042], |
| 113 | [43, "ProjectionThreadsUnsettledAt", Migration0043], | 114 | [43, "ProjectionThreadsUnsettledAt", Migration0043], |
| 114 | [44, "ClearAutomaticProjectModelDefaults", Migration0044], | 115 | [44, "ClearAutomaticProjectModelDefaults", Migration0044], |
| 116 | [45, "ProjectionProjectsAutoPull", Migration0045], | ||
| 115 | ] as const; | 117 | ] as const; |
| 116 | 118 | ||
| 117 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 119 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/045_ProjectionProjectsAutoPull.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | const columns = yield* sql<{ readonly name: string }>` | ||
| 7 | PRAGMA table_info(projection_projects) | ||
| 8 | `; | ||
| 9 | |||
| 10 | if (!columns.some((column) => column.name === "auto_pull")) { | ||
| 11 | yield* sql` | ||
| 12 | ALTER TABLE projection_projects | ||
| 13 | ADD COLUMN auto_pull INTEGER NOT NULL DEFAULT 0 | ||
| 14 | `; | ||
| 15 | } | ||
| 16 | }); | ||
apps/server/src/persistence/Services/ProjectionProjects.ts+1| ... | @@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({ | ... | @@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({ |
| 26 | workspaceRoot: Schema.String, | 26 | workspaceRoot: Schema.String, |
| 27 | defaultModelSelection: Schema.NullOr(ModelSelection), | 27 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 28 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), | 28 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), |
| 29 | autoPull: Schema.Boolean, | ||
| 29 | faviconPath: Schema.optional(Schema.NullOr(Schema.String)), | 30 | faviconPath: Schema.optional(Schema.NullOr(Schema.String)), |
| 30 | scripts: Schema.Array(ProjectScript), | 31 | scripts: Schema.Array(ProjectScript), |
| 31 | createdAt: IsoDateTime, | 32 | createdAt: IsoDateTime, |
apps/server/src/server.ts+6-1| ... | @@ -339,7 +339,12 @@ const VcsLayerLive = Layer.empty.pipe( | ... | @@ -339,7 +339,12 @@ const VcsLayerLive = Layer.empty.pipe( |
| 339 | Layer.provideMerge(GitWorkflowLayerLive), | 339 | Layer.provideMerge(GitWorkflowLayerLive), |
| 340 | Layer.provideMerge(ReviewLayerLive), | 340 | Layer.provideMerge(ReviewLayerLive), |
| 341 | Layer.provideMerge(SourceControlRepositoryServiceLayerLive), | 341 | Layer.provideMerge(SourceControlRepositoryServiceLayerLive), |
| 342 | Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), | 342 | Layer.provideMerge( |
| 343 | VcsStatusBroadcaster.layer.pipe( | ||
| 344 | Layer.provide(GitWorkflowLayerLive), | ||
| 345 | Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), | ||
| 346 | ), | ||
| 347 | ), | ||
| 343 | ); | 348 | ); |
| 344 | 349 | ||
| 345 | const CheckpointingLayerLive = Layer.empty.pipe( | 350 | const CheckpointingLayerLive = Layer.empty.pipe( |
apps/server/src/serverRuntimeStartup.test.ts+40| ... | @@ -15,6 +15,7 @@ import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngi | ... | @@ -15,6 +15,7 @@ import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngi |
| 15 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 15 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 16 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | 16 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 17 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 17 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 18 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 18 | 19 | ||
| 19 | it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { | 20 | it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { |
| 20 | assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { | 21 | assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { |
| ... | @@ -23,6 +24,45 @@ it("uses the canonical Codex default for the auto-bootstrapped welcome thread", | ... | @@ -23,6 +24,45 @@ it("uses the canonical Codex default for the auto-bootstrapped welcome thread", |
| 23 | }); | 24 | }); |
| 24 | }); | 25 | }); |
| 25 | 26 | ||
| 27 | it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => | ||
| 28 | Effect.gen(function* () { | ||
| 29 | const pulled: string[] = []; | ||
| 30 | const git = { | ||
| 31 | statusDetails: (cwd: string) => | ||
| 32 | Effect.succeed({ | ||
| 33 | isRepo: true, | ||
| 34 | isDefaultBranch: cwd !== "/feature", | ||
| 35 | hasUpstream: true, | ||
| 36 | hasWorkingTreeChanges: cwd === "/dirty", | ||
| 37 | aheadCount: cwd === "/ahead" ? 1 : 0, | ||
| 38 | behindCount: cwd === "/current" ? 0 : 1, | ||
| 39 | } as never), | ||
| 40 | pullCurrentBranch: (cwd: string) => | ||
| 41 | Effect.sync(() => { | ||
| 42 | pulled.push(cwd); | ||
| 43 | return { | ||
| 44 | status: "pulled" as const, | ||
| 45 | refName: "main", | ||
| 46 | upstreamRef: "origin/main", | ||
| 47 | }; | ||
| 48 | }), | ||
| 49 | } as unknown as GitVcsDriver.GitVcsDriver["Service"]; | ||
| 50 | const project = (workspaceRoot: string, autoPull = true) => | ||
| 51 | ({ workspaceRoot, autoPull }) as never; | ||
| 52 | |||
| 53 | yield* ServerRuntimeStartup.autoPullProjects([ | ||
| 54 | project("/clean"), | ||
| 55 | project("/current"), | ||
| 56 | project("/dirty"), | ||
| 57 | project("/ahead"), | ||
| 58 | project("/feature"), | ||
| 59 | project("/disabled", false), | ||
| 60 | ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); | ||
| 61 | |||
| 62 | assert.deepStrictEqual(pulled, ["/clean"]); | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | |||
| 26 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => | 66 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => |
| 27 | Effect.scoped( | 67 | Effect.scoped( |
| 28 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
apps/server/src/serverRuntimeStartup.ts+71| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | DEFAULT_MODEL, | 3 | DEFAULT_MODEL, |
| 4 | DEFAULT_PROVIDER_INTERACTION_MODE, | 4 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 5 | type ModelSelection, | 5 | type ModelSelection, |
| 6 | type OrchestrationProjectShell, | ||
| 6 | ProjectId, | 7 | ProjectId, |
| 7 | ProviderInstanceId, | 8 | ProviderInstanceId, |
| 8 | ThreadId, | 9 | ThreadId, |
| ... | @@ -40,6 +41,7 @@ import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDi | ... | @@ -40,6 +41,7 @@ import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDi |
| 40 | import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; | 41 | import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; |
| 41 | import { forkParked } from "./serverActivation.ts"; | 42 | import { forkParked } from "./serverActivation.ts"; |
| 42 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; | 43 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; |
| 44 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 43 | import { | 45 | import { |
| 44 | formatHeadlessServeOutput, | 46 | formatHeadlessServeOutput, |
| 45 | formatHostForUrl, | 47 | formatHostForUrl, |
| ... | @@ -634,6 +636,65 @@ interface StartupOptions { | ... | @@ -634,6 +636,65 @@ interface StartupOptions { |
| 634 | readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect<void>; | 636 | readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect<void>; |
| 635 | } | 637 | } |
| 636 | 638 | ||
| 639 | export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( | ||
| 640 | projects: ReadonlyArray<OrchestrationProjectShell>, | ||
| 641 | ) { | ||
| 642 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 643 | const workspaceRoots = [ | ||
| 644 | ...new Set( | ||
| 645 | projects | ||
| 646 | .filter((project) => project.autoPull === true) | ||
| 647 | .map((project) => project.workspaceRoot), | ||
| 648 | ), | ||
| 649 | ]; | ||
| 650 | |||
| 651 | yield* Effect.forEach( | ||
| 652 | workspaceRoots, | ||
| 653 | (cwd) => | ||
| 654 | Effect.gen(function* () { | ||
| 655 | const status = yield* git.statusDetails(cwd); | ||
| 656 | if ( | ||
| 657 | !status.isRepo || | ||
| 658 | !status.isDefaultBranch || | ||
| 659 | !status.hasUpstream || | ||
| 660 | status.hasWorkingTreeChanges || | ||
| 661 | status.aheadCount > 0 | ||
| 662 | ) { | ||
| 663 | yield* Effect.logDebug("Skipped automatic project pull", { | ||
| 664 | cwd, | ||
| 665 | reason: !status.isRepo | ||
| 666 | ? "not-a-repository" | ||
| 667 | : !status.isDefaultBranch | ||
| 668 | ? "not-on-default-branch" | ||
| 669 | : !status.hasUpstream | ||
| 670 | ? "no-upstream" | ||
| 671 | : status.hasWorkingTreeChanges | ||
| 672 | ? "working-tree-changes" | ||
| 673 | : "local-commits", | ||
| 674 | }); | ||
| 675 | return; | ||
| 676 | } | ||
| 677 | |||
| 678 | if (status.behindCount <= 0) return; | ||
| 679 | |||
| 680 | const result = yield* git.pullCurrentBranch(cwd); | ||
| 681 | yield* Effect.logDebug("Automatic project pull completed", { | ||
| 682 | cwd, | ||
| 683 | status: result.status, | ||
| 684 | refName: result.refName, | ||
| 685 | }); | ||
| 686 | }).pipe( | ||
| 687 | Effect.catch((cause) => | ||
| 688 | Effect.logWarning("Automatic project pull failed", { | ||
| 689 | cwd, | ||
| 690 | cause, | ||
| 691 | }), | ||
| 692 | ), | ||
| 693 | ), | ||
| 694 | { concurrency: 4, discard: true }, | ||
| 695 | ); | ||
| 696 | }); | ||
| 697 | |||
| 637 | export const make = (options?: StartupOptions) => | 698 | export const make = (options?: StartupOptions) => |
| 638 | Effect.gen(function* () { | 699 | Effect.gen(function* () { |
| 639 | const serverConfig = yield* ServerConfig.ServerConfig; | 700 | const serverConfig = yield* ServerConfig.ServerConfig; |
| ... | @@ -652,6 +713,13 @@ export const make = (options?: StartupOptions) => | ... | @@ -652,6 +713,13 @@ export const make = (options?: StartupOptions) => |
| 652 | const httpListening = yield* Deferred.make<void>(); | 713 | const httpListening = yield* Deferred.make<void>(); |
| 653 | const reactorScope = yield* Scope.make("sequential"); | 714 | const reactorScope = yield* Scope.make("sequential"); |
| 654 | 715 | ||
| 716 | const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( | ||
| 717 | Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), | ||
| 718 | Effect.catch((cause) => | ||
| 719 | Effect.logWarning("Failed to load projects for automatic pull", { cause }), | ||
| 720 | ), | ||
| 721 | ); | ||
| 722 | |||
| 655 | yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); | 723 | yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); |
| 656 | 724 | ||
| 657 | const startup = Effect.gen(function* () { | 725 | const startup = Effect.gen(function* () { |
| ... | @@ -696,6 +764,9 @@ export const make = (options?: StartupOptions) => | ... | @@ -696,6 +764,9 @@ export const make = (options?: StartupOptions) => |
| 696 | 764 | ||
| 697 | yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); | 765 | yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); |
| 698 | 766 | ||
| 767 | yield* Effect.logDebug("startup phase: syncing clean projects"); | ||
| 768 | yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); | ||
| 769 | |||
| 699 | const welcomeBase = yield* resolveWelcomeBase; | 770 | const welcomeBase = yield* resolveWelcomeBase; |
| 700 | const environment = yield* serverEnvironment.getDescriptor; | 771 | const environment = yield* serverEnvironment.getDescriptor; |
| 701 | yield* Effect.logDebug("startup phase: preparing welcome payload"); | 772 | yield* Effect.logDebug("startup phase: preparing welcome payload"); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+61| ... | @@ -142,6 +142,67 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) | ... | @@ -142,6 +142,67 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) |
| 142 | } | 142 | } |
| 143 | 143 | ||
| 144 | describe("VcsStatusBroadcaster", () => { | 144 | describe("VcsStatusBroadcaster", () => { |
| 145 | it.effect( | ||
| 146 | "automatically pulls an enabled clean default branch when status detects it is behind", | ||
| 147 | () => { | ||
| 148 | let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; | ||
| 149 | let pullCalls = 0; | ||
| 150 | let configuredWorkspaceRoot = ""; | ||
| 151 | const localStatus: VcsStatusLocalResult = { | ||
| 152 | ...baseLocalStatus, | ||
| 153 | isDefaultRef: true, | ||
| 154 | refName: "main", | ||
| 155 | }; | ||
| 156 | const testLayer = VcsStatusBroadcaster.layer.pipe( | ||
| 157 | Layer.provideMerge(NodeServices.layer), | ||
| 158 | Layer.provide(makeBackgroundPolicyLayer(() => true)), | ||
| 159 | Layer.provide( | ||
| 160 | Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { | ||
| 161 | isEnabled: (cwd) => Effect.succeed(cwd === configuredWorkspaceRoot), | ||
| 162 | }), | ||
| 163 | ), | ||
| 164 | Layer.provide( | ||
| 165 | Layer.mock(GitWorkflowService.GitWorkflowService)({ | ||
| 166 | localStatus: () => Effect.succeed(localStatus), | ||
| 167 | remoteStatus: () => Effect.succeed(remoteStatus), | ||
| 168 | invalidateLocalStatus: () => Effect.void, | ||
| 169 | invalidateRemoteStatus: () => Effect.void, | ||
| 170 | invalidateStatus: () => Effect.void, | ||
| 171 | pullCurrentBranch: () => | ||
| 172 | Effect.sync(() => { | ||
| 173 | pullCalls += 1; | ||
| 174 | remoteStatus = { ...remoteStatus, behindCount: 0 }; | ||
| 175 | return { | ||
| 176 | status: "pulled" as const, | ||
| 177 | refName: "main", | ||
| 178 | upstreamRef: "origin/main", | ||
| 179 | }; | ||
| 180 | }), | ||
| 181 | }), | ||
| 182 | ), | ||
| 183 | ); | ||
| 184 | |||
| 185 | return Effect.gen(function* () { | ||
| 186 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 187 | const path = yield* Path.Path; | ||
| 188 | const realDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 189 | prefix: "t3-vcs-auto-pull-real-", | ||
| 190 | }); | ||
| 191 | const linkParent = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 192 | prefix: "t3-vcs-auto-pull-link-", | ||
| 193 | }); | ||
| 194 | configuredWorkspaceRoot = path.join(linkParent, "repo-link"); | ||
| 195 | yield* fileSystem.symlink(realDir, configuredWorkspaceRoot); | ||
| 196 | |||
| 197 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | ||
| 198 | const status = yield* broadcaster.refreshStatus(configuredWorkspaceRoot); | ||
| 199 | |||
| 200 | assert.equal(pullCalls, 1); | ||
| 201 | assert.equal(status.behindCount, 0); | ||
| 202 | }).pipe(Effect.provide(testLayer)); | ||
| 203 | }, | ||
| 204 | ); | ||
| 205 | |||
| 145 | it.effect("reuses the cached VCS status across repeated reads", () => { | 206 | it.effect("reuses the cached VCS status across repeated reads", () => { |
| 146 | const state = { | 207 | const state = { |
| 147 | currentLocalStatus: baseLocalStatus, | 208 | currentLocalStatus: baseLocalStatus, |
apps/server/src/vcs/VcsStatusBroadcaster.ts+69-1| ... | @@ -24,6 +24,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; | ... | @@ -24,6 +24,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | 24 | ||
| 25 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 25 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 26 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; | 26 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 27 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 27 | 28 | ||
| 28 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | 29 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| 29 | const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); | 30 | const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); |
| ... | @@ -139,6 +140,26 @@ interface StreamStatusOptions { | ... | @@ -139,6 +140,26 @@ interface StreamStatusOptions { |
| 139 | readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>; | 140 | readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>; |
| 140 | } | 141 | } |
| 141 | 142 | ||
| 143 | export class VcsAutoPullPolicy extends Context.Reference<{ | ||
| 144 | readonly isEnabled: (cwd: string) => Effect.Effect<boolean, never>; | ||
| 145 | }>("t3/vcs/VcsAutoPullPolicy", { | ||
| 146 | defaultValue: () => ({ isEnabled: () => Effect.succeed(false) }), | ||
| 147 | }) {} | ||
| 148 | |||
| 149 | export const autoPullPolicyLayer = Layer.effect( | ||
| 150 | VcsAutoPullPolicy, | ||
| 151 | Effect.gen(function* () { | ||
| 152 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 153 | return { | ||
| 154 | isEnabled: (cwd: string) => | ||
| 155 | snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( | ||
| 156 | Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), | ||
| 157 | Effect.orElseSucceed(() => false), | ||
| 158 | ), | ||
| 159 | }; | ||
| 160 | }), | ||
| 161 | ); | ||
| 162 | |||
| 142 | export function remoteRefreshFailureDelay( | 163 | export function remoteRefreshFailureDelay( |
| 143 | consecutiveFailures: number, | 164 | consecutiveFailures: number, |
| 144 | configuredInterval: Duration.Duration, | 165 | configuredInterval: Duration.Duration, |
| ... | @@ -181,6 +202,7 @@ const normalizeCwd = (cwd: string) => | ... | @@ -181,6 +202,7 @@ const normalizeCwd = (cwd: string) => |
| 181 | ); | 202 | ); |
| 182 | 203 | ||
| 183 | export const make = Effect.gen(function* () { | 204 | export const make = Effect.gen(function* () { |
| 205 | const autoPullPolicy = yield* VcsAutoPullPolicy; | ||
| 184 | const workflow = yield* GitWorkflowService.GitWorkflowService; | 206 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 185 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | 207 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| 186 | const fs = yield* FileSystem.FileSystem; | 208 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -354,14 +376,57 @@ export const make = Effect.gen(function* () { | ... | @@ -354,14 +376,57 @@ export const make = Effect.gen(function* () { |
| 354 | return yield* refreshLocalStatusCore(cwd); | 376 | return yield* refreshLocalStatusCore(cwd); |
| 355 | }); | 377 | }); |
| 356 | 378 | ||
| 379 | const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( | ||
| 380 | cwd: string, | ||
| 381 | remote: VcsStatusRemoteResult | null, | ||
| 382 | policyCwds: ReadonlyArray<string>, | ||
| 383 | ) { | ||
| 384 | return yield* Effect.gen(function* () { | ||
| 385 | const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { | ||
| 386 | concurrency: "unbounded", | ||
| 387 | })).some(Boolean); | ||
| 388 | if ( | ||
| 389 | remote === null || | ||
| 390 | !remote.hasUpstream || | ||
| 391 | remote.aheadCount > 0 || | ||
| 392 | remote.behindCount <= 0 || | ||
| 393 | !autoPullEnabled | ||
| 394 | ) { | ||
| 395 | return null; | ||
| 396 | } | ||
| 397 | |||
| 398 | yield* workflow.invalidateLocalStatus(cwd); | ||
| 399 | const local = yield* workflow.localStatus({ cwd }); | ||
| 400 | if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; | ||
| 401 | |||
| 402 | yield* workflow.pullCurrentBranch(cwd); | ||
| 403 | yield* workflow.invalidateStatus(cwd); | ||
| 404 | const [refreshedLocal, refreshedRemote] = yield* Effect.all( | ||
| 405 | [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], | ||
| 406 | { concurrency: "unbounded" }, | ||
| 407 | ); | ||
| 408 | yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); | ||
| 409 | return { local: refreshedLocal, remote: refreshedRemote }; | ||
| 410 | }).pipe( | ||
| 411 | Effect.catch(() => | ||
| 412 | Effect.logWarning("Automatic project pull failed", { cwd }).pipe(Effect.as(null)), | ||
| 413 | ), | ||
| 414 | ); | ||
| 415 | }); | ||
| 416 | |||
| 357 | const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( | 417 | const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( |
| 358 | cwd: string, | 418 | cwd: string, |
| 359 | options?: { readonly refreshUpstream?: boolean }, | 419 | options?: { |
| 420 | readonly refreshUpstream?: boolean; | ||
| 421 | readonly policyCwds?: ReadonlyArray<string>; | ||
| 422 | }, | ||
| 360 | ) { | 423 | ) { |
| 361 | if (options?.refreshUpstream !== false) { | 424 | if (options?.refreshUpstream !== false) { |
| 362 | yield* workflow.invalidateRemoteStatus(cwd); | 425 | yield* workflow.invalidateRemoteStatus(cwd); |
| 363 | } | 426 | } |
| 364 | const remote = yield* workflow.remoteStatus({ cwd }, options); | 427 | const remote = yield* workflow.remoteStatus({ cwd }, options); |
| 428 | const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); | ||
| 429 | if (pulled !== null) return pulled.remote; | ||
| 365 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); | 430 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); |
| 366 | }); | 431 | }); |
| 367 | 432 | ||
| ... | @@ -376,6 +441,8 @@ export const make = Effect.gen(function* () { | ... | @@ -376,6 +441,8 @@ export const make = Effect.gen(function* () { |
| 376 | [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], | 441 | [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], |
| 377 | { concurrency: "unbounded" }, | 442 | { concurrency: "unbounded" }, |
| 378 | ); | 443 | ); |
| 444 | const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); | ||
| 445 | if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); | ||
| 379 | return yield* updateCachedStatus(cwd, local, remote, { publish: true }); | 446 | return yield* updateCachedStatus(cwd, local, remote, { publish: true }); |
| 380 | }); | 447 | }); |
| 381 | 448 | ||
| ... | @@ -416,6 +483,7 @@ export const make = Effect.gen(function* () { | ... | @@ -416,6 +483,7 @@ export const make = Effect.gen(function* () { |
| 416 | 483 | ||
| 417 | const exit = yield* refreshRemoteStatus(cwd, { | 484 | const exit = yield* refreshRemoteStatus(cwd, { |
| 418 | refreshUpstream: !Duration.isZero(configuredInterval), | 485 | refreshUpstream: !Duration.isZero(configuredInterval), |
| 486 | policyCwds: [...demandCwds.keys()], | ||
| 419 | }).pipe(Effect.exit); | 487 | }).pipe(Effect.exit); |
| 420 | if (Exit.isSuccess(exit)) { | 488 | if (Exit.isSuccess(exit)) { |
| 421 | yield* Ref.set(needsInitialRefreshRef, false); | 489 | yield* Ref.set(needsInitialRefreshRef, false); |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+20| ... | @@ -96,6 +96,7 @@ import { | ... | @@ -96,6 +96,7 @@ import { |
| 96 | } from "../ui/menu"; | 96 | } from "../ui/menu"; |
| 97 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 97 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 98 | import { SidebarInset } from "../ui/sidebar"; | 98 | import { SidebarInset } from "../ui/sidebar"; |
| 99 | import { Switch } from "../ui/switch"; | ||
| 99 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 100 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 100 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 101 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 101 | import { | 102 | import { |
| ... | @@ -371,6 +372,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { | ... | @@ -371,6 +372,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 371 | title: string; | 372 | title: string; |
| 372 | defaultModelSelection: ModelSelection | null; | 373 | defaultModelSelection: ModelSelection | null; |
| 373 | defaultThreadEnvMode: ThreadEnvMode | null; | 374 | defaultThreadEnvMode: ThreadEnvMode | null; |
| 375 | autoPull: boolean; | ||
| 374 | faviconPath: string | null; | 376 | faviconPath: string | null; |
| 375 | }>, | 377 | }>, |
| 376 | failureTitle: string, | 378 | failureTitle: string, |
| ... | @@ -464,6 +466,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { | ... | @@ -464,6 +466,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 464 | [updateAllMembers], | 466 | [updateAllMembers], |
| 465 | ); | 467 | ); |
| 466 | 468 | ||
| 469 | const autoPull = representative.autoPull ?? false; | ||
| 470 | const setAutoPull = useCallback( | ||
| 471 | (enabled: boolean) => | ||
| 472 | void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), | ||
| 473 | [updateAllMembers], | ||
| 474 | ); | ||
| 475 | |||
| 467 | // ----- favicon ----- | 476 | // ----- favicon ----- |
| 468 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); | 477 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); |
| 469 | const [isSavingFavicon, setIsSavingFavicon] = useState(false); | 478 | const [isSavingFavicon, setIsSavingFavicon] = useState(false); |
| ... | @@ -940,6 +949,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { | ... | @@ -940,6 +949,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 940 | </Select> | 949 | </Select> |
| 941 | } | 950 | } |
| 942 | /> | 951 | /> |
| 952 | <SettingsRow | ||
| 953 | title="Automatically pull" | ||
| 954 | description="Keeps the default branch current in the background when the checkout has no local changes or commits." | ||
| 955 | control={ | ||
| 956 | <Switch | ||
| 957 | checked={autoPull} | ||
| 958 | aria-label="Automatically pull the default branch" | ||
| 959 | onCheckedChange={setAutoPull} | ||
| 960 | /> | ||
| 961 | } | ||
| 962 | /> | ||
| 943 | </SettingsSection> | 963 | </SettingsSection> |
| 944 | 964 | ||
| 945 | <SettingsSection | 965 | <SettingsSection |
docs/user/project-settings.md+10| ... | @@ -14,3 +14,13 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa | ... | @@ -14,3 +14,13 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa |
| 14 | each checkout in the project group and appears on your connected clients. | 14 | each checkout in the project group and appears on your connected clients. |
| 15 | 15 | ||
| 16 | To use automatic detection again, select **Automatic**. | 16 | To use automatic detection again, select **Automatic**. |
| 17 | |||
| 18 | ## Keep the default branch current | ||
| 19 | |||
| 20 | Turn on **Automatically pull** in a project's settings to keep its default-branch checkout current. | ||
| 21 | T3 Code checks in the background and when the server starts. It uses the branch's configured | ||
| 22 | upstream and only performs a fast-forward pull when the checkout has no working-tree changes, | ||
| 23 | untracked files, or local commits. | ||
| 24 | |||
| 25 | The pull is skipped if the checkout is on another branch, has no upstream, or contains local work. | ||
| 26 | Pull failures do not prevent the server from starting. |
packages/contracts/src/orchestration.ts+6| ... | @@ -291,6 +291,9 @@ export const OrchestrationProject = Schema.Struct({ | ... | @@ -291,6 +291,9 @@ export const OrchestrationProject = Schema.Struct({ |
| 291 | // Per-project override for where new threads start. Null/absent means | 291 | // Per-project override for where new threads start. Null/absent means |
| 292 | // "no override": clients fall back to t3.json, then the global setting. | 292 | // "no override": clients fall back to t3.json, then the global setting. |
| 293 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 293 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 294 | // Opt-in because background sync performs network I/O and may move the checkout. | ||
| 295 | // Optional on the wire so cached snapshots from older servers still decode. | ||
| 296 | autoPull: Schema.optional(Schema.Boolean), | ||
| 294 | // Optional on the wire so cached snapshots from older servers still decode. | 297 | // Optional on the wire so cached snapshots from older servers still decode. |
| 295 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | 298 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 296 | scripts: Schema.Array(ProjectScript), | 299 | scripts: Schema.Array(ProjectScript), |
| ... | @@ -502,6 +505,7 @@ export const OrchestrationProjectShell = Schema.Struct({ | ... | @@ -502,6 +505,7 @@ export const OrchestrationProjectShell = Schema.Struct({ |
| 502 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 505 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 503 | defaultModelSelection: Schema.NullOr(ModelSelection), | 506 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 504 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 507 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 508 | autoPull: Schema.optional(Schema.Boolean), | ||
| 505 | // Optional on the wire so cached snapshots from older servers still decode. | 509 | // Optional on the wire so cached snapshots from older servers still decode. |
| 506 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | 510 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 507 | scripts: Schema.Array(ProjectScript), | 511 | scripts: Schema.Array(ProjectScript), |
| ... | @@ -723,6 +727,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ | ... | @@ -723,6 +727,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ |
| 723 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 727 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 724 | // Absent = leave unchanged; null = clear the override. | 728 | // Absent = leave unchanged; null = clear the override. |
| 725 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 729 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 730 | autoPull: Schema.optional(Schema.Boolean), | ||
| 726 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | 731 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 727 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 732 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 728 | }); | 733 | }); |
| ... | @@ -1191,6 +1196,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ | ... | @@ -1191,6 +1196,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 1191 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 1196 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 1192 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 1197 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 1193 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 1198 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 1199 | autoPull: Schema.optional(Schema.Boolean), | ||
| 1194 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | 1200 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 1195 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 1201 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 1196 | updatedAt: IsoDateTime, | 1202 | updatedAt: IsoDateTime, |