| 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 | 40 | import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; |
| 41 | 41 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 42 | 42 | import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; |
| 43 | import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; | |
| 43 | 44 | |
| 44 | 45 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| 45 | 46 | const projectId = ProjectId.make("project-startup-orphan"); |
| ... | ... | @@ -105,6 +106,7 @@ const startupDependencies = Layer.mergeAll( |
| 105 | 106 | }), |
| 106 | 107 | ), |
| 107 | 108 | AnalyticsService.layerTest, |
| 109 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 108 | 110 | Layer.succeed(ProviderService.ProviderService, { |
| 109 | 111 | startSession: () => Effect.die("unused"), |
| 110 | 112 | sendTurn: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2| ... | ... | @@ -515,6 +515,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 515 | 515 | workspaceRoot: event.payload.workspaceRoot, |
| 516 | 516 | defaultModelSelection: event.payload.defaultModelSelection, |
| 517 | 517 | defaultThreadEnvMode: null, |
| 518 | autoPull: false, | |
| 518 | 519 | faviconPath: event.payload.faviconPath ?? null, |
| 519 | 520 | scripts: event.payload.scripts, |
| 520 | 521 | createdAt: event.payload.createdAt, |
| ... | ... | @@ -542,6 +543,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 542 | 543 | ...(event.payload.defaultThreadEnvMode !== undefined |
| 543 | 544 | ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } |
| 544 | 545 | : {}), |
| 546 | ...(event.payload.autoPull !== undefined ? { autoPull: event.payload.autoPull } : {}), | |
| 545 | 547 | ...(event.payload.faviconPath !== undefined |
| 546 | 548 | ? { faviconPath: event.payload.faviconPath } |
| 547 | 549 | : {}), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2| ... | ... | @@ -278,6 +278,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 278 | 278 | instanceId: ProviderInstanceId.make("codex"), |
| 279 | 279 | model: "gpt-5-codex", |
| 280 | 280 | }, |
| 281 | autoPull: false, | |
| 281 | 282 | faviconPath: null, |
| 282 | 283 | scripts: [ |
| 283 | 284 | { |
| ... | ... | @@ -405,6 +406,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 405 | 406 | instanceId: ProviderInstanceId.make("codex"), |
| 406 | 407 | model: "gpt-5-codex", |
| 407 | 408 | }, |
| 409 | autoPull: false, | |
| 408 | 410 | faviconPath: null, |
| 409 | 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 | 83 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 84 | 84 | Struct.assign({ |
| 85 | 85 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 86 | autoPull: Schema.Number, | |
| 86 | 87 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 87 | 88 | }), |
| 88 | 89 | ); |
| ... | ... | @@ -347,6 +348,7 @@ function mapProjectShellRow( |
| 347 | 348 | repositoryIdentity, |
| 348 | 349 | defaultModelSelection: row.defaultModelSelection, |
| 349 | 350 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 351 | autoPull: row.autoPull === 1, | |
| 350 | 352 | faviconPath: row.faviconPath ?? null, |
| 351 | 353 | scripts: row.scripts, |
| 352 | 354 | createdAt: row.createdAt, |
| ... | ... | @@ -439,6 +441,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 439 | 441 | workspace_root AS "workspaceRoot", |
| 440 | 442 | default_model_selection_json AS "defaultModelSelection", |
| 441 | 443 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 444 | auto_pull AS "autoPull", | |
| 442 | 445 | favicon_path AS "faviconPath", |
| 443 | 446 | scripts_json AS "scripts", |
| 444 | 447 | created_at AS "createdAt", |
| ... | ... | @@ -914,6 +917,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 914 | 917 | workspace_root AS "workspaceRoot", |
| 915 | 918 | default_model_selection_json AS "defaultModelSelection", |
| 916 | 919 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 920 | auto_pull AS "autoPull", | |
| 917 | 921 | favicon_path AS "faviconPath", |
| 918 | 922 | scripts_json AS "scripts", |
| 919 | 923 | created_at AS "createdAt", |
| ... | ... | @@ -938,6 +942,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 938 | 942 | workspace_root AS "workspaceRoot", |
| 939 | 943 | default_model_selection_json AS "defaultModelSelection", |
| 940 | 944 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 945 | auto_pull AS "autoPull", | |
| 941 | 946 | favicon_path AS "faviconPath", |
| 942 | 947 | scripts_json AS "scripts", |
| 943 | 948 | created_at AS "createdAt", |
| ... | ... | @@ -1878,6 +1883,7 @@ pending_approval_requests AS ( |
| 1878 | 1883 | repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, |
| 1879 | 1884 | defaultModelSelection: row.defaultModelSelection, |
| 1880 | 1885 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 1886 | autoPull: row.autoPull === 1, | |
| 1881 | 1887 | faviconPath: row.faviconPath ?? null, |
| 1882 | 1888 | scripts: row.scripts, |
| 1883 | 1889 | createdAt: row.createdAt, |
| ... | ... | @@ -2013,6 +2019,7 @@ pending_approval_requests AS ( |
| 2013 | 2019 | workspaceRoot: row.workspaceRoot, |
| 2014 | 2020 | defaultModelSelection: row.defaultModelSelection, |
| 2015 | 2021 | defaultThreadEnvMode: row.defaultThreadEnvMode, |
| 2022 | autoPull: row.autoPull === 1, | |
| 2016 | 2023 | faviconPath: row.faviconPath ?? null, |
| 2017 | 2024 | scripts: row.scripts, |
| 2018 | 2025 | createdAt: row.createdAt, |
| ... | ... | @@ -2534,6 +2541,7 @@ pending_approval_requests AS ( |
| 2534 | 2541 | repositoryIdentity, |
| 2535 | 2542 | defaultModelSelection: option.value.defaultModelSelection, |
| 2536 | 2543 | defaultThreadEnvMode: option.value.defaultThreadEnvMode, |
| 2544 | autoPull: option.value.autoPull === 1, | |
| 2537 | 2545 | faviconPath: option.value.faviconPath ?? null, |
| 2538 | 2546 | scripts: option.value.scripts, |
| 2539 | 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 | 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 | 264 | ...(command.defaultThreadEnvMode !== undefined |
| 265 | 265 | ? { defaultThreadEnvMode: command.defaultThreadEnvMode } |
| 266 | 266 | : {}), |
| 267 | ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), | |
| 267 | 268 | ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), |
| 268 | 269 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), |
| 269 | 270 | updatedAt: occurredAt, |
apps/server/src/orchestration/projector.ts+2| ... | ... | @@ -215,6 +215,7 @@ export function projectEvent( |
| 215 | 215 | workspaceRoot: payload.workspaceRoot, |
| 216 | 216 | defaultModelSelection: payload.defaultModelSelection, |
| 217 | 217 | defaultThreadEnvMode: null, |
| 218 | autoPull: false, | |
| 218 | 219 | faviconPath: payload.faviconPath ?? null, |
| 219 | 220 | scripts: payload.scripts, |
| 220 | 221 | createdAt: payload.createdAt, |
| ... | ... | @@ -251,6 +252,7 @@ export function projectEvent( |
| 251 | 252 | ...(payload.defaultThreadEnvMode !== undefined |
| 252 | 253 | ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } |
| 253 | 254 | : {}), |
| 255 | ...(payload.autoPull !== undefined ? { autoPull: payload.autoPull } : {}), | |
| 254 | 256 | ...(payload.faviconPath !== undefined |
| 255 | 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 | 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | |
| 5 | 6 | import * as Schema from "effect/Schema"; |
| 6 | 7 | import * as Struct from "effect/Struct"; |
| 7 | 8 | |
| ... | ... | @@ -18,6 +19,7 @@ import { |
| 18 | 19 | const ProjectionProjectDbRow = ProjectionProject.mapFields( |
| 19 | 20 | Struct.assign({ |
| 20 | 21 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 22 | autoPull: Schema.Number, | |
| 21 | 23 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 22 | 24 | }), |
| 23 | 25 | ); |
| ... | ... | @@ -36,6 +38,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 36 | 38 | workspace_root, |
| 37 | 39 | default_model_selection_json, |
| 38 | 40 | default_thread_env_mode, |
| 41 | auto_pull, | |
| 39 | 42 | favicon_path, |
| 40 | 43 | scripts_json, |
| 41 | 44 | created_at, |
| ... | ... | @@ -48,6 +51,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 48 | 51 | ${row.workspaceRoot}, |
| 49 | 52 | ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, |
| 50 | 53 | ${row.defaultThreadEnvMode}, |
| 54 | ${row.autoPull ? 1 : 0}, | |
| 51 | 55 | ${row.faviconPath ?? null}, |
| 52 | 56 | ${JSON.stringify(row.scripts)}, |
| 53 | 57 | ${row.createdAt}, |
| ... | ... | @@ -60,6 +64,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 60 | 64 | workspace_root = excluded.workspace_root, |
| 61 | 65 | default_model_selection_json = excluded.default_model_selection_json, |
| 62 | 66 | default_thread_env_mode = excluded.default_thread_env_mode, |
| 67 | auto_pull = excluded.auto_pull, | |
| 63 | 68 | favicon_path = excluded.favicon_path, |
| 64 | 69 | scripts_json = excluded.scripts_json, |
| 65 | 70 | created_at = excluded.created_at, |
| ... | ... | @@ -79,6 +84,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 79 | 84 | workspace_root AS "workspaceRoot", |
| 80 | 85 | default_model_selection_json AS "defaultModelSelection", |
| 81 | 86 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 87 | auto_pull AS "autoPull", | |
| 82 | 88 | favicon_path AS "faviconPath", |
| 83 | 89 | scripts_json AS "scripts", |
| 84 | 90 | created_at AS "createdAt", |
| ... | ... | @@ -100,6 +106,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 100 | 106 | workspace_root AS "workspaceRoot", |
| 101 | 107 | default_model_selection_json AS "defaultModelSelection", |
| 102 | 108 | default_thread_env_mode AS "defaultThreadEnvMode", |
| 109 | auto_pull AS "autoPull", | |
| 103 | 110 | favicon_path AS "faviconPath", |
| 104 | 111 | scripts_json AS "scripts", |
| 105 | 112 | created_at AS "createdAt", |
| ... | ... | @@ -126,11 +133,13 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 126 | 133 | |
| 127 | 134 | const getById: ProjectionProjectRepositoryShape["getById"] = (input) => |
| 128 | 135 | getProjectionProjectRow(input).pipe( |
| 136 | Effect.map(Option.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))), | |
| 129 | 137 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), |
| 130 | 138 | ); |
| 131 | 139 | |
| 132 | 140 | const listAll: ProjectionProjectRepositoryShape["listAll"] = () => |
| 133 | 141 | listProjectionProjectRows().pipe( |
| 142 | Effect.map((rows) => rows.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))), | |
| 134 | 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 | 34 | model: "gpt-5.4", |
| 35 | 35 | }, |
| 36 | 36 | defaultThreadEnvMode: null, |
| 37 | autoPull: false, | |
| 37 | 38 | scripts: [], |
| 38 | 39 | createdAt: "2026-03-24T00:00:00.000Z", |
| 39 | 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 | 56 | import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; |
| 57 | 57 | import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; |
| 58 | 58 | import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts"; |
| 59 | import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; | |
| 59 | 60 | |
| 60 | 61 | /** |
| 61 | 62 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -112,6 +113,7 @@ export const migrationEntries = [ |
| 112 | 113 | [42, "ProjectionThreadLinkedPullRequest", Migration0042], |
| 113 | 114 | [43, "ProjectionThreadsUnsettledAt", Migration0043], |
| 114 | 115 | [44, "ClearAutomaticProjectModelDefaults", Migration0044], |
| 116 | [45, "ProjectionProjectsAutoPull", Migration0045], | |
| 115 | 117 | ] as const; |
| 116 | 118 | |
| 117 | 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 | 26 | workspaceRoot: Schema.String, |
| 27 | 27 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 28 | 28 | defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), |
| 29 | autoPull: Schema.Boolean, | |
| 29 | 30 | faviconPath: Schema.optional(Schema.NullOr(Schema.String)), |
| 30 | 31 | scripts: Schema.Array(ProjectScript), |
| 31 | 32 | createdAt: IsoDateTime, |
apps/server/src/server.ts+6-1| ... | ... | @@ -339,7 +339,12 @@ const VcsLayerLive = Layer.empty.pipe( |
| 339 | 339 | Layer.provideMerge(GitWorkflowLayerLive), |
| 340 | 340 | Layer.provideMerge(ReviewLayerLive), |
| 341 | 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 | 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 | 15 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 16 | 16 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 17 | 17 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 18 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | |
| 18 | 19 | |
| 19 | 20 | it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { |
| 20 | 21 | assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { |
| ... | ... | @@ -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 | 66 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => |
| 27 | 67 | Effect.scoped( |
| 28 | 68 | Effect.gen(function* () { |
apps/server/src/serverRuntimeStartup.ts+71| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | DEFAULT_MODEL, |
| 4 | 4 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 5 | 5 | type ModelSelection, |
| 6 | type OrchestrationProjectShell, | |
| 6 | 7 | ProjectId, |
| 7 | 8 | ProviderInstanceId, |
| 8 | 9 | ThreadId, |
| ... | ... | @@ -40,6 +41,7 @@ import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDi |
| 40 | 41 | import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; |
| 41 | 42 | import { forkParked } from "./serverActivation.ts"; |
| 42 | 43 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; |
| 44 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | |
| 43 | 45 | import { |
| 44 | 46 | formatHeadlessServeOutput, |
| 45 | 47 | formatHostForUrl, |
| ... | ... | @@ -634,6 +636,65 @@ interface StartupOptions { |
| 634 | 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 | 698 | export const make = (options?: StartupOptions) => |
| 638 | 699 | Effect.gen(function* () { |
| 639 | 700 | const serverConfig = yield* ServerConfig.ServerConfig; |
| ... | ... | @@ -652,6 +713,13 @@ export const make = (options?: StartupOptions) => |
| 652 | 713 | const httpListening = yield* Deferred.make<void>(); |
| 653 | 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 | 723 | yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); |
| 656 | 724 | |
| 657 | 725 | const startup = Effect.gen(function* () { |
| ... | ... | @@ -696,6 +764,9 @@ export const make = (options?: StartupOptions) => |
| 696 | 764 | |
| 697 | 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 | 770 | const welcomeBase = yield* resolveWelcomeBase; |
| 700 | 771 | const environment = yield* serverEnvironment.getDescriptor; |
| 701 | 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 | 142 | } |
| 143 | 143 | |
| 144 | 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 | 206 | it.effect("reuses the cached VCS status across repeated reads", () => { |
| 146 | 207 | const state = { |
| 147 | 208 | currentLocalStatus: baseLocalStatus, |
apps/server/src/vcs/VcsStatusBroadcaster.ts+69-1| ... | ... | @@ -24,6 +24,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | 24 | |
| 25 | 25 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 26 | 26 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 27 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 27 | 28 | |
| 28 | 29 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| 29 | 30 | const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); |
| ... | ... | @@ -139,6 +140,26 @@ interface StreamStatusOptions { |
| 139 | 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 | 163 | export function remoteRefreshFailureDelay( |
| 143 | 164 | consecutiveFailures: number, |
| 144 | 165 | configuredInterval: Duration.Duration, |
| ... | ... | @@ -181,6 +202,7 @@ const normalizeCwd = (cwd: string) => |
| 181 | 202 | ); |
| 182 | 203 | |
| 183 | 204 | export const make = Effect.gen(function* () { |
| 205 | const autoPullPolicy = yield* VcsAutoPullPolicy; | |
| 184 | 206 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 185 | 207 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| 186 | 208 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -354,14 +376,57 @@ export const make = Effect.gen(function* () { |
| 354 | 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 | 417 | const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( |
| 358 | 418 | cwd: string, |
| 359 | options?: { readonly refreshUpstream?: boolean }, | |
| 419 | options?: { | |
| 420 | readonly refreshUpstream?: boolean; | |
| 421 | readonly policyCwds?: ReadonlyArray<string>; | |
| 422 | }, | |
| 360 | 423 | ) { |
| 361 | 424 | if (options?.refreshUpstream !== false) { |
| 362 | 425 | yield* workflow.invalidateRemoteStatus(cwd); |
| 363 | 426 | } |
| 364 | 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 | 430 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); |
| 366 | 431 | }); |
| 367 | 432 | |
| ... | ... | @@ -376,6 +441,8 @@ export const make = Effect.gen(function* () { |
| 376 | 441 | [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], |
| 377 | 442 | { concurrency: "unbounded" }, |
| 378 | 443 | ); |
| 444 | const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); | |
| 445 | if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); | |
| 379 | 446 | return yield* updateCachedStatus(cwd, local, remote, { publish: true }); |
| 380 | 447 | }); |
| 381 | 448 | |
| ... | ... | @@ -416,6 +483,7 @@ export const make = Effect.gen(function* () { |
| 416 | 483 | |
| 417 | 484 | const exit = yield* refreshRemoteStatus(cwd, { |
| 418 | 485 | refreshUpstream: !Duration.isZero(configuredInterval), |
| 486 | policyCwds: [...demandCwds.keys()], | |
| 419 | 487 | }).pipe(Effect.exit); |
| 420 | 488 | if (Exit.isSuccess(exit)) { |
| 421 | 489 | yield* Ref.set(needsInitialRefreshRef, false); |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+20| ... | ... | @@ -96,6 +96,7 @@ import { |
| 96 | 96 | } from "../ui/menu"; |
| 97 | 97 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 98 | 98 | import { SidebarInset } from "../ui/sidebar"; |
| 99 | import { Switch } from "../ui/switch"; | |
| 99 | 100 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 100 | 101 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 101 | 102 | import { |
| ... | ... | @@ -371,6 +372,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 371 | 372 | title: string; |
| 372 | 373 | defaultModelSelection: ModelSelection | null; |
| 373 | 374 | defaultThreadEnvMode: ThreadEnvMode | null; |
| 375 | autoPull: boolean; | |
| 374 | 376 | faviconPath: string | null; |
| 375 | 377 | }>, |
| 376 | 378 | failureTitle: string, |
| ... | ... | @@ -464,6 +466,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 464 | 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 | 476 | // ----- favicon ----- |
| 468 | 477 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); |
| 469 | 478 | const [isSavingFavicon, setIsSavingFavicon] = useState(false); |
| ... | ... | @@ -940,6 +949,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 940 | 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 | 963 | </SettingsSection> |
| 944 | 964 | |
| 945 | 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 | 14 | each checkout in the project group and appears on your connected clients. |
| 15 | 15 | |
| 16 | 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 | 291 | // Per-project override for where new threads start. Null/absent means |
| 292 | 292 | // "no override": clients fall back to t3.json, then the global setting. |
| 293 | 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 | 297 | // Optional on the wire so cached snapshots from older servers still decode. |
| 295 | 298 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 296 | 299 | scripts: Schema.Array(ProjectScript), |
| ... | ... | @@ -502,6 +505,7 @@ export const OrchestrationProjectShell = Schema.Struct({ |
| 502 | 505 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 503 | 506 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 504 | 507 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 508 | autoPull: Schema.optional(Schema.Boolean), | |
| 505 | 509 | // Optional on the wire so cached snapshots from older servers still decode. |
| 506 | 510 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 507 | 511 | scripts: Schema.Array(ProjectScript), |
| ... | ... | @@ -723,6 +727,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ |
| 723 | 727 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 724 | 728 | // Absent = leave unchanged; null = clear the override. |
| 725 | 729 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 730 | autoPull: Schema.optional(Schema.Boolean), | |
| 726 | 731 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 727 | 732 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 728 | 733 | }); |
| ... | ... | @@ -1191,6 +1196,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 1191 | 1196 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 1192 | 1197 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 1193 | 1198 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 1199 | autoPull: Schema.optional(Schema.Boolean), | |
| 1194 | 1200 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 1195 | 1201 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 1196 | 1202 | updatedAt: IsoDateTime, |