authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 18:29:07-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:29:07-04:00
logba3cb0773859334fe9f75016295bb3893c9e6044
tree2938feb30778205e092884e266ed835c5f91e475
parent91c8d4771ccb503a9dde65190b87db642df0a6ea
signature Signed by PGP key B5690EEEBB952194

feat(projects): automatically pull clean default branches (#9277)

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";
40import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts";40import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts";
41import * as ServerSettings from "../src/serverSettings.ts";41import * as ServerSettings from "../src/serverSettings.ts";
42import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts";42import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts";
43import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
4344
44const providerInstanceId = ProviderInstanceId.make("codex");45const providerInstanceId = ProviderInstanceId.make("codex");
45const projectId = ProjectId.make("project-startup-orphan");46const 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 !== undefined543 ...(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 !== undefined547 ...(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;
83const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(83const 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 !== undefined264 ...(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 !== undefined252 ...(payload.defaultThreadEnvMode !== undefined
252 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }253 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
253 : {}),254 : {}),
255 ...(payload.autoPull !== undefined ? { autoPull: payload.autoPull } : {}),
254 ...(payload.faviconPath !== undefined256 ...(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";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
6import * as Struct from "effect/Struct";7import * as Struct from "effect/Struct";
78
...@@ -18,6 +19,7 @@ import {...@@ -18,6 +19,7 @@ import {
18const ProjectionProjectDbRow = ProjectionProject.mapFields(19const 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* () {
126133
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 );
131139
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 );
136145
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";
56import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";56import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";57import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";58import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
5960
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;
116118
117export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);119export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/045_ProjectionProjectsAutoPull.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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);
344349
345const CheckpointingLayerLive = Layer.empty.pipe(350const 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
15import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";15import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
16import * as AnalyticsService from "./telemetry/AnalyticsService.ts";16import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
17import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";17import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
18import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
1819
19it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => {20it("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});
2526
27it.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
26it.effect("enqueueCommand waits for readiness and then drains queued work", () =>66it.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
40import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";41import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
41import { forkParked } from "./serverActivation.ts";42import { forkParked } from "./serverActivation.ts";
42import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";43import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
44import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
43import {45import {
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}
636638
639export 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
637export const make = (options?: StartupOptions) =>698export 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");
654715
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));
656724
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) =>
696764
697 yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions);765 yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions);
698766
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}
143143
144describe("VcsStatusBroadcaster", () => {144describe("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";
2424
25import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";25import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
26import * as GitWorkflowService from "../git/GitWorkflowService.ts";26import * as GitWorkflowService from "../git/GitWorkflowService.ts";
27import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
2728
28const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);29const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
29const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);30const 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}
141142
143export 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
149export 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
142export function remoteRefreshFailureDelay(163export 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 );
182203
183export const make = Effect.gen(function* () {204export 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 });
356378
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 });
367432
...@@ -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 });
381448
...@@ -416,6 +483,7 @@ export const make = Effect.gen(function* () {...@@ -416,6 +483,7 @@ export const make = Effect.gen(function* () {
416483
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";
97import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";97import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
98import { SidebarInset } from "../ui/sidebar";98import { SidebarInset } from "../ui/sidebar";
99import { Switch } from "../ui/switch";
99import { stackedThreadToast, toastManager } from "../ui/toast";100import { stackedThreadToast, toastManager } from "../ui/toast";
100import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";101import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
101import {102import {
...@@ -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 );
466468
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>
944964
945 <SettingsSection965 <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
14each checkout in the project group and appears on your connected clients.14each checkout in the project group and appears on your connected clients.
1515
16To use automatic detection again, select **Automatic**.16To use automatic detection again, select **Automatic**.
17
18## Keep the default branch current
19
20Turn on **Automatically pull** in a project's settings to keep its default-branch checkout current.
21T3 Code checks in the background and when the server starts. It uses the branch's configured
22upstream and only performs a fast-forward pull when the checkout has no working-tree changes,
23untracked files, or local commits.
24
25The pull is skipped if the checkout is on another branch, has no upstream, or contains local work.
26Pull 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 means291 // 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,