diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 368c26173..0f6d6fe80 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -43,6 +43,7 @@ import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; const providerInstanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project-startup-orphan"); @@ -108,6 +109,7 @@ const startupDependencies = Layer.mergeAll( }), ), AnalyticsService.layerTest, + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.succeed(ProviderService.ProviderService, { startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e7f471113..fc4496bd8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -496,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, defaultThreadEnvMode: null, + autoPull: false, faviconPath: event.payload.faviconPath ?? null, projectIcon: event.payload.projectIcon ?? null, scripts: event.payload.scripts, @@ -524,6 +525,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } : {}), + ...(event.payload.autoPull !== undefined ? { autoPull: event.payload.autoPull } : {}), ...(event.payload.faviconPath !== undefined ? { faviconPath: event.payload.faviconPath } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 39cce651f..92bf179f9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -322,6 +322,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + autoPull: false, faviconPath: null, projectIcon: null, scripts: [ @@ -463,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + autoPull: false, faviconPath: null, projectIcon: null, scripts: [ diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c1a206d93..6a7ea8465 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -99,6 +99,7 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), + autoPull: Schema.Number, scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -504,6 +505,7 @@ function mapProjectShellRow( repositoryIdentity, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, projectIcon: row.projectIcon ?? null, scripts: row.scripts, @@ -597,6 +599,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", @@ -1176,6 +1179,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", @@ -1201,6 +1205,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", @@ -2439,6 +2444,7 @@ pending_approval_requests AS ( repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, projectIcon: row.projectIcon ?? null, scripts: row.scripts, @@ -2583,6 +2589,7 @@ pending_approval_requests AS ( workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, projectIcon: row.projectIcon ?? null, scripts: row.scripts, @@ -3140,6 +3147,7 @@ pending_approval_requests AS ( repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, defaultThreadEnvMode: option.value.defaultThreadEnvMode, + autoPull: option.value.autoPull === 1, faviconPath: option.value.faviconPath ?? null, projectIcon: option.value.projectIcon ?? null, scripts: option.value.scripts, diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts index 4624ff0ab..fd15d8f26 100644 --- a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -153,4 +153,26 @@ it.layer(NodeServices.layer)("decider project defaults", (it) => { expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); }), ); + + it.effect("propagates autoPull through meta.update into the read model", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + expect(readModel.projects[0]?.autoPull).toBe(false); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-auto-pull"), + projectId, + autoPull: true, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + expect((event.payload as { autoPull?: unknown }).autoPull).toBe(true); + + const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); + expect(updated.projects[0]?.autoPull).toBe(true); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 04af846ea..ae6c0ed3c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -301,6 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: command.defaultThreadEnvMode } : {}), + ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index af2f35e1d..6368a6328 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -238,6 +238,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: null, + autoPull: false, faviconPath: payload.faviconPath ?? null, projectIcon: payload.projectIcon ?? null, scripts: payload.scripts, @@ -275,6 +276,7 @@ export function projectEvent( ...(payload.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } : {}), + ...(payload.autoPull !== undefined ? { autoPull: payload.autoPull } : {}), ...(payload.faviconPath !== undefined ? { faviconPath: payload.faviconPath } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 3ff601120..8ea6b7b04 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -19,6 +20,7 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), projectIcon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), + autoPull: Schema.Number, scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -37,6 +39,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, default_thread_env_mode, + auto_pull, favicon_path, project_icon_json, scripts_json, @@ -50,6 +53,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${row.defaultThreadEnvMode}, + ${row.autoPull ? 1 : 0}, ${row.faviconPath ?? null}, ${row.projectIcon ? JSON.stringify(row.projectIcon) : null}, ${JSON.stringify(row.scripts)}, @@ -63,6 +67,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, default_thread_env_mode = excluded.default_thread_env_mode, + auto_pull = excluded.auto_pull, favicon_path = excluded.favicon_path, project_icon_json = excluded.project_icon_json, scripts_json = excluded.scripts_json, @@ -83,6 +88,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", @@ -105,6 +111,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", @@ -132,11 +139,13 @@ const makeProjectionProjectRepository = Effect.gen(function* () { const getById: ProjectionProjectRepositoryShape["getById"] = (input) => getProjectionProjectRow(input).pipe( + Effect.map(Option.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))), Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), ); const listAll: ProjectionProjectRepositoryShape["listAll"] = () => listProjectionProjectRows().pipe( + Effect.map((rows) => rows.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))), Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), ); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 6e3262e5b..6e4470118 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -289,6 +289,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "gpt-5.4", }, defaultThreadEnvMode: null, + autoPull: false, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5683a7781..6e546b712 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -68,6 +68,7 @@ import Migration0054 from "./Migrations/054_RepairAutomaticSettlementTimestamps. import Migration0056 from "./Migrations/056_ProjectionProjectIcon.ts"; import Migration0057 from "./Migrations/057_ProjectionThreadsActiveOrderKey.ts"; import Migration0055 from "./Migrations/055_ProjectionThreadBranchPullRequest.ts"; +import Migration0058 from "./Migrations/058_ProjectionProjectsAutoPull.ts"; /** * Migration loader with all migrations defined inline. * @@ -165,6 +166,9 @@ export const migrationEntries = [ [56, "ProjectionProjectIcon", Migration0056], // Upstream #9729 used 49; Pylon already uses that id for pending stops. [57, "ProjectionThreadsActiveOrderKey", Migration0057], + // Upstream #9277 used 45; Pylon already uses that id for auth session client + // connections and its lineage runs through 57. + [58, "ProjectionProjectsAutoPull", Migration0058], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/058_ProjectionProjectsAutoPull.ts b/apps/server/src/persistence/Migrations/058_ProjectionProjectsAutoPull.ts new file mode 100644 index 000000000..3a0ca678f --- /dev/null +++ b/apps/server/src/persistence/Migrations/058_ProjectionProjectsAutoPull.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "auto_pull")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN auto_pull INTEGER NOT NULL DEFAULT 0 + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index fad4ebdae..e5d1f6ba1 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -27,6 +27,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + autoPull: Schema.Boolean, faviconPath: Schema.optional(Schema.NullOr(Schema.String)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), scripts: Schema.Array(ProjectScript), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ef741a9bd..d42e74e0c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -364,7 +364,12 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(GitWorkflowLayerLive), Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), - Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), + Layer.provideMerge( + VcsStatusBroadcaster.layer.pipe( + Layer.provide(GitWorkflowLayerLive), + Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), + ), + ), ); const CheckpointingLayerLive = Layer.empty.pipe( diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index ea396854a..3266ba452 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -14,6 +14,46 @@ import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; + +it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => + Effect.gen(function* () { + const pulled: string[] = []; + const git = { + statusDetails: (cwd: string) => + Effect.succeed({ + isRepo: true, + isDefaultBranch: cwd !== "/feature", + hasUpstream: true, + hasWorkingTreeChanges: cwd === "/dirty", + aheadCount: cwd === "/ahead" ? 1 : 0, + behindCount: cwd === "/current" ? 0 : 1, + } as never), + pullCurrentBranch: (cwd: string) => + Effect.sync(() => { + pulled.push(cwd); + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + } as unknown as GitVcsDriver.GitVcsDriver["Service"]; + const project = (workspaceRoot: string, autoPull = true) => + ({ workspaceRoot, autoPull }) as never; + + yield* ServerRuntimeStartup.autoPullProjects([ + project("/clean"), + project("/current"), + project("/dirty"), + project("/ahead"), + project("/feature"), + project("/disabled", false), + ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + + assert.deepStrictEqual(pulled, ["/clean"]); + }), +); it.effect("enqueueCommand waits for readiness and then drains queued work", () => Effect.scoped( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 30e7f93ed..cf0c914dd 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, type ModelSelection, + type OrchestrationProjectShell, ProjectId, ProviderInstanceId, ThreadId, @@ -42,6 +43,7 @@ import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReape import { RollbackSagaRepository } from "./persistence/Services/RollbackSagas.ts"; import { forkParked } from "./serverActivation.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -739,6 +741,65 @@ interface StartupOptions { readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect; } +export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( + projects: ReadonlyArray, +) { + const git = yield* GitVcsDriver.GitVcsDriver; + const workspaceRoots = [ + ...new Set( + projects + .filter((project) => project.autoPull === true) + .map((project) => project.workspaceRoot), + ), + ]; + + yield* Effect.forEach( + workspaceRoots, + (cwd) => + Effect.gen(function* () { + const status = yield* git.statusDetails(cwd); + if ( + !status.isRepo || + !status.isDefaultBranch || + !status.hasUpstream || + status.hasWorkingTreeChanges || + status.aheadCount > 0 + ) { + yield* Effect.logDebug("Skipped automatic project pull", { + cwd, + reason: !status.isRepo + ? "not-a-repository" + : !status.isDefaultBranch + ? "not-on-default-branch" + : !status.hasUpstream + ? "no-upstream" + : status.hasWorkingTreeChanges + ? "working-tree-changes" + : "local-commits", + }); + return; + } + + if (status.behindCount <= 0) return; + + const result = yield* git.pullCurrentBranch(cwd); + yield* Effect.logDebug("Automatic project pull completed", { + cwd, + status: result.status, + refName: result.refName, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Automatic project pull failed", { + cwd, + cause, + }), + ), + ), + { concurrency: 4, discard: true }, + ); +}); + export const make = (options?: StartupOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -757,6 +818,13 @@ export const make = (options?: StartupOptions) => const httpListening = yield* Deferred.make(); const reactorScope = yield* Scope.make("sequential"); + const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), + Effect.catch((cause) => + Effect.logWarning("Failed to load projects for automatic pull", { cause }), + ), + ); + yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); const startup = Effect.gen(function* () { @@ -801,6 +869,9 @@ export const make = (options?: StartupOptions) => yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); + yield* Effect.logDebug("startup phase: syncing clean projects"); + yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 7064645b0..3e34c979d 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -145,6 +145,67 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) } describe("VcsStatusBroadcaster", () => { + it.effect( + "automatically pulls an enabled clean default branch when status detects it is behind", + () => { + let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; + let pullCalls = 0; + let configuredWorkspaceRoot = ""; + const localStatus: VcsStatusLocalResult = { + ...baseLocalStatus, + isDefaultRef: true, + refName: "main", + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: (cwd) => Effect.succeed(cwd === configuredWorkspaceRoot), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(localStatus), + remoteStatus: () => Effect.succeed(remoteStatus), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls += 1; + remoteStatus = { ...remoteStatus, behindCount: 0 }; + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + }), + ), + ); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-auto-pull-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-auto-pull-link-", + }); + configuredWorkspaceRoot = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, configuredWorkspaceRoot); + + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const status = yield* broadcaster.refreshStatus(configuredWorkspaceRoot); + + assert.equal(pullCalls, 1); + assert.equal(status.behindCount, 0); + }).pipe(Effect.provide(testLayer)); + }, + ); + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 5bce806b1..011af8e3b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -25,6 +25,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -140,6 +141,26 @@ interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } +export class VcsAutoPullPolicy extends Context.Reference<{ + readonly isEnabled: (cwd: string) => Effect.Effect; +}>("t3/vcs/VcsAutoPullPolicy", { + defaultValue: () => ({ isEnabled: () => Effect.succeed(false) }), +}) {} + +export const autoPullPolicyLayer = Layer.effect( + VcsAutoPullPolicy, + Effect.gen(function* () { + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + return { + isEnabled: (cwd: string) => + snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( + Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), + Effect.orElseSucceed(() => false), + ), + }; + }), +); + export function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, @@ -190,6 +211,7 @@ const normalizeCwd = (cwd: string) => ); export const make = Effect.gen(function* () { + const autoPullPolicy = yield* VcsAutoPullPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const fs = yield* FileSystem.FileSystem; @@ -381,10 +403,53 @@ export const make = Effect.gen(function* () { return yield* refreshLocalStatusCore(cwd); }); + const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + policyCwds: ReadonlyArray, + ) { + return yield* Effect.gen(function* () { + const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { + concurrency: "unbounded", + })).some(Boolean); + if ( + remote === null || + !remote.hasUpstream || + remote.aheadCount > 0 || + remote.behindCount <= 0 || + !autoPullEnabled + ) { + return null; + } + + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; + + yield* workflow.pullCurrentBranch(cwd); + yield* workflow.invalidateStatus(cwd); + const [refreshedLocal, refreshedRemote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], + { concurrency: "unbounded" }, + ); + yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); + return { local: refreshedLocal, remote: refreshedRemote }; + }).pipe( + Effect.catch(() => + Effect.logWarning("Automatic project pull failed", { cwd }).pipe(Effect.as(null)), + ), + ); + }); + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( cwd: string, - options?: { readonly refreshUpstream?: boolean }, + options?: { + readonly refreshUpstream?: boolean; + readonly policyCwds?: ReadonlyArray; + }, ) { + // The pull writes to the checkout, so it runs under the same remote write + // lock as the status refresh rather than racing it. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { @@ -392,6 +457,8 @@ export const make = Effect.gen(function* () { yield* workflow.invalidateRemoteStatus(cwd); } const remote = yield* workflow.remoteStatus({ cwd }, options); + const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + if (pulled !== null) return pulled.remote; return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); @@ -411,6 +478,8 @@ export const make = Effect.gen(function* () { [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, ); + const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }), ); @@ -481,6 +550,7 @@ export const make = Effect.gen(function* () { const exit = yield* refreshRemoteStatus(cwd, { refreshUpstream: !Duration.isZero(configuredInterval), + policyCwds: [...demandCwds.keys()], }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { yield* Ref.set(needsInitialRefreshRef, false); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index e9d464a08..58a1aa2c2 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -99,6 +99,7 @@ import { } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; +import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -386,6 +387,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; + autoPull: boolean; faviconPath: string | null; projectIcon: ProjectIconOverride | null; }>, @@ -480,6 +482,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); + const autoPull = representative.autoPull ?? false; + const setAutoPull = useCallback( + (enabled: boolean) => + void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), + [updateAllMembers], + ); + // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); const [iconPickerOpen, setIconPickerOpen] = useState(false); @@ -988,6 +997,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> + setAutoPull(false)} /> + ) : null + } + control={ + + } + />