| author | |
| committer | |
| log | 8ac6846a0b855c3aad70adbf745ff113d4eb3f1f |
| tree | 531bb7453f6135f9f0f664eca7a0d1a38c8bac60 |
| parent | 243c9994d047f3102a9622d1a5cff42ef78515ee |
| signature | Signed by PGP key B5690EEEBB952194 |
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>83 files changed, 3568 insertions(+), 394 deletions(-)
apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx+6-1| ... | @@ -25,7 +25,11 @@ export function ScheduledTaskModelPickerRouteScreen() { | ... | @@ -25,7 +25,11 @@ export function ScheduledTaskModelPickerRouteScreen() { |
| 25 | const { editor, setEditor } = useScheduledTaskEditor(); | 25 | const { editor, setEditor } = useScheduledTaskEditor(); |
| 26 | const config = useEnvironmentServerConfig(editor?.environmentId ?? null); | 26 | const config = useEnvironmentServerConfig(editor?.environmentId ?? null); |
| 27 | const selectedModel = editor?.draft.modelSelection ?? null; | 27 | const selectedModel = editor?.draft.modelSelection ?? null; |
| 28 | const models = useMemo(() => buildModelOptions(config, selectedModel), [config, selectedModel]); | 28 | const projectId = editor?.draft.projectId ?? null; |
| 29 | const models = useMemo( | ||
| 30 | () => buildModelOptions(config, selectedModel, undefined, projectId), | ||
| 31 | [config, selectedModel, projectId], | ||
| 32 | ); | ||
| 29 | const providerGroups = useMemo(() => groupByProvider(models), [models]); | 33 | const providerGroups = useMemo(() => groupByProvider(models), [models]); |
| 30 | const selectedOption = models.find( | 34 | const selectedOption = models.find( |
| 31 | (option) => | 35 | (option) => |
| ... | @@ -46,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() { | ... | @@ -46,6 +50,7 @@ export function ScheduledTaskModelPickerRouteScreen() { |
| 46 | return ( | 50 | return ( |
| 47 | <ThreadSettingsPickerScreen | 51 | <ThreadSettingsPickerScreen |
| 48 | environmentId={editor.environmentId} | 52 | environmentId={editor.environmentId} |
| 53 | projectId={projectId} | ||
| 49 | providerGroups={providerGroups} | 54 | providerGroups={providerGroups} |
| 50 | selectedModel={selectedModel} | 55 | selectedModel={selectedModel} |
| 51 | onSelectModel={(option) => | 56 | onSelectModel={(option) => |
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+26-10| ... | @@ -1,5 +1,8 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | ||
| 4 | resolveProjectProviderInstanceEnabled, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 3 | import { readEnvironmentScope } from "../../state/session"; | 6 | import { readEnvironmentScope } from "../../state/session"; |
| 4 | import type { | 7 | import type { |
| 5 | EnvironmentId, | 8 | EnvironmentId, |
| ... | @@ -582,7 +585,10 @@ function TaskForm({ | ... | @@ -582,7 +585,10 @@ function TaskForm({ |
| 582 | ); | 585 | ); |
| 583 | const projects = useProjects().filter((project) => project.environmentId === environmentId); | 586 | const projects = useProjects().filter((project) => project.environmentId === environmentId); |
| 584 | const config = useEnvironmentServerConfig(environmentId); | 587 | const config = useEnvironmentServerConfig(environmentId); |
| 585 | const modelOptions = useMemo(() => buildModelOptions(config, null), [config]); | 588 | const modelOptions = useMemo( |
| 589 | () => buildModelOptions(config, null, undefined, draft.projectId), | ||
| 590 | [config, draft.projectId], | ||
| 591 | ); | ||
| 586 | const canOperate = useAtomValue( | 592 | const canOperate = useAtomValue( |
| 587 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), | 593 | serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), |
| 588 | ); | 594 | ); |
| ... | @@ -757,14 +763,24 @@ function TaskForm({ | ... | @@ -757,14 +763,24 @@ function TaskForm({ |
| 757 | }))} | 763 | }))} |
| 758 | onSelect={(id) => { | 764 | onSelect={(id) => { |
| 759 | const project = projects.find((item) => item.id === id); | 765 | const project = projects.find((item) => item.id === id); |
| 760 | if (project) | 766 | if (!project) return; |
| 761 | setDraft({ | 767 | const keepsExplicitModel = |
| 762 | ...draft, | 768 | draft.modelSelectionIsExplicit && |
| 763 | projectId: project.id, | 769 | (draft.modelSelection === null || |
| 764 | modelSelection: draft.modelSelectionIsExplicit | 770 | !config?.settings || |
| 765 | ? draft.modelSelection | 771 | resolveProjectProviderInstanceEnabled( |
| 766 | : scheduledTaskDefaultModel(config, project), | 772 | config.settings, |
| 767 | }); | 773 | project.id, |
| 774 | draft.modelSelection.instanceId, | ||
| 775 | )); | ||
| 776 | setDraft({ | ||
| 777 | ...draft, | ||
| 778 | projectId: project.id, | ||
| 779 | modelSelection: keepsExplicitModel | ||
| 780 | ? draft.modelSelection | ||
| 781 | : scheduledTaskDefaultModel(config, project), | ||
| 782 | modelSelectionIsExplicit: keepsExplicitModel, | ||
| 783 | }); | ||
| 768 | }} | 784 | }} |
| 769 | /> | 785 | /> |
| 770 | <PickerRow | 786 | <PickerRow |
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+7| ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | type ServerConfig, | 4 | type ServerConfig, |
| 5 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| 6 | ProjectId, | 7 | ProjectId, |
| 7 | ScheduledTaskId, | 8 | ScheduledTaskId, |
| ... | @@ -339,6 +340,12 @@ describe("scheduled task model defaults", () => { | ... | @@ -339,6 +340,12 @@ describe("scheduled task model defaults", () => { |
| 339 | resolve( | 340 | resolve( |
| 340 | { | 341 | { |
| 341 | ...config, | 342 | ...config, |
| 343 | settings: { | ||
| 344 | ...config.settings, | ||
| 345 | providerInstances: { | ||
| 346 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 347 | }, | ||
| 348 | }, | ||
| 342 | providers: config.providers.map((provider) => ({ ...provider, enabled: false })), | 349 | providers: config.providers.map((provider) => ({ ...provider, enabled: false })), |
| 343 | }, | 350 | }, |
| 344 | null, | 351 | null, |
apps/mobile/src/features/settings/scheduledTaskDraft.ts+8-3| ... | @@ -28,13 +28,18 @@ export function scheduledTaskDefaultModel( | ... | @@ -28,13 +28,18 @@ export function scheduledTaskDefaultModel( |
| 28 | const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings | 28 | const configured = resolveProjectSettings(settings, project?.id ?? null, project).settings |
| 29 | .defaultModelSelection; | 29 | .defaultModelSelection; |
| 30 | const projectDefaultSelection = | 30 | const projectDefaultSelection = |
| 31 | resolveDefaultableModelSelection(config, configured) ?? | 31 | resolveDefaultableModelSelection(config, configured, project?.id ?? null) ?? |
| 32 | resolveDefaultableModelSelection(config, settings.defaultModelSelection); | 32 | resolveDefaultableModelSelection(config, settings.defaultModelSelection, project?.id ?? null); |
| 33 | return resolveNewTaskModelSelection({ | 33 | return resolveNewTaskModelSelection({ |
| 34 | draftSelection: null, | 34 | draftSelection: null, |
| 35 | projectDefaultSelection, | 35 | projectDefaultSelection, |
| 36 | stickySelection: null, | 36 | stickySelection: null, |
| 37 | modelOptions: buildModelOptions(config, projectDefaultSelection), | 37 | modelOptions: buildModelOptions( |
| 38 | config, | ||
| 39 | projectDefaultSelection, | ||
| 40 | undefined, | ||
| 41 | project?.id ?? null, | ||
| 42 | ), | ||
| 38 | }); | 43 | }); |
| 39 | } | 44 | } |
| 40 | 45 |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+12-4| ... | @@ -112,6 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl"; | ... | @@ -112,6 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 112 | import { useAtomCommand } from "../../state/use-atom-command"; | 112 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 113 | import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; | 113 | import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; |
| 114 | import { | 114 | import { |
| 115 | describeUnavailableModelSelection, | ||
| 115 | isModelSelectionUnavailable, | 116 | isModelSelectionUnavailable, |
| 116 | resolveSelectableModelSelection, | 117 | resolveSelectableModelSelection, |
| 117 | } from "../../lib/modelOptions"; | 118 | } from "../../lib/modelOptions"; |
| ... | @@ -1236,6 +1237,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1236,6 +1237,7 @@ export function NewTaskDraftScreen(props: { |
| 1236 | resolveSelectableModelSelection( | 1237 | resolveSelectableModelSelection( |
| 1237 | selectedEnvironmentServerConfig, | 1238 | selectedEnvironmentServerConfig, |
| 1238 | draft.modelSelection ?? null, | 1239 | draft.modelSelection ?? null, |
| 1240 | selectedProject.id, | ||
| 1239 | ) ?? flow.selectedModel; | 1241 | ) ?? flow.selectedModel; |
| 1240 | const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; | 1242 | const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; |
| 1241 | const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; | 1243 | const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; |
| ... | @@ -1252,12 +1254,18 @@ export function NewTaskDraftScreen(props: { | ... | @@ -1252,12 +1254,18 @@ export function NewTaskDraftScreen(props: { |
| 1252 | } | 1254 | } |
| 1253 | if ( | 1255 | if ( |
| 1254 | environmentConnected && | 1256 | environmentConnected && |
| 1255 | isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection) | 1257 | isModelSelectionUnavailable( |
| 1258 | selectedEnvironmentServerConfig, | ||
| 1259 | modelSelection, | ||
| 1260 | selectedProject.id, | ||
| 1261 | ) | ||
| 1256 | ) { | 1262 | ) { |
| 1257 | Alert.alert( | 1263 | const { title, detail } = describeUnavailableModelSelection( |
| 1258 | "Antigravity model unavailable", | 1264 | selectedEnvironmentServerConfig, |
| 1259 | "Set up Antigravity on web or desktop, or choose another model.", | 1265 | modelSelection, |
| 1266 | selectedProject.id, | ||
| 1260 | ); | 1267 | ); |
| 1268 | Alert.alert(title, detail); | ||
| 1261 | return; | 1269 | return; |
| 1262 | } | 1270 | } |
| 1263 | // T3's own limits command is answered by the thread composer; a new task would | 1271 | // T3's own limits command is answered by the thread composer; a new task would |
apps/mobile/src/features/threads/ThreadComposer.tsx+20-3| ... | @@ -435,7 +435,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -435,7 +435,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 435 | const currentRuntimeMode = props.selectedThread.runtimeMode; | 435 | const currentRuntimeMode = props.selectedThread.runtimeMode; |
| 436 | const modelUnavailable = | 436 | const modelUnavailable = |
| 437 | props.connectionState === "connected" && | 437 | props.connectionState === "connected" && |
| 438 | isModelSelectionUnavailable(props.serverConfig, currentModelSelection); | 438 | isModelSelectionUnavailable( |
| 439 | props.serverConfig, | ||
| 440 | currentModelSelection, | ||
| 441 | props.selectedThread.projectId, | ||
| 442 | ); | ||
| 439 | const selectedProviderStatus = useMemo(() => { | 443 | const selectedProviderStatus = useMemo(() => { |
| 440 | if (!props.serverConfig) return null; | 444 | if (!props.serverConfig) return null; |
| 441 | return ( | 445 | return ( |
| ... | @@ -651,8 +655,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -651,8 +655,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 651 | ? undefined | 655 | ? undefined |
| 652 | : currentModelSelection.instanceId; | 656 | : currentModelSelection.instanceId; |
| 653 | const modelOptions = useMemo( | 657 | const modelOptions = useMemo( |
| 654 | () => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId), | 658 | () => |
| 655 | [props.serverConfig, currentModelSelection, lockedProviderInstanceId], | 659 | buildModelOptions( |
| 660 | props.serverConfig, | ||
| 661 | currentModelSelection, | ||
| 662 | lockedProviderInstanceId, | ||
| 663 | props.selectedThread.projectId, | ||
| 664 | ), | ||
| 665 | [ | ||
| 666 | props.serverConfig, | ||
| 667 | currentModelSelection, | ||
| 668 | lockedProviderInstanceId, | ||
| 669 | props.selectedThread.projectId, | ||
| 670 | ], | ||
| 656 | ); | 671 | ); |
| 657 | const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | 672 | const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 658 | const currentModelOption = | 673 | const currentModelOption = |
| ... | @@ -674,6 +689,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -674,6 +689,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 674 | () => ({ | 689 | () => ({ |
| 675 | ownerId: settingsOwnerId, | 690 | ownerId: settingsOwnerId, |
| 676 | environmentId: props.environmentId, | 691 | environmentId: props.environmentId, |
| 692 | projectId: props.selectedThread.projectId, | ||
| 677 | providerInstanceId: currentModelSelection.instanceId, | 693 | providerInstanceId: currentModelSelection.instanceId, |
| 678 | ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), | 694 | ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), |
| 679 | providerGroups: threadProviderGroups, | 695 | providerGroups: threadProviderGroups, |
| ... | @@ -703,6 +719,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -703,6 +719,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 703 | settingsOwnerId, | 719 | settingsOwnerId, |
| 704 | lockedProviderInstanceId, | 720 | lockedProviderInstanceId, |
| 705 | threadProviderGroups, | 721 | threadProviderGroups, |
| 722 | props.selectedThread.projectId, | ||
| 706 | ], | 723 | ], |
| 707 | ); | 724 | ); |
| 708 | const openSettings = useCallback(() => { | 725 | const openSettings = useCallback(() => { |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+11-1| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | import type { | 7 | import type { |
| 8 | EnvironmentId, | 8 | EnvironmentId, |
| 9 | ModelSelection, | 9 | ModelSelection, |
| 10 | ProjectId, | ||
| 10 | ProviderInstanceId, | 11 | ProviderInstanceId, |
| 11 | ProviderOptionDescriptor, | 12 | ProviderOptionDescriptor, |
| 12 | ProviderOptionSelection, | 13 | ProviderOptionSelection, |
| ... | @@ -252,6 +253,7 @@ type ThreadSettingsSubmenuPage = | ... | @@ -252,6 +253,7 @@ type ThreadSettingsSubmenuPage = |
| 252 | 253 | ||
| 253 | type ThreadSettingsSessionProps = { | 254 | type ThreadSettingsSessionProps = { |
| 254 | readonly environmentId: EnvironmentId | null; | 255 | readonly environmentId: EnvironmentId | null; |
| 256 | readonly projectId: ProjectId | null; | ||
| 255 | readonly providerInstanceId?: ProviderInstanceId; | 257 | readonly providerInstanceId?: ProviderInstanceId; |
| 256 | readonly lockedProviderInstanceId?: ProviderInstanceId; | 258 | readonly lockedProviderInstanceId?: ProviderInstanceId; |
| 257 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | 259 | readonly providerGroups: ReadonlyArray<ProviderGroup>; |
| ... | @@ -360,7 +362,13 @@ function ThreadSettingsSessionProvider( | ... | @@ -360,7 +362,13 @@ function ThreadSettingsSessionProvider( |
| 360 | () => | 362 | () => |
| 361 | manageHidden && config | 363 | manageHidden && config |
| 362 | ? groupByProvider( | 364 | ? groupByProvider( |
| 363 | buildModelOptions(config, props.selectedModel, props.lockedProviderInstanceId, true), | 365 | buildModelOptions( |
| 366 | config, | ||
| 367 | props.selectedModel, | ||
| 368 | props.lockedProviderInstanceId, | ||
| 369 | props.projectId, | ||
| 370 | true, | ||
| 371 | ), | ||
| 364 | ) | 372 | ) |
| 365 | : props.providerGroups, | 373 | : props.providerGroups, |
| 366 | [ | 374 | [ |
| ... | @@ -369,6 +377,7 @@ function ThreadSettingsSessionProvider( | ... | @@ -369,6 +377,7 @@ function ThreadSettingsSessionProvider( |
| 369 | props.providerGroups, | 377 | props.providerGroups, |
| 370 | props.selectedModel, | 378 | props.selectedModel, |
| 371 | props.lockedProviderInstanceId, | 379 | props.lockedProviderInstanceId, |
| 380 | props.projectId, | ||
| 372 | ], | 381 | ], |
| 373 | ); | 382 | ); |
| 374 | const legacyFavorites = AsyncResult.isSuccess(preferences) | 383 | const legacyFavorites = AsyncResult.isSuccess(preferences) |
| ... | @@ -1605,6 +1614,7 @@ export function NewTaskThreadSettingsRouteScreen() { | ... | @@ -1605,6 +1614,7 @@ export function NewTaskThreadSettingsRouteScreen() { |
| 1605 | return ( | 1614 | return ( |
| 1606 | <ThreadSettingsPickerScreen | 1615 | <ThreadSettingsPickerScreen |
| 1607 | environmentId={flow.selectedEnvironmentId} | 1616 | environmentId={flow.selectedEnvironmentId} |
| 1617 | projectId={flow.selectedProject?.id ?? null} | ||
| 1608 | {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})} | 1618 | {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})} |
| 1609 | providerGroups={flow.providerGroups} | 1619 | providerGroups={flow.providerGroups} |
| 1610 | selectedModel={flow.selectedModel} | 1620 | selectedModel={flow.selectedModel} |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+7| ... | @@ -581,27 +581,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -581,27 +581,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 581 | const draftModelSelection = resolveSelectableModelSelection( | 581 | const draftModelSelection = resolveSelectableModelSelection( |
| 582 | selectedEnvironmentServerConfig, | 582 | selectedEnvironmentServerConfig, |
| 583 | selectedProjectDraft.modelSelection ?? null, | 583 | selectedProjectDraft.modelSelection ?? null, |
| 584 | selectedProject?.id ?? null, | ||
| 584 | ); | 585 | ); |
| 585 | const projectDefaultModelSelection = resolveDefaultableModelSelection( | 586 | const projectDefaultModelSelection = resolveDefaultableModelSelection( |
| 586 | selectedEnvironmentServerConfig, | 587 | selectedEnvironmentServerConfig, |
| 587 | projectSettings.settings.defaultModelSelection, | 588 | projectSettings.settings.defaultModelSelection, |
| 589 | selectedProject?.id ?? null, | ||
| 588 | ); | 590 | ); |
| 589 | const storedStickyModelSelection = useStickyComposerModelSelection(); | 591 | const storedStickyModelSelection = useStickyComposerModelSelection(); |
| 590 | const stickyModelSelection = resolveDefaultableModelSelection( | 592 | const stickyModelSelection = resolveDefaultableModelSelection( |
| 591 | selectedEnvironmentServerConfig, | 593 | selectedEnvironmentServerConfig, |
| 592 | storedStickyModelSelection, | 594 | storedStickyModelSelection, |
| 595 | selectedProject?.id ?? null, | ||
| 593 | ); | 596 | ); |
| 594 | const modelOptions = useMemo( | 597 | const modelOptions = useMemo( |
| 595 | () => | 598 | () => |
| 596 | buildModelOptions( | 599 | buildModelOptions( |
| 597 | selectedEnvironmentServerConfig, | 600 | selectedEnvironmentServerConfig, |
| 598 | draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, | 601 | draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, |
| 602 | undefined, | ||
| 603 | selectedProject?.id ?? null, | ||
| 599 | ), | 604 | ), |
| 600 | [ | 605 | [ |
| 601 | selectedEnvironmentServerConfig, | 606 | selectedEnvironmentServerConfig, |
| 602 | draftModelSelection, | 607 | draftModelSelection, |
| 603 | projectDefaultModelSelection, | 608 | projectDefaultModelSelection, |
| 604 | stickyModelSelection, | 609 | stickyModelSelection, |
| 610 | selectedProject?.id, | ||
| 605 | ], | 611 | ], |
| 606 | ); | 612 | ); |
| 607 | 613 | ||
| ... | @@ -1117,6 +1123,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -1117,6 +1123,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 1117 | resolveSelectableModelSelection( | 1123 | resolveSelectableModelSelection( |
| 1118 | selectedEnvironmentServerConfig, | 1124 | selectedEnvironmentServerConfig, |
| 1119 | draft.modelSelection ?? null, | 1125 | draft.modelSelection ?? null, |
| 1126 | selectedProject.id, | ||
| 1120 | ) ?? selectedModel; | 1127 | ) ?? selectedModel; |
| 1121 | // A shared image or file is a task on its own; text is optional. | 1128 | // A shared image or file is a task on its own; text is optional. |
| 1122 | if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { | 1129 | if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { |
apps/mobile/src/lib/modelOptions.test.ts+305-59| ... | @@ -2,13 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -2,13 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | DEFAULT_SERVER_SETTINGS, | 4 | DEFAULT_SERVER_SETTINGS, |
| 5 | ProjectId, | ||
| 6 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 6 | type ModelSelection, | 8 | type ModelSelection, |
| 7 | type ServerConfig, | 9 | type ServerConfig, |
| 10 | type ServerSettings, | ||
| 8 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 9 | 12 | ||
| 10 | import { | 13 | import { |
| 11 | buildModelOptions, | 14 | buildModelOptions, |
| 15 | describeUnavailableModelSelection, | ||
| 12 | groupByProvider, | 16 | groupByProvider, |
| 13 | isModelSelectionUnavailable, | 17 | isModelSelectionUnavailable, |
| 14 | resolveDefaultableModelSelection, | 18 | resolveDefaultableModelSelection, |
| ... | @@ -47,7 +51,7 @@ describe("mobile model options", () => { | ... | @@ -47,7 +51,7 @@ describe("mobile model options", () => { |
| 47 | ], | 51 | ], |
| 48 | } as unknown as ServerConfig; | 52 | } as unknown as ServerConfig; |
| 49 | 53 | ||
| 50 | expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([ | 54 | expect(groupByProvider(buildModelOptions(config, null, undefined, null))).toMatchObject([ |
| 51 | { | 55 | { |
| 52 | providerKey: "codex", | 56 | providerKey: "codex", |
| 53 | providerLabel: "Codex", | 57 | providerLabel: "Codex", |
| ... | @@ -83,7 +87,7 @@ describe("mobile model options", () => { | ... | @@ -83,7 +87,7 @@ describe("mobile model options", () => { |
| 83 | ], | 87 | ], |
| 84 | } as unknown as ServerConfig; | 88 | } as unknown as ServerConfig; |
| 85 | 89 | ||
| 86 | const [group] = groupByProvider(buildModelOptions(config, null)); | 90 | const [group] = groupByProvider(buildModelOptions(config, null, undefined, null)); |
| 87 | 91 | ||
| 88 | expect(group).toMatchObject({ | 92 | expect(group).toMatchObject({ |
| 89 | providerKey: "acpRegistry_antigravity", | 93 | providerKey: "acpRegistry_antigravity", |
| ... | @@ -127,7 +131,7 @@ describe("mobile model options", () => { | ... | @@ -127,7 +131,7 @@ describe("mobile model options", () => { |
| 127 | model: "github-copilot/claude-fable-5", | 131 | model: "github-copilot/claude-fable-5", |
| 128 | }; | 132 | }; |
| 129 | 133 | ||
| 130 | const options = buildModelOptions(config, selection); | 134 | const options = buildModelOptions(config, selection, undefined, null); |
| 131 | 135 | ||
| 132 | expect(options).toMatchObject( | 136 | expect(options).toMatchObject( |
| 133 | sources.map((source) => ({ | 137 | sources.map((source) => ({ |
| ... | @@ -181,26 +185,41 @@ describe("mobile model options", () => { | ... | @@ -181,26 +185,41 @@ describe("mobile model options", () => { |
| 181 | ], | 185 | ], |
| 182 | } as unknown as ServerConfig; | 186 | } as unknown as ServerConfig; |
| 183 | 187 | ||
| 184 | const [option] = buildModelOptions(config, { | 188 | const [option] = buildModelOptions( |
| 185 | instanceId: ProviderInstanceId.make("codex"), | 189 | config, |
| 186 | model: "gpt-test", | 190 | { |
| 187 | }); | 191 | instanceId: ProviderInstanceId.make("codex"), |
| 192 | model: "gpt-test", | ||
| 193 | }, | ||
| 194 | undefined, | ||
| 195 | null, | ||
| 196 | ); | ||
| 188 | 197 | ||
| 189 | expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); | 198 | expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); |
| 190 | expect(option?.selection.options).toBeUndefined(); | 199 | expect(option?.selection.options).toBeUndefined(); |
| 191 | 200 | ||
| 192 | const [emptyOption] = buildModelOptions(config, { | 201 | const [emptyOption] = buildModelOptions( |
| 193 | instanceId: ProviderInstanceId.make("codex"), | 202 | config, |
| 194 | model: "gpt-test", | 203 | { |
| 195 | options: [], | 204 | instanceId: ProviderInstanceId.make("codex"), |
| 196 | }); | 205 | model: "gpt-test", |
| 206 | options: [], | ||
| 207 | }, | ||
| 208 | undefined, | ||
| 209 | null, | ||
| 210 | ); | ||
| 197 | expect(emptyOption?.selection).toEqual(option?.selection); | 211 | expect(emptyOption?.selection).toEqual(option?.selection); |
| 198 | 212 | ||
| 199 | const [explicitOption] = buildModelOptions(config, { | 213 | const [explicitOption] = buildModelOptions( |
| 200 | instanceId: ProviderInstanceId.make("codex"), | 214 | config, |
| 201 | model: "gpt-test", | 215 | { |
| 202 | options: [{ id: "serviceTier", value: "priority" }], | 216 | instanceId: ProviderInstanceId.make("codex"), |
| 203 | }); | 217 | model: "gpt-test", |
| 218 | options: [{ id: "serviceTier", value: "priority" }], | ||
| 219 | }, | ||
| 220 | undefined, | ||
| 221 | null, | ||
| 222 | ); | ||
| 204 | expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]); | 223 | expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]); |
| 205 | }); | 224 | }); |
| 206 | 225 | ||
| ... | @@ -216,12 +235,13 @@ describe("mobile model options", () => { | ... | @@ -216,12 +235,13 @@ describe("mobile model options", () => { |
| 216 | const config = { providers } as unknown as ServerConfig; | 235 | const config = { providers } as unknown as ServerConfig; |
| 217 | const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; | 236 | const selection = { instanceId: ProviderInstanceId.make("codex"), model: "test" }; |
| 218 | 237 | ||
| 219 | expect(buildModelOptions(config, selection).map((option) => option.providerKey)).toEqual([ | 238 | expect( |
| 220 | "codex", | 239 | buildModelOptions(config, selection, undefined, null).map((option) => option.providerKey), |
| 221 | "claudeAgent", | 240 | ).toEqual(["codex", "claudeAgent"]); |
| 222 | ]); | 241 | expect(buildModelOptions(config, selection, selection.instanceId, null)).toEqual( |
| 223 | expect(buildModelOptions(config, selection, selection.instanceId)).toEqual( | 242 | buildModelOptions(config, selection, undefined, null).filter( |
| 224 | buildModelOptions(config, selection).filter((option) => option.providerKey === "codex"), | 243 | (option) => option.providerKey === "codex", |
| 244 | ), | ||
| 225 | ); | 245 | ); |
| 226 | }); | 246 | }); |
| 227 | 247 | ||
| ... | @@ -247,8 +267,8 @@ describe("mobile model options", () => { | ... | @@ -247,8 +267,8 @@ describe("mobile model options", () => { |
| 247 | providers: state === "missing" ? [] : [provider], | 267 | providers: state === "missing" ? [] : [provider], |
| 248 | settings: { providerInstances: { google_work: { driver: "antigravity" } } }, | 268 | settings: { providerInstances: { google_work: { driver: "antigravity" } } }, |
| 249 | } as unknown as ServerConfig; | 269 | } as unknown as ServerConfig; |
| 250 | const options = buildModelOptions(config, selection, selection.instanceId); | 270 | const options = buildModelOptions(config, selection, selection.instanceId, null); |
| 251 | expect(options).toEqual(buildModelOptions(config, selection)); | 271 | expect(options).toEqual(buildModelOptions(config, selection, undefined, null)); |
| 252 | expect(options).toHaveLength(1); | 272 | expect(options).toHaveLength(1); |
| 253 | expect(options[0]).toMatchObject({ selection, isUnavailable: true }); | 273 | expect(options[0]).toMatchObject({ selection, isUnavailable: true }); |
| 254 | }, | 274 | }, |
| ... | @@ -289,12 +309,15 @@ describe("mobile model options", () => { | ... | @@ -289,12 +309,15 @@ describe("mobile model options", () => { |
| 289 | model: "gpt-5.6-sol", | 309 | model: "gpt-5.6-sol", |
| 290 | }; | 310 | }; |
| 291 | 311 | ||
| 292 | expect(resolveSelectableModelSelection(config, usable)).toBe(usable); | 312 | expect(resolveSelectableModelSelection(config, usable, null)).toBe(usable); |
| 293 | expect(resolveSelectableModelSelection(config, disabled)).toBeNull(); | 313 | expect(resolveSelectableModelSelection(config, disabled, null)).toBeNull(); |
| 294 | expect(resolveSelectableModelSelection(config, removed)).toBeNull(); | 314 | expect(resolveSelectableModelSelection(config, removed, null)).toBeNull(); |
| 295 | expect(isModelSelectionUnavailable(config, disabled)).toBe(false); | 315 | // Disabled is disabled even with no settings to consult; only a provider |
| 316 | // that is merely missing or unauthenticated falls through silently. | ||
| 317 | expect(isModelSelectionUnavailable(config, disabled, null)).toBe(true); | ||
| 318 | expect(isModelSelectionUnavailable(config, removed, null)).toBe(false); | ||
| 296 | // An offline environment has no config to validate. | 319 | // An offline environment has no config to validate. |
| 297 | expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); | 320 | expect(resolveSelectableModelSelection(null, disabled, null)).toBe(disabled); |
| 298 | }); | 321 | }); |
| 299 | 322 | ||
| 300 | describe("Antigravity selections", () => { | 323 | describe("Antigravity selections", () => { |
| ... | @@ -347,11 +370,11 @@ describe("mobile model options", () => { | ... | @@ -347,11 +370,11 @@ describe("mobile model options", () => { |
| 347 | providers: config.providers.map((provider) => ({ ...provider, ...update })), | 370 | providers: config.providers.map((provider) => ({ ...provider, ...update })), |
| 348 | }; | 371 | }; |
| 349 | 372 | ||
| 350 | expect(resolveSelectableModelSelection(unavailableConfig, selection)).toBe(selection); | 373 | expect(resolveSelectableModelSelection(unavailableConfig, selection, null)).toBe(selection); |
| 351 | expect(resolveDefaultableModelSelection(unavailableConfig, selection)).toBe(selection); | 374 | expect(resolveDefaultableModelSelection(unavailableConfig, selection, null)).toBe(selection); |
| 352 | expect(isModelSelectionUnavailable(unavailableConfig, selection)).toBe(true); | 375 | expect(isModelSelectionUnavailable(unavailableConfig, selection, null)).toBe(true); |
| 353 | expect(buildModelOptions(unavailableConfig, null)).toEqual([]); | 376 | expect(buildModelOptions(unavailableConfig, null, undefined, null)).toEqual([]); |
| 354 | const [option] = buildModelOptions(unavailableConfig, selection); | 377 | const [option] = buildModelOptions(unavailableConfig, selection, undefined, null); |
| 355 | expect(option).toMatchObject({ | 378 | expect(option).toMatchObject({ |
| 356 | key: `google_work:${selection.model}`, | 379 | key: `google_work:${selection.model}`, |
| 357 | label: model.name, | 380 | label: model.name, |
| ... | @@ -376,9 +399,9 @@ describe("mobile model options", () => { | ... | @@ -376,9 +399,9 @@ describe("mobile model options", () => { |
| 376 | })), | 399 | })), |
| 377 | }; | 400 | }; |
| 378 | 401 | ||
| 379 | expect(resolveDefaultableModelSelection(changedConfig, selection)).toBe(selection); | 402 | expect(resolveDefaultableModelSelection(changedConfig, selection, null)).toBe(selection); |
| 380 | expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true); | 403 | expect(isModelSelectionUnavailable(changedConfig, selection, null)).toBe(true); |
| 381 | const options = buildModelOptions(changedConfig, selection); | 404 | const options = buildModelOptions(changedConfig, selection, undefined, null); |
| 382 | const missing = options.find((option) => option.selection.model === selection.model); | 405 | const missing = options.find((option) => option.selection.model === selection.model); |
| 383 | expect(missing).toMatchObject({ | 406 | expect(missing).toMatchObject({ |
| 384 | label: selection.model, | 407 | label: selection.model, |
| ... | @@ -391,18 +414,20 @@ describe("mobile model options", () => { | ... | @@ -391,18 +414,20 @@ describe("mobile model options", () => { |
| 391 | expect( | 414 | expect( |
| 392 | resolveNewTaskModelSelection({ | 415 | resolveNewTaskModelSelection({ |
| 393 | draftSelection: null, | 416 | draftSelection: null, |
| 394 | projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection), | 417 | projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection, null), |
| 395 | stickySelection: null, | 418 | stickySelection: null, |
| 396 | modelOptions: options, | 419 | modelOptions: options, |
| 397 | }), | 420 | }), |
| 398 | ).toBe(selection); | 421 | ).toBe(selection); |
| 399 | 422 | ||
| 400 | const [restored] = buildModelOptions(config, selection); | 423 | const [restored] = buildModelOptions(config, selection, undefined, null); |
| 401 | expect(isModelSelectionUnavailable(config, selection)).toBe(false); | 424 | expect(isModelSelectionUnavailable(config, selection, null)).toBe(false); |
| 402 | expect(restored?.isUnavailable).not.toBe(true); | 425 | expect(restored?.isUnavailable).not.toBe(true); |
| 403 | expect(restored?.selection).toBe(selection); | 426 | expect(restored?.selection).toBe(selection); |
| 404 | expect(resolveDefaultableModelSelection(config, selection)).toBe(selection); | 427 | expect(resolveDefaultableModelSelection(config, selection, null)).toBe(selection); |
| 405 | expect(buildModelOptions(config, null)[0]?.selection.options).toBeUndefined(); | 428 | expect( |
| 429 | buildModelOptions(config, null, undefined, null)[0]?.selection.options, | ||
| 430 | ).toBeUndefined(); | ||
| 406 | }); | 431 | }); |
| 407 | 432 | ||
| 408 | it("uses configured instance metadata when provider status is missing", () => { | 433 | it("uses configured instance metadata when provider status is missing", () => { |
| ... | @@ -415,9 +440,11 @@ describe("mobile model options", () => { | ... | @@ -415,9 +440,11 @@ describe("mobile model options", () => { |
| 415 | }, | 440 | }, |
| 416 | } as unknown as ServerConfig; | 441 | } as unknown as ServerConfig; |
| 417 | 442 | ||
| 418 | expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection); | 443 | expect(resolveDefaultableModelSelection(missingStatusConfig, selection, null)).toBe( |
| 419 | expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true); | 444 | selection, |
| 420 | expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([ | 445 | ); |
| 446 | expect(isModelSelectionUnavailable(missingStatusConfig, selection, null)).toBe(true); | ||
| 447 | expect(buildModelOptions(missingStatusConfig, selection, undefined, null)).toMatchObject([ | ||
| 421 | { | 448 | { |
| 422 | providerDriver: "antigravity", | 449 | providerDriver: "antigravity", |
| 423 | providerLabel: "Google Work", | 450 | providerLabel: "Google Work", |
| ... | @@ -430,12 +457,12 @@ describe("mobile model options", () => { | ... | @@ -430,12 +457,12 @@ describe("mobile model options", () => { |
| 430 | it("keeps offline selections without assuming that an unknown instance is Antigravity", () => { | 457 | it("keeps offline selections without assuming that an unknown instance is Antigravity", () => { |
| 431 | const unknownConfig = { ...config, providers: [] }; | 458 | const unknownConfig = { ...config, providers: [] }; |
| 432 | 459 | ||
| 433 | expect(resolveDefaultableModelSelection(null, selection)).toBe(selection); | 460 | expect(resolveDefaultableModelSelection(null, selection, null)).toBe(selection); |
| 434 | expect(isModelSelectionUnavailable(null, selection)).toBe(false); | 461 | expect(isModelSelectionUnavailable(null, selection, null)).toBe(false); |
| 435 | expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection); | 462 | expect(buildModelOptions(null, selection, undefined, null)[0]?.selection).toBe(selection); |
| 436 | expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true); | 463 | expect(buildModelOptions(null, selection, undefined, null)[0]?.isUnavailable).not.toBe(true); |
| 437 | expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false); | 464 | expect(isModelSelectionUnavailable(unknownConfig, selection, null)).toBe(false); |
| 438 | expect(resolveSelectableModelSelection(unknownConfig, selection)).toBeNull(); | 465 | expect(resolveSelectableModelSelection(unknownConfig, selection, null)).toBeNull(); |
| 439 | }); | 466 | }); |
| 440 | }); | 467 | }); |
| 441 | 468 | ||
| ... | @@ -466,11 +493,11 @@ describe("mobile model options", () => { | ... | @@ -466,11 +493,11 @@ describe("mobile model options", () => { |
| 466 | const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; | 493 | const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; |
| 467 | const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; | 494 | const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; |
| 468 | 495 | ||
| 469 | expect(resolveDefaultableModelSelection(config, current)).toBe(current); | 496 | expect(resolveDefaultableModelSelection(config, current, null)).toBe(current); |
| 470 | // A legacy last-used selection falls through to the provider default. | 497 | // A legacy last-used selection falls through to the provider default. |
| 471 | expect(resolveDefaultableModelSelection(config, legacy)).toBeNull(); | 498 | expect(resolveDefaultableModelSelection(config, legacy, null)).toBeNull(); |
| 472 | // Offline: nothing to validate against, selection passes through. | 499 | // Offline: nothing to validate against, selection passes through. |
| 473 | expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); | 500 | expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy); |
| 474 | }); | 501 | }); |
| 475 | 502 | ||
| 476 | it("resolves new tasks from draft, project, sticky, then provider defaults", () => { | 503 | it("resolves new tasks from draft, project, sticky, then provider defaults", () => { |
| ... | @@ -508,6 +535,217 @@ describe("mobile model options", () => { | ... | @@ -508,6 +535,217 @@ describe("mobile model options", () => { |
| 508 | }), | 535 | }), |
| 509 | ).toBeNull(); | 536 | ).toBeNull(); |
| 510 | }); | 537 | }); |
| 538 | |||
| 539 | describe("project-aware enablement", () => { | ||
| 540 | const projectId = ProjectId.make("project-a"); | ||
| 541 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 542 | |||
| 543 | function configWith(settings: ServerSettings): ServerConfig { | ||
| 544 | return { | ||
| 545 | providers: [ | ||
| 546 | { | ||
| 547 | instanceId, | ||
| 548 | driver: "codex", | ||
| 549 | displayName: "Codex Work", | ||
| 550 | enabled: true, | ||
| 551 | installed: true, | ||
| 552 | auth: { status: "authenticated" }, | ||
| 553 | models: [{ slug: "model-a", name: "Model A", isCustom: false, capabilities: null }], | ||
| 554 | }, | ||
| 555 | ], | ||
| 556 | settings, | ||
| 557 | } as unknown as ServerConfig; | ||
| 558 | } | ||
| 559 | |||
| 560 | it("words a project's own turn-off apart from missing setup", () => { | ||
| 561 | const settings: ServerSettings = { | ||
| 562 | ...DEFAULT_SERVER_SETTINGS, | ||
| 563 | providerInstances: { | ||
| 564 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 565 | }, | ||
| 566 | projectSettingsOverrides: { | ||
| 567 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 568 | }, | ||
| 569 | }; | ||
| 570 | const selection = { instanceId, model: "model-a" }; | ||
| 571 | |||
| 572 | expect( | ||
| 573 | describeUnavailableModelSelection(configWith(settings), selection, projectId).title, | ||
| 574 | ).toBe("Model turned off for this project"); | ||
| 575 | expect(describeUnavailableModelSelection(configWith(settings), selection, null).title).toBe( | ||
| 576 | "Antigravity model unavailable", | ||
| 577 | ); | ||
| 578 | }); | ||
| 579 | |||
| 580 | it("refuses to send a non-Antigravity model the project turned off", () => { | ||
| 581 | const settings: ServerSettings = { | ||
| 582 | ...DEFAULT_SERVER_SETTINGS, | ||
| 583 | providerInstances: { | ||
| 584 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 585 | }, | ||
| 586 | projectSettingsOverrides: { | ||
| 587 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 588 | }, | ||
| 589 | }; | ||
| 590 | const selection = { instanceId, model: "model-a" }; | ||
| 591 | |||
| 592 | expect(isModelSelectionUnavailable(configWith(settings), selection, projectId)).toBe(true); | ||
| 593 | expect(isModelSelectionUnavailable(configWith(settings), selection, null)).toBe(false); | ||
| 594 | }); | ||
| 595 | |||
| 596 | it("hides an instance the project disables even though the machine enables it", () => { | ||
| 597 | const settings: ServerSettings = { | ||
| 598 | ...DEFAULT_SERVER_SETTINGS, | ||
| 599 | providerInstances: { | ||
| 600 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 601 | }, | ||
| 602 | projectSettingsOverrides: { | ||
| 603 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 604 | }, | ||
| 605 | }; | ||
| 606 | |||
| 607 | expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]); | ||
| 608 | }); | ||
| 609 | |||
| 610 | it("shows an instance the project enables even though the machine disables it", () => { | ||
| 611 | const settings: ServerSettings = { | ||
| 612 | ...DEFAULT_SERVER_SETTINGS, | ||
| 613 | providerInstances: { | ||
| 614 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 615 | }, | ||
| 616 | projectSettingsOverrides: { | ||
| 617 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 618 | }, | ||
| 619 | }; | ||
| 620 | |||
| 621 | expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toHaveLength(1); | ||
| 622 | }); | ||
| 623 | |||
| 624 | it("falls back to the machine value with no project in scope", () => { | ||
| 625 | const settings: ServerSettings = { | ||
| 626 | ...DEFAULT_SERVER_SETTINGS, | ||
| 627 | providerInstances: { | ||
| 628 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 629 | }, | ||
| 630 | projectSettingsOverrides: { | ||
| 631 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 632 | }, | ||
| 633 | }; | ||
| 634 | |||
| 635 | expect(buildModelOptions(configWith(settings), null, undefined, null)).toHaveLength(1); | ||
| 636 | }); | ||
| 637 | |||
| 638 | it("hides a machine-disabled instance for a project with no override, even though another project's keep-alive left it running", () => { | ||
| 639 | const otherProjectId = ProjectId.make("project-b"); | ||
| 640 | const settings: ServerSettings = { | ||
| 641 | ...DEFAULT_SERVER_SETTINGS, | ||
| 642 | providerInstances: { | ||
| 643 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 644 | }, | ||
| 645 | projectSettingsOverrides: { | ||
| 646 | [otherProjectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 647 | }, | ||
| 648 | }; | ||
| 649 | |||
| 650 | // configWith's snapshot always reports enabled: true, standing in for | ||
| 651 | // the server keeping the instance alive to serve project-b's override. | ||
| 652 | expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]); | ||
| 653 | }); | ||
| 654 | |||
| 655 | it("refuses to send an instance the project merely inherits as disabled, not just an explicit override", () => { | ||
| 656 | const settings: ServerSettings = { | ||
| 657 | ...DEFAULT_SERVER_SETTINGS, | ||
| 658 | providerInstances: { | ||
| 659 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 660 | }, | ||
| 661 | }; | ||
| 662 | const selection = { instanceId, model: "model-a" }; | ||
| 663 | |||
| 664 | // configWith's snapshot always reports enabled: true, standing in for | ||
| 665 | // the server keeping the instance alive for another project's keep-alive. | ||
| 666 | expect(isModelSelectionUnavailable(configWith(settings), selection, projectId)).toBe(true); | ||
| 667 | expect( | ||
| 668 | describeUnavailableModelSelection(configWith(settings), selection, projectId).title, | ||
| 669 | ).toBe("Model turned off for this project"); | ||
| 670 | // With no project in scope, the machine value applies and is disabled. | ||
| 671 | expect(isModelSelectionUnavailable(configWith(settings), selection, null)).toBe(true); | ||
| 672 | }); | ||
| 673 | |||
| 674 | it("marks a project-disabled instance's fallback selection unavailable instead of reviving it", () => { | ||
| 675 | const settings: ServerSettings = { | ||
| 676 | ...DEFAULT_SERVER_SETTINGS, | ||
| 677 | providerInstances: { | ||
| 678 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 679 | }, | ||
| 680 | projectSettingsOverrides: { | ||
| 681 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 682 | }, | ||
| 683 | }; | ||
| 684 | const fallback = { instanceId, model: "model-a" }; | ||
| 685 | |||
| 686 | const options = buildModelOptions(configWith(settings), fallback, undefined, projectId); | ||
| 687 | |||
| 688 | expect(options).toHaveLength(1); | ||
| 689 | expect(options[0]).toMatchObject({ selection: fallback, isUnavailable: true }); | ||
| 690 | }); | ||
| 691 | |||
| 692 | it("rejects a project-disabled instance's selection via resolveSelectableModelSelection", () => { | ||
| 693 | const settings: ServerSettings = { | ||
| 694 | ...DEFAULT_SERVER_SETTINGS, | ||
| 695 | providerInstances: { | ||
| 696 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 697 | }, | ||
| 698 | projectSettingsOverrides: { | ||
| 699 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 700 | }, | ||
| 701 | }; | ||
| 702 | const selection = { instanceId, model: "model-a" }; | ||
| 703 | |||
| 704 | expect( | ||
| 705 | resolveSelectableModelSelection(configWith(settings), selection, projectId), | ||
| 706 | ).toBeNull(); | ||
| 707 | expect(resolveSelectableModelSelection(configWith(settings), selection, null)).toBe( | ||
| 708 | selection, | ||
| 709 | ); | ||
| 710 | }); | ||
| 711 | |||
| 712 | it("rejects a project-disabled antigravity instance's selection instead of keeping it", () => { | ||
| 713 | const antigravityInstanceId = ProviderInstanceId.make("antigravity_work"); | ||
| 714 | const config = { | ||
| 715 | providers: [ | ||
| 716 | { | ||
| 717 | instanceId: antigravityInstanceId, | ||
| 718 | driver: "antigravity", | ||
| 719 | displayName: "Antigravity Work", | ||
| 720 | enabled: true, | ||
| 721 | installed: true, | ||
| 722 | auth: { status: "authenticated" }, | ||
| 723 | availability: "available", | ||
| 724 | models: [{ slug: "model-a", name: "Model A", isCustom: false, capabilities: null }], | ||
| 725 | }, | ||
| 726 | ], | ||
| 727 | settings: { | ||
| 728 | ...DEFAULT_SERVER_SETTINGS, | ||
| 729 | providerInstances: { | ||
| 730 | [antigravityInstanceId]: { | ||
| 731 | driver: ProviderDriverKind.make("antigravity"), | ||
| 732 | enabled: true, | ||
| 733 | }, | ||
| 734 | }, | ||
| 735 | projectSettingsOverrides: { | ||
| 736 | [projectId]: { providerInstances: { [antigravityInstanceId]: { enabled: false } } }, | ||
| 737 | }, | ||
| 738 | }, | ||
| 739 | } as unknown as ServerConfig; | ||
| 740 | const selection = { instanceId: antigravityInstanceId, model: "model-a" }; | ||
| 741 | |||
| 742 | expect(resolveSelectableModelSelection(config, selection, projectId)).toBeNull(); | ||
| 743 | expect(resolveSelectableModelSelection(config, selection, null)).toBe(selection); | ||
| 744 | expect( | ||
| 745 | resolveSelectableModelSelection({ ...config, providers: [] }, selection, projectId), | ||
| 746 | ).toBeNull(); | ||
| 747 | }); | ||
| 748 | }); | ||
| 511 | }); | 749 | }); |
| 512 | 750 | ||
| 513 | describe("synchronized mobile model visibility", () => { | 751 | describe("synchronized mobile model visibility", () => { |
| ... | @@ -522,6 +760,13 @@ describe("synchronized mobile model visibility", () => { | ... | @@ -522,6 +760,13 @@ describe("synchronized mobile model visibility", () => { |
| 522 | const config = { | 760 | const config = { |
| 523 | settings: { | 761 | settings: { |
| 524 | ...DEFAULT_SERVER_SETTINGS, | 762 | ...DEFAULT_SERVER_SETTINGS, |
| 763 | // A live snapshot entry always has a matching configured instance (or | ||
| 764 | // is a driver's own default id); these are stand-ins for neither, so | ||
| 765 | // the enablement fixture needs its own entry to read as enabled. | ||
| 766 | providerInstances: { | ||
| 767 | [provider]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 768 | [other]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 769 | }, | ||
| 525 | providerModelPreferences: { | 770 | providerModelPreferences: { |
| 526 | [provider]: { | 771 | [provider]: { |
| 527 | hiddenModels: ["hidden", "custom", "missing"], | 772 | hiddenModels: ["hidden", "custom", "missing"], |
| ... | @@ -540,7 +785,7 @@ describe("synchronized mobile model visibility", () => { | ... | @@ -540,7 +785,7 @@ describe("synchronized mobile model visibility", () => { |
| 540 | } as unknown as ServerConfig; | 785 | } as unknown as ServerConfig; |
| 541 | 786 | ||
| 542 | it("applies server visibility and ordering independently to each provider instance", () => { | 787 | it("applies server visibility and ordering independently to each provider instance", () => { |
| 543 | const options = buildModelOptions(config, null); | 788 | const options = buildModelOptions(config, null, undefined, null); |
| 544 | expect( | 789 | expect( |
| 545 | options | 790 | options |
| 546 | .filter((option) => option.providerKey === provider) | 791 | .filter((option) => option.providerKey === provider) |
| ... | @@ -558,6 +803,7 @@ describe("synchronized mobile model visibility", () => { | ... | @@ -558,6 +803,7 @@ describe("synchronized mobile model visibility", () => { |
| 558 | config, | 803 | config, |
| 559 | { instanceId: provider, model: "hidden" }, | 804 | { instanceId: provider, model: "hidden" }, |
| 560 | provider, | 805 | provider, |
| 806 | null, | ||
| 561 | true, | 807 | true, |
| 562 | ); | 808 | ); |
| 563 | expect(options.map((option) => option.selection.model)).toEqual([ | 809 | expect(options.map((option) => option.selection.model)).toEqual([ |
| ... | @@ -578,7 +824,7 @@ describe("synchronized mobile model visibility", () => { | ... | @@ -578,7 +824,7 @@ describe("synchronized mobile model visibility", () => { |
| 578 | 824 | ||
| 579 | it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => { | 825 | it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => { |
| 580 | expect( | 826 | expect( |
| 581 | buildModelOptions(config, { instanceId: provider, model }, provider).map( | 827 | buildModelOptions(config, { instanceId: provider, model }, provider, null).map( |
| 582 | (option) => option.selection.model, | 828 | (option) => option.selection.model, |
| 583 | ), | 829 | ), |
| 584 | ).toEqual(["second", "first", "custom"]); | 830 | ).toEqual(["second", "first", "custom"]); |
| ... | @@ -586,13 +832,13 @@ describe("synchronized mobile model visibility", () => { | ... | @@ -586,13 +832,13 @@ describe("synchronized mobile model visibility", () => { |
| 586 | 832 | ||
| 587 | it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { | 833 | it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { |
| 588 | expect( | 834 | expect( |
| 589 | buildModelOptions(config, { instanceId: provider, model: "custom" }, provider).map( | 835 | buildModelOptions(config, { instanceId: provider, model: "custom" }, provider, null).map( |
| 590 | (option) => option.selection.model, | 836 | (option) => option.selection.model, |
| 591 | ), | 837 | ), |
| 592 | ).toContain("custom"); | 838 | ).toContain("custom"); |
| 593 | const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } }; | 839 | const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } }; |
| 594 | expect( | 840 | expect( |
| 595 | buildModelOptions(cleared, null, provider).map((option) => option.selection.model), | 841 | buildModelOptions(cleared, null, provider, null).map((option) => option.selection.model), |
| 596 | ).toEqual(["hidden", "first", "second", "custom"]); | 842 | ).toEqual(["hidden", "first", "second", "custom"]); |
| 597 | }); | 843 | }); |
| 598 | }); | 844 | }); |
apps/mobile/src/lib/modelOptions.ts+113-8| ... | @@ -2,9 +2,11 @@ import type { MenuAction } from "@react-native-menu/menu"; | ... | @@ -2,9 +2,11 @@ import type { MenuAction } from "@react-native-menu/menu"; |
| 2 | import type { | 2 | import type { |
| 3 | ModelCapabilities, | 3 | ModelCapabilities, |
| 4 | ModelSelection, | 4 | ModelSelection, |
| 5 | ProjectId, | ||
| 5 | RuntimeMode, | 6 | RuntimeMode, |
| 6 | ServerConfig as T3ServerConfig, | 7 | ServerConfig as T3ServerConfig, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { resolveProjectProviderInstanceEnabled } from "@t3tools/contracts"; | ||
| 8 | import { | 10 | import { |
| 9 | buildExplicitProviderOptionSelectionsFromDescriptors, | 11 | buildExplicitProviderOptionSelectionsFromDescriptors, |
| 10 | getProviderOptionDescriptors, | 12 | getProviderOptionDescriptors, |
| ... | @@ -54,6 +56,67 @@ function providerDisplayLabel(provider: { | ... | @@ -54,6 +56,67 @@ function providerDisplayLabel(provider: { |
| 54 | return provider.instanceId; | 56 | return provider.instanceId; |
| 55 | } | 57 | } |
| 56 | 58 | ||
| 59 | /** | ||
| 60 | * `provider.enabled` reflects the machine plus any project's keep-alive | ||
| 61 | * override, so it alone can't tell this project apart from one that merely | ||
| 62 | * shares the instance. Settings carry the real per-project view the server | ||
| 63 | * itself enforces, so a project with no override of its own must resolve | ||
| 64 | * from there, not from a snapshot another project may be keeping alive. | ||
| 65 | */ | ||
| 66 | function isProviderEnabledForProject( | ||
| 67 | config: T3ServerConfig | null | undefined, | ||
| 68 | provider: | ||
| 69 | | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean } | ||
| 70 | | undefined, | ||
| 71 | projectId: ProjectId | null, | ||
| 72 | ): boolean { | ||
| 73 | if (!provider) { | ||
| 74 | return false; | ||
| 75 | } | ||
| 76 | return config?.settings | ||
| 77 | ? resolveProjectProviderInstanceEnabled(config.settings, projectId, provider.instanceId) | ||
| 78 | : provider.enabled; | ||
| 79 | } | ||
| 80 | |||
| 81 | /** | ||
| 82 | * Whether the project has explicitly turned this instance off, as opposed | ||
| 83 | * to it merely being disabled or unreachable at the machine level. | ||
| 84 | */ | ||
| 85 | function isProjectDisabledOverride( | ||
| 86 | config: T3ServerConfig | null | undefined, | ||
| 87 | instanceId: ModelSelection["instanceId"], | ||
| 88 | projectId: ProjectId | null, | ||
| 89 | ): boolean { | ||
| 90 | if (!projectId || !config?.settings) { | ||
| 91 | return false; | ||
| 92 | } | ||
| 93 | return ( | ||
| 94 | config.settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId] | ||
| 95 | ?.enabled === false | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | /** | ||
| 100 | * Whether the project cannot use this instance at all, whether the project | ||
| 101 | * turned it off itself or it merely inherits a machine-level disable. Either | ||
| 102 | * way the server's `ThreadManagementService.dispatch` refuses a send, and the | ||
| 103 | * instance may still look installed and running in the snapshot because | ||
| 104 | * another project's keep-alive left it up. | ||
| 105 | */ | ||
| 106 | function isDisabledForProject( | ||
| 107 | config: T3ServerConfig | null | undefined, | ||
| 108 | provider: | ||
| 109 | | { readonly instanceId: ModelSelection["instanceId"]; readonly enabled: boolean } | ||
| 110 | | undefined, | ||
| 111 | instanceId: ModelSelection["instanceId"], | ||
| 112 | projectId: ProjectId | null, | ||
| 113 | ): boolean { | ||
| 114 | if (isProjectDisabledOverride(config, instanceId, projectId)) { | ||
| 115 | return true; | ||
| 116 | } | ||
| 117 | return provider !== undefined && !isProviderEnabledForProject(config, provider, projectId); | ||
| 118 | } | ||
| 119 | |||
| 57 | function normalizeSelectionOptions( | 120 | function normalizeSelectionOptions( |
| 58 | selection: ModelSelection, | 121 | selection: ModelSelection, |
| 59 | capabilities: ModelCapabilities | null, | 122 | capabilities: ModelCapabilities | null, |
| ... | @@ -79,10 +142,19 @@ function normalizeSelectionOptions( | ... | @@ -79,10 +142,19 @@ function normalizeSelectionOptions( |
| 79 | }; | 142 | }; |
| 80 | } | 143 | } |
| 81 | 144 | ||
| 82 | /** Whether a known Antigravity selection needs setup or a different model. */ | 145 | /** |
| 146 | * Whether the selection cannot be sent as is: the instance resolves disabled | ||
| 147 | * for the project (its own override, or an inherited machine-level disable), | ||
| 148 | * or a known Antigravity selection needs setup or a different model. | ||
| 149 | * | ||
| 150 | * `projectId` has no default: a caller that forgets it would silently check | ||
| 151 | * only the machine-wide state and miss a project that turned this instance | ||
| 152 | * off. Pass `null` explicitly outside a project's scope. | ||
| 153 | */ | ||
| 83 | export function isModelSelectionUnavailable( | 154 | export function isModelSelectionUnavailable( |
| 84 | config: T3ServerConfig | null | undefined, | 155 | config: T3ServerConfig | null | undefined, |
| 85 | selection: ModelSelection | null | undefined, | 156 | selection: ModelSelection | null | undefined, |
| 157 | projectId: ProjectId | null, | ||
| 86 | ): boolean { | 158 | ): boolean { |
| 87 | if (!config || !selection) { | 159 | if (!config || !selection) { |
| 88 | return false; | 160 | return false; |
| ... | @@ -90,12 +162,14 @@ export function isModelSelectionUnavailable( | ... | @@ -90,12 +162,14 @@ export function isModelSelectionUnavailable( |
| 90 | const provider = config.providers.find( | 162 | const provider = config.providers.find( |
| 91 | (candidate) => candidate.instanceId === selection.instanceId, | 163 | (candidate) => candidate.instanceId === selection.instanceId, |
| 92 | ); | 164 | ); |
| 165 | if (isDisabledForProject(config, provider, selection.instanceId, projectId)) { | ||
| 166 | return true; | ||
| 167 | } | ||
| 93 | const driver = | 168 | const driver = |
| 94 | provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; | 169 | provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; |
| 95 | return ( | 170 | return ( |
| 96 | driver === "antigravity" && | 171 | driver === "antigravity" && |
| 97 | (!provider || | 172 | (!provider || |
| 98 | !provider.enabled || | ||
| 99 | !provider.installed || | 173 | !provider.installed || |
| 100 | provider.auth.status === "unauthenticated" || | 174 | provider.auth.status === "unauthenticated" || |
| 101 | provider.availability === "unavailable" || | 175 | provider.availability === "unavailable" || |
| ... | @@ -103,14 +177,37 @@ export function isModelSelectionUnavailable( | ... | @@ -103,14 +177,37 @@ export function isModelSelectionUnavailable( |
| 103 | ); | 177 | ); |
| 104 | } | 178 | } |
| 105 | 179 | ||
| 180 | /** Why `isModelSelectionUnavailable` refused a selection, worded for the recovery that applies. */ | ||
| 181 | export function describeUnavailableModelSelection( | ||
| 182 | config: T3ServerConfig | null | undefined, | ||
| 183 | selection: ModelSelection, | ||
| 184 | projectId: ProjectId | null, | ||
| 185 | ): { readonly title: string; readonly detail: string } { | ||
| 186 | const provider = config?.providers.find( | ||
| 187 | (candidate) => candidate.instanceId === selection.instanceId, | ||
| 188 | ); | ||
| 189 | return isDisabledForProject(config, provider, selection.instanceId, projectId) | ||
| 190 | ? { | ||
| 191 | title: "Model turned off for this project", | ||
| 192 | detail: "Turn it back on from web or desktop, or choose another model.", | ||
| 193 | } | ||
| 194 | : { | ||
| 195 | title: "Antigravity model unavailable", | ||
| 196 | detail: "Set up Antigravity on web or desktop, or choose another model.", | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | |||
| 106 | /** | 200 | /** |
| 107 | * Keep Antigravity selections when setup or catalog changes make them | 201 | * Keep Antigravity selections when setup or catalog changes make them |
| 108 | * unavailable. Other providers fall through to the server default when they | 202 | * unavailable. Other providers fall through to the server default when they |
| 109 | * are disabled, missing, or signed out. Without config, keep stored selections. | 203 | * are disabled, missing, or signed out. Without config, keep stored selections. |
| 204 | * | ||
| 205 | * `projectId` has no default; see `isModelSelectionUnavailable`. | ||
| 110 | */ | 206 | */ |
| 111 | export function resolveSelectableModelSelection( | 207 | export function resolveSelectableModelSelection( |
| 112 | config: T3ServerConfig | null | undefined, | 208 | config: T3ServerConfig | null | undefined, |
| 113 | selection: ModelSelection | null, | 209 | selection: ModelSelection | null, |
| 210 | projectId: ProjectId | null, | ||
| 114 | ): ModelSelection | null { | 211 | ): ModelSelection | null { |
| 115 | if (!selection || !config) { | 212 | if (!selection || !config) { |
| 116 | return selection; | 213 | return selection; |
| ... | @@ -121,10 +218,10 @@ export function resolveSelectableModelSelection( | ... | @@ -121,10 +218,10 @@ export function resolveSelectableModelSelection( |
| 121 | const driver = | 218 | const driver = |
| 122 | provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; | 219 | provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; |
| 123 | if (driver === "antigravity") { | 220 | if (driver === "antigravity") { |
| 124 | return selection; | 221 | return isProjectDisabledOverride(config, selection.instanceId, projectId) ? null : selection; |
| 125 | } | 222 | } |
| 126 | return provider && | 223 | return provider && |
| 127 | provider.enabled && | 224 | isProviderEnabledForProject(config, provider, projectId) && |
| 128 | provider.installed && | 225 | provider.installed && |
| 129 | provider.auth.status !== "unauthenticated" | 226 | provider.auth.status !== "unauthenticated" |
| 130 | ? selection | 227 | ? selection |
| ... | @@ -135,12 +232,15 @@ export function resolveSelectableModelSelection( | ... | @@ -135,12 +232,15 @@ export function resolveSelectableModelSelection( |
| 135 | * Reject legacy models for implicit defaults, except Antigravity selections, | 232 | * Reject legacy models for implicit defaults, except Antigravity selections, |
| 136 | * which must not silently change after a catalog update. Explicit picks in | 233 | * which must not silently change after a catalog update. Explicit picks in |
| 137 | * the settings sheet are unaffected. | 234 | * the settings sheet are unaffected. |
| 235 | * | ||
| 236 | * `projectId` has no default; see `isModelSelectionUnavailable`. | ||
| 138 | */ | 237 | */ |
| 139 | export function resolveDefaultableModelSelection( | 238 | export function resolveDefaultableModelSelection( |
| 140 | config: T3ServerConfig | null | undefined, | 239 | config: T3ServerConfig | null | undefined, |
| 141 | selection: ModelSelection | null, | 240 | selection: ModelSelection | null, |
| 241 | projectId: ProjectId | null, | ||
| 142 | ): ModelSelection | null { | 242 | ): ModelSelection | null { |
| 143 | const usable = resolveSelectableModelSelection(config, selection); | 243 | const usable = resolveSelectableModelSelection(config, selection, projectId); |
| 144 | if (!usable || !config) { | 244 | if (!usable || !config) { |
| 145 | return usable; | 245 | return usable; |
| 146 | } | 246 | } |
| ... | @@ -165,10 +265,14 @@ export function resolveNewTaskModelSelection(input: { | ... | @@ -165,10 +265,14 @@ export function resolveNewTaskModelSelection(input: { |
| 165 | ); | 265 | ); |
| 166 | } | 266 | } |
| 167 | 267 | ||
| 268 | /** | ||
| 269 | * `projectId` has no default; see `isModelSelectionUnavailable`. | ||
| 270 | */ | ||
| 168 | export function buildModelOptions( | 271 | export function buildModelOptions( |
| 169 | config: T3ServerConfig | null | undefined, | 272 | config: T3ServerConfig | null | undefined, |
| 170 | fallbackModelSelection: ModelSelection | null, | 273 | fallbackModelSelection: ModelSelection | null, |
| 171 | providerInstanceId?: ModelSelection["instanceId"], | 274 | providerInstanceId: ModelSelection["instanceId"] | undefined, |
| 275 | projectId: ProjectId | null, | ||
| 172 | includeHidden = false, | 276 | includeHidden = false, |
| 173 | ): ReadonlyArray<ModelOption> { | 277 | ): ReadonlyArray<ModelOption> { |
| 174 | const options = new Map<string, ModelOption>(); | 278 | const options = new Map<string, ModelOption>(); |
| ... | @@ -176,7 +280,7 @@ export function buildModelOptions( | ... | @@ -176,7 +280,7 @@ export function buildModelOptions( |
| 176 | for (const provider of config?.providers ?? []) { | 280 | for (const provider of config?.providers ?? []) { |
| 177 | if ( | 281 | if ( |
| 178 | (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || | 282 | (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || |
| 179 | !provider.enabled || | 283 | !isProviderEnabledForProject(config, provider, projectId) || |
| 180 | !provider.installed || | 284 | !provider.installed || |
| 181 | provider.auth.status === "unauthenticated" || | 285 | provider.auth.status === "unauthenticated" || |
| 182 | (provider.driver === "antigravity" && provider.availability === "unavailable") | 286 | (provider.driver === "antigravity" && provider.availability === "unavailable") |
| ... | @@ -271,7 +375,8 @@ export function buildModelOptions( | ... | @@ -271,7 +375,8 @@ export function buildModelOptions( |
| 271 | providerDriver, | 375 | providerDriver, |
| 272 | isDefault: false, | 376 | isDefault: false, |
| 273 | isLegacy: model?.isLegacy === true, | 377 | isLegacy: model?.isLegacy === true, |
| 274 | ...(isModelSelectionUnavailable(config, fallbackModelSelection) || | 378 | ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) || |
| 379 | (provider !== undefined && !isProviderEnabledForProject(config, provider, projectId)) || | ||
| 275 | provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model) | 380 | provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model) |
| 276 | ? { isUnavailable: true } | 381 | ? { isUnavailable: true } |
| 277 | : {}), | 382 | : {}), |
apps/mobile/src/state/use-thread-composer-state.ts+10-5| ... | @@ -39,7 +39,10 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li | ... | @@ -39,7 +39,10 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li |
| 39 | import { uuidv4 } from "../lib/uuid"; | 39 | import { uuidv4 } from "../lib/uuid"; |
| 40 | 40 | ||
| 41 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | 41 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| 42 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; | 42 | import { |
| 43 | describeUnavailableModelSelection, | ||
| 44 | isModelSelectionUnavailable, | ||
| 45 | } from "../lib/modelOptions"; | ||
| 43 | import { resolveProviderInteractionMode } from "./legacy-plan-mode"; | 46 | import { resolveProviderInteractionMode } from "./legacy-plan-mode"; |
| 44 | import { | 47 | import { |
| 45 | convertPastedImagesToAttachments, | 48 | convertPastedImagesToAttachments, |
| ... | @@ -616,12 +619,14 @@ export function useThreadComposerState() { | ... | @@ -616,12 +619,14 @@ export function useThreadComposerState() { |
| 616 | const serverConfig = selectedEnvironmentRuntime?.serverConfig; | 619 | const serverConfig = selectedEnvironmentRuntime?.serverConfig; |
| 617 | if ( | 620 | if ( |
| 618 | selectedEnvironmentRuntime?.connectionState === "connected" && | 621 | selectedEnvironmentRuntime?.connectionState === "connected" && |
| 619 | isModelSelectionUnavailable(serverConfig, modelSelection) | 622 | isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId) |
| 620 | ) { | 623 | ) { |
| 621 | Alert.alert( | 624 | const { title, detail } = describeUnavailableModelSelection( |
| 622 | "Antigravity model unavailable", | 625 | serverConfig, |
| 623 | "Set up Antigravity on web or desktop, or choose another model.", | 626 | modelSelection, |
| 627 | thread.projectId, | ||
| 624 | ); | 628 | ); |
| 629 | Alert.alert(title, detail); | ||
| 625 | return null; | 630 | return null; |
| 626 | } | 631 | } |
| 627 | const provider = serverConfig?.providers.find( | 632 | const provider = serverConfig?.providers.find( |
apps/mobile/src/state/use-thread-outbox-drain.ts+28-17| ... | @@ -26,7 +26,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn" | ... | @@ -26,7 +26,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn" |
| 26 | import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; | 26 | import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; |
| 27 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; | 27 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; |
| 28 | import { randomHex } from "../lib/uuid"; | 28 | import { randomHex } from "../lib/uuid"; |
| 29 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; | 29 | import { |
| 30 | describeUnavailableModelSelection, | ||
| 31 | isModelSelectionUnavailable, | ||
| 32 | } from "../lib/modelOptions"; | ||
| 30 | import { | 33 | import { |
| 31 | retainAcknowledgedThreadMessage, | 34 | retainAcknowledgedThreadMessage, |
| 32 | forgetAcknowledgedThreadMessage, | 35 | forgetAcknowledgedThreadMessage, |
| ... | @@ -811,11 +814,13 @@ export function useThreadOutboxDrain(): void { | ... | @@ -811,11 +814,13 @@ export function useThreadOutboxDrain(): void { |
| 811 | ); | 814 | ); |
| 812 | if (!serverConfig) return false; | 815 | if (!serverConfig) return false; |
| 813 | const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); | 816 | const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); |
| 814 | if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { | 817 | if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) { |
| 815 | return restoreQueuedMessage( | 818 | const { title, detail } = describeUnavailableModelSelection( |
| 816 | queuedMessage, | 819 | serverConfig, |
| 817 | "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", | 820 | settings.modelSelection, |
| 821 | thread.projectId, | ||
| 818 | ); | 822 | ); |
| 823 | return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); | ||
| 819 | } | 824 | } |
| 820 | const { reportFailure } = makeDeliveryHelpers(queuedMessage); | 825 | const { reportFailure } = makeDeliveryHelpers(queuedMessage); |
| 821 | 826 | ||
| ... | @@ -893,11 +898,13 @@ export function useThreadOutboxDrain(): void { | ... | @@ -893,11 +898,13 @@ export function useThreadOutboxDrain(): void { |
| 893 | serverEnvironment.configValueAtom(queuedMessage.environmentId), | 898 | serverEnvironment.configValueAtom(queuedMessage.environmentId), |
| 894 | ); | 899 | ); |
| 895 | if (!currentConfig) return false; | 900 | if (!currentConfig) return false; |
| 896 | if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { | 901 | if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) { |
| 897 | return restoreQueuedMessage( | 902 | const { title, detail } = describeUnavailableModelSelection( |
| 898 | persistedMessage, | 903 | currentConfig, |
| 899 | "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", | 904 | settings.modelSelection, |
| 905 | thread.projectId, | ||
| 900 | ); | 906 | ); |
| 907 | return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); | ||
| 901 | } | 908 | } |
| 902 | const sendSettings = resolveQueuedThreadSettings( | 909 | const sendSettings = resolveQueuedThreadSettings( |
| 903 | queuedMessage, | 910 | queuedMessage, |
| ... | @@ -990,11 +997,13 @@ export function useThreadOutboxDrain(): void { | ... | @@ -990,11 +997,13 @@ export function useThreadOutboxDrain(): void { |
| 990 | }, | 997 | }, |
| 991 | serverConfig.providers, | 998 | serverConfig.providers, |
| 992 | ); | 999 | ); |
| 993 | if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { | 1000 | if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, creation.projectId)) { |
| 994 | return restoreQueuedMessage( | 1001 | const { title, detail } = describeUnavailableModelSelection( |
| 995 | queuedMessage, | 1002 | serverConfig, |
| 996 | "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", | 1003 | settings.modelSelection, |
| 1004 | creation.projectId, | ||
| 997 | ); | 1005 | ); |
| 1006 | return restoreQueuedMessage(queuedMessage, `${title}. ${detail}`); | ||
| 998 | } | 1007 | } |
| 999 | let prepared: PreparedTurnAttachments; | 1008 | let prepared: PreparedTurnAttachments; |
| 1000 | let persistedMessage: QueuedThreadMessage; | 1009 | let persistedMessage: QueuedThreadMessage; |
| ... | @@ -1035,11 +1044,13 @@ export function useThreadOutboxDrain(): void { | ... | @@ -1035,11 +1044,13 @@ export function useThreadOutboxDrain(): void { |
| 1035 | serverEnvironment.configValueAtom(queuedMessage.environmentId), | 1044 | serverEnvironment.configValueAtom(queuedMessage.environmentId), |
| 1036 | ); | 1045 | ); |
| 1037 | if (!currentConfig) return false; | 1046 | if (!currentConfig) return false; |
| 1038 | if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { | 1047 | if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, creation.projectId)) { |
| 1039 | return restoreQueuedMessage( | 1048 | const { title, detail } = describeUnavailableModelSelection( |
| 1040 | persistedMessage, | 1049 | currentConfig, |
| 1041 | "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", | 1050 | settings.modelSelection, |
| 1051 | creation.projectId, | ||
| 1042 | ); | 1052 | ); |
| 1053 | return restoreQueuedMessage(persistedMessage, `${title}. ${detail}`); | ||
| 1043 | } | 1054 | } |
| 1044 | const sendSettings = resolveQueuedThreadSettings( | 1055 | const sendSettings = resolveQueuedThreadSettings( |
| 1045 | queuedMessage, | 1056 | queuedMessage, |
apps/server/src/cli/project.test.ts+3| ... | @@ -25,6 +25,7 @@ import * as References from "effect/References"; | ... | @@ -25,6 +25,7 @@ import * as References from "effect/References"; |
| 25 | import * as Stream from "effect/Stream"; | 25 | import * as Stream from "effect/Stream"; |
| 26 | import { Command } from "effect/cli"; | 26 | import { Command } from "effect/cli"; |
| 27 | 27 | ||
| 28 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 28 | import { cli } from "../binCli.ts"; | 29 | import { cli } from "../binCli.ts"; |
| 29 | import * as ServerConfig from "../config.ts"; | 30 | import * as ServerConfig from "../config.ts"; |
| 30 | import * as EventSink from "../orchestration-v2/EventSink.ts"; | 31 | import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| ... | @@ -37,6 +38,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | ... | @@ -37,6 +38,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 37 | import * as ProjectService from "../project/ProjectService.ts"; | 38 | import * as ProjectService from "../project/ProjectService.ts"; |
| 38 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | 39 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 39 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; | 40 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 41 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 40 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 42 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 41 | import { | 43 | import { |
| 42 | ProjectLiveServerDeclaredResponseError, | 44 | ProjectLiveServerDeclaredResponseError, |
| ... | @@ -93,6 +95,7 @@ const readProjects = (baseDir: string) => | ... | @@ -93,6 +95,7 @@ const readProjects = (baseDir: string) => |
| 93 | Layer.provideMerge(ProjectFaviconResolver.layer), | 95 | Layer.provideMerge(ProjectFaviconResolver.layer), |
| 94 | Layer.provideMerge(T3ProjectFileLoader.layer), | 96 | Layer.provideMerge(T3ProjectFileLoader.layer), |
| 95 | Layer.provideMerge(WorkspacePaths.layer), | 97 | Layer.provideMerge(WorkspacePaths.layer), |
| 98 | Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), | ||
| 96 | Layer.provideMerge(SqlitePersistence.layerConfig), | 99 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 97 | Layer.provideMerge(NodeServices.layer), | 100 | Layer.provideMerge(NodeServices.layer), |
| 98 | Layer.provide(ServerConfig.layer(config)), | 101 | Layer.provide(ServerConfig.layer(config)), |
apps/server/src/cli/project.ts+3| ... | @@ -22,6 +22,7 @@ import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http"; | ... | @@ -22,6 +22,7 @@ import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http"; |
| 22 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; | 22 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 23 | 23 | ||
| 24 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 24 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 25 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 25 | 26 | ||
| 26 | import * as ServerConfig from "../config.ts"; | 27 | import * as ServerConfig from "../config.ts"; |
| 27 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 28 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| ... | @@ -32,6 +33,7 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv | ... | @@ -32,6 +33,7 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv |
| 32 | import * as ProjectService from "../project/ProjectService.ts"; | 33 | import * as ProjectService from "../project/ProjectService.ts"; |
| 33 | import { projectMutationOperation } from "../project/ProjectMutation.ts"; | 34 | import { projectMutationOperation } from "../project/ProjectMutation.ts"; |
| 34 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; | 35 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 36 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 35 | import { | 37 | import { |
| 36 | clearPersistedServerRuntimeState, | 38 | clearPersistedServerRuntimeState, |
| 37 | readPersistedServerRuntimeState, | 39 | readPersistedServerRuntimeState, |
| ... | @@ -206,6 +208,7 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( | ... | @@ -206,6 +208,7 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( |
| 206 | ), | 208 | ), |
| 207 | ), | 209 | ), |
| 208 | Layer.provideMerge(WorkspacePaths.layer), | 210 | Layer.provideMerge(WorkspacePaths.layer), |
| 211 | Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), | ||
| 209 | Layer.provideMerge(SqlitePersistence.layerConfig), | 212 | Layer.provideMerge(SqlitePersistence.layerConfig), |
| 210 | ); | 213 | ); |
| 211 | 214 |
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | @@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ... | @@ -222,6 +222,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 222 | expect(second.capabilities.threadPullRequests).toBe(true); | 222 | expect(second.capabilities.threadPullRequests).toBe(true); |
| 223 | expect(second.capabilities.threadPullRequestLinking).toBe(true); | 223 | expect(second.capabilities.threadPullRequestLinking).toBe(true); |
| 224 | expect(second.capabilities.serverResolvedCommandContext).toBe(true); | 224 | expect(second.capabilities.serverResolvedCommandContext).toBe(true); |
| 225 | expect(second.capabilities.projectProviderInstanceOverrides).toBe(true); | ||
| 225 | expect(second.capabilities.agentActivityPublishing).toBe(false); | 226 | expect(second.capabilities.agentActivityPublishing).toBe(false); |
| 226 | }), | 227 | }), |
| 227 | ); | 228 | ); |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -234,6 +234,7 @@ export const make = Effect.gen(function* () { | ... | @@ -234,6 +234,7 @@ export const make = Effect.gen(function* () { |
| 234 | worktreesDirectory: true, | 234 | worktreesDirectory: true, |
| 235 | threadRestartContinuation: true, | 235 | threadRestartContinuation: true, |
| 236 | projectSettingsOverrides: true, | 236 | projectSettingsOverrides: true, |
| 237 | projectProviderInstanceOverrides: true, | ||
| 237 | threadSnooze: true, | 238 | threadSnooze: true, |
| 238 | environmentThemes: true, | 239 | environmentThemes: true, |
| 239 | usageLimitSources: true, | 240 | usageLimitSources: true, |
apps/server/src/git/GitManager.ts+7-5| ... | @@ -731,7 +731,8 @@ export const make = Effect.gen(function* () { | ... | @@ -731,7 +731,8 @@ export const make = Effect.gen(function* () { |
| 731 | readonly threadId?: ThreadId | undefined; | 731 | readonly threadId?: ThreadId | undefined; |
| 732 | }) { | 732 | }) { |
| 733 | const settings = yield* serverSettingsService.getSettings; | 733 | const settings = yield* serverSettingsService.getSettings; |
| 734 | if (!hasProjectSettingsOverrides(settings)) return settings; | 734 | if (!hasProjectSettingsOverrides(settings)) |
| 735 | return { settings, projectId: null as ProjectId | null }; | ||
| 735 | const projectId: ProjectId | null = yield* input.threadId !== undefined | 736 | const projectId: ProjectId | null = yield* input.threadId !== undefined |
| 736 | ? threads.getThreadShell(input.threadId).pipe( | 737 | ? threads.getThreadShell(input.threadId).pipe( |
| 737 | Effect.map((thread) => thread?.projectId ?? null), | 738 | Effect.map((thread) => thread?.projectId ?? null), |
| ... | @@ -741,7 +742,7 @@ export const make = Effect.gen(function* () { | ... | @@ -741,7 +742,7 @@ export const make = Effect.gen(function* () { |
| 741 | Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), | 742 | Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), |
| 742 | Effect.orElseSucceed(() => null), | 743 | Effect.orElseSucceed(() => null), |
| 743 | ); | 744 | ); |
| 744 | return resolveProjectSettings(settings, projectId).settings; | 745 | return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; |
| 745 | }); | 746 | }); |
| 746 | // Best effort: a settings read failure falls back to the default location. | 747 | // Best effort: a settings read failure falls back to the default location. |
| 747 | const readWorktreesDirectory = serverSettingsService.getSettings.pipe( | 748 | const readWorktreesDirectory = serverSettingsService.getSettings.pipe( |
| ... | @@ -755,7 +756,7 @@ export const make = Effect.gen(function* () { | ... | @@ -755,7 +756,7 @@ export const make = Effect.gen(function* () { |
| 755 | options?.submodules !== undefined | 756 | options?.submodules !== undefined |
| 756 | ? options.submodules | 757 | ? options.submodules |
| 757 | : yield* projectSettingsFor(input).pipe( | 758 | : yield* projectSettingsFor(input).pipe( |
| 758 | Effect.map((settings) => settings.worktreeSubmodules), | 759 | Effect.map(({ settings }) => settings.worktreeSubmodules), |
| 759 | Effect.orElseSucceed(() => null), | 760 | Effect.orElseSucceed(() => null), |
| 760 | ); | 761 | ); |
| 761 | const worktreesDirectory = yield* readWorktreesDirectory; | 762 | const worktreesDirectory = yield* readWorktreesDirectory; |
| ... | @@ -2798,7 +2799,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2798,7 +2799,7 @@ export const make = Effect.gen(function* () { |
| 2798 | worktreesDirectory: yield* readWorktreesDirectory, | 2799 | worktreesDirectory: yield* readWorktreesDirectory, |
| 2799 | // Best effort: a settings read failure falls back to the checkout's t3.json. | 2800 | // Best effort: a settings read failure falls back to the checkout's t3.json. |
| 2800 | submodules: yield* projectSettingsFor(input).pipe( | 2801 | submodules: yield* projectSettingsFor(input).pipe( |
| 2801 | Effect.map((settings) => settings.worktreeSubmodules), | 2802 | Effect.map(({ settings }) => settings.worktreeSubmodules), |
| 2802 | Effect.orElseSucceed(() => null), | 2803 | Effect.orElseSucceed(() => null), |
| 2803 | ), | 2804 | ), |
| 2804 | }, | 2805 | }, |
| ... | @@ -2910,7 +2911,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2910,7 +2911,7 @@ export const make = Effect.gen(function* () { |
| 2910 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | 2911 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; |
| 2911 | 2912 | ||
| 2912 | const textGenerationSettings = yield* projectSettingsFor(input).pipe( | 2913 | const textGenerationSettings = yield* projectSettingsFor(input).pipe( |
| 2913 | Effect.flatMap((settings) => | 2914 | Effect.flatMap(({ settings, projectId }) => |
| 2914 | settings.sourceControlWriterModelSelection === null | 2915 | settings.sourceControlWriterModelSelection === null |
| 2915 | ? Effect.succeed({ | 2916 | ? Effect.succeed({ |
| 2916 | modelSelection: settings.textGenerationModelSelection, | 2917 | modelSelection: settings.textGenerationModelSelection, |
| ... | @@ -2921,6 +2922,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2921,6 +2922,7 @@ export const make = Effect.gen(function* () { |
| 2921 | modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( | 2922 | modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( |
| 2922 | settings, | 2923 | settings, |
| 2923 | providers, | 2924 | providers, |
| 2925 | projectId, | ||
| 2924 | ), | 2926 | ), |
| 2925 | style: settings.sourceControlWritingStyle, | 2927 | style: settings.sourceControlWritingStyle, |
| 2926 | })), | 2928 | })), |
apps/server/src/mcp/McpToolAccess.race.test.ts+18-2| ... | @@ -17,10 +17,12 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; | ... | @@ -17,10 +17,12 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; |
| 17 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 17 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 18 | import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; | 18 | import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 19 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; | 19 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 20 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; | ||
| 20 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 21 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 21 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | 22 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 22 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 23 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 23 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; | 24 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; |
| 25 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 24 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; | 26 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 25 | import * as McpHttpServer from "./McpHttpServer.ts"; | 27 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 26 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 28 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| ... | @@ -184,7 +186,14 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { | ... | @@ -184,7 +186,14 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { |
| 184 | const shell = yield* projections.getThreadShell(threadId); | 186 | const shell = yield* projections.getThreadShell(threadId); |
| 185 | assert.equal(shell?.runtimeMode, "full-access"); | 187 | assert.equal(shell?.runtimeMode, "full-access"); |
| 186 | assert.equal(shell?.title, "Before"); | 188 | assert.equal(shell?.title, "Before"); |
| 187 | }).pipe(Effect.provide(ThreadManagement.layer)), | 189 | }).pipe( |
| 190 | Effect.provide( | ||
| 191 | ThreadManagement.layer.pipe( | ||
| 192 | Layer.provide(ServerSettings.layerTest()), | ||
| 193 | Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), | ||
| 194 | ), | ||
| 195 | ), | ||
| 196 | ), | ||
| 188 | ); | 197 | ); |
| 189 | 198 | ||
| 190 | it.effect("lets the write through when the thread stays within the caller's modes", () => | 199 | it.effect("lets the write through when the thread stays within the caller's modes", () => |
| ... | @@ -195,6 +204,13 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { | ... | @@ -195,6 +204,13 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { |
| 195 | const outcome = yield* renameRacingTheUser(threadId, false); | 204 | const outcome = yield* renameRacingTheUser(threadId, false); |
| 196 | assert.deepEqual(outcome, { renamed: true }); | 205 | assert.deepEqual(outcome, { renamed: true }); |
| 197 | assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); | 206 | assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); |
| 198 | }).pipe(Effect.provide(ThreadManagement.layer)), | 207 | }).pipe( |
| 208 | Effect.provide( | ||
| 209 | ThreadManagement.layer.pipe( | ||
| 210 | Layer.provide(ServerSettings.layerTest()), | ||
| 211 | Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), | ||
| 212 | ), | ||
| 213 | ), | ||
| 214 | ), | ||
| 199 | ); | 215 | ); |
| 200 | }); | 216 | }); |
apps/server/src/mcp/OrchestratorMcpService.test.ts+289| ... | @@ -1099,6 +1099,8 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1099,6 +1099,8 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1099 | const layerDependencies = Layer.mergeAll( | 1099 | const layerDependencies = Layer.mergeAll( |
| 1100 | NodeServices.layer, | 1100 | NodeServices.layer, |
| 1101 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 1101 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 1102 | hasReceipt: () => Effect.succeed(false), | ||
| 1103 | assertProviderInstanceEnabled: () => Effect.void, | ||
| 1102 | getThreadRecords: (threadId) => | 1104 | getThreadRecords: (threadId) => |
| 1103 | Effect.succeed( | 1105 | Effect.succeed( |
| 1104 | threadId === parentThreadId | 1106 | threadId === parentThreadId |
| ... | @@ -1197,6 +1199,9 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1197,6 +1199,9 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1197 | const layerDependencies = Layer.mergeAll( | 1199 | const layerDependencies = Layer.mergeAll( |
| 1198 | NodeServices.layer, | 1200 | NodeServices.layer, |
| 1199 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 1201 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 1202 | hasReceipt: () => Effect.succeed(false), | ||
| 1203 | assertProviderInstanceEnabled: () => Effect.void, | ||
| 1204 | isProviderInstanceEnabledForProject: () => Effect.succeed(true), | ||
| 1200 | getThreadRecords: (threadId) => | 1205 | getThreadRecords: (threadId) => |
| 1201 | Effect.succeed( | 1206 | Effect.succeed( |
| 1202 | threadId === parentThreadId | 1207 | threadId === parentThreadId |
| ... | @@ -1275,6 +1280,7 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1275,6 +1280,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1275 | const layerDependencies = Layer.mergeAll( | 1280 | const layerDependencies = Layer.mergeAll( |
| 1276 | NodeServices.layer, | 1281 | NodeServices.layer, |
| 1277 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 1282 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 1283 | hasReceipt: () => Effect.succeed(false), | ||
| 1278 | getThreadRecords: () => Effect.succeed(parentProjection([])), | 1284 | getThreadRecords: () => Effect.succeed(parentProjection([])), |
| 1279 | }), | 1285 | }), |
| 1280 | ServerSettings.layerTest(), | 1286 | ServerSettings.layerTest(), |
| ... | @@ -1321,6 +1327,284 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1321,6 +1327,284 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1321 | }), | 1327 | }), |
| 1322 | ); | 1328 | ); |
| 1323 | 1329 | ||
| 1330 | it.effect("refuses delegation to a provider the project turned off", () => | ||
| 1331 | Effect.gen(function* () { | ||
| 1332 | const layerDependencies = Layer.mergeAll( | ||
| 1333 | NodeServices.layer, | ||
| 1334 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 1335 | hasReceipt: () => Effect.succeed(false), | ||
| 1336 | getThreadRecords: () => Effect.succeed(parentProjection([])), | ||
| 1337 | // ThreadManagementService.dispatch is the chokepoint now: it is the | ||
| 1338 | // one that refuses, not a separate pre-dispatch assert. | ||
| 1339 | dispatch: () => | ||
| 1340 | Effect.fail( | ||
| 1341 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 1342 | projectId, | ||
| 1343 | instanceId: codexInstanceId, | ||
| 1344 | }), | ||
| 1345 | ), | ||
| 1346 | }), | ||
| 1347 | providerRegistryLayer([ | ||
| 1348 | providerSnapshot({ | ||
| 1349 | instanceId: codexInstanceId, | ||
| 1350 | driver: ProviderDriverKind.make("codex"), | ||
| 1351 | model: "gpt-5.4", | ||
| 1352 | }), | ||
| 1353 | ]), | ||
| 1354 | adapterRegistryLayer([codexInstanceId]), | ||
| 1355 | Layer.mock(ProjectService.ProjectService)({}), | ||
| 1356 | Layer.mock(SecretRequests.SecretRequests)({}), | ||
| 1357 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | ||
| 1358 | ServerSettings.layerTest(), | ||
| 1359 | ); | ||
| 1360 | |||
| 1361 | yield* Effect.gen(function* () { | ||
| 1362 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | ||
| 1363 | const error = yield* service | ||
| 1364 | .delegateTask(scope, { | ||
| 1365 | task: "Summarize the diff.", | ||
| 1366 | target: { providerInstanceId: codexInstanceId }, | ||
| 1367 | mode: "async", | ||
| 1368 | clientRequestId: "delegate-disabled-1", | ||
| 1369 | }) | ||
| 1370 | .pipe(Effect.flip); | ||
| 1371 | assert.equal(error.code, "invalid_request"); | ||
| 1372 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | ||
| 1373 | }), | ||
| 1374 | ); | ||
| 1375 | |||
| 1376 | it.effect("driver-only delegation skips an instance the project turned off", () => | ||
| 1377 | Effect.gen(function* () { | ||
| 1378 | const altInstanceId = ProviderInstanceId.make("codex-alt"); | ||
| 1379 | const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]); | ||
| 1380 | const task = { | ||
| 1381 | id: taskId, | ||
| 1382 | threadId: parentThreadId, | ||
| 1383 | runId: parentRunId, | ||
| 1384 | parentNodeId, | ||
| 1385 | origin: "app_owned", | ||
| 1386 | createdBy: "agent", | ||
| 1387 | driver: ProviderDriverKind.make("codex"), | ||
| 1388 | providerInstanceId: altInstanceId, | ||
| 1389 | providerThreadId: null, | ||
| 1390 | childThreadId, | ||
| 1391 | nativeTaskRef: null, | ||
| 1392 | prompt: "Summarize the diff.", | ||
| 1393 | title: null, | ||
| 1394 | model: "gpt-5.4", | ||
| 1395 | status: "running", | ||
| 1396 | result: null, | ||
| 1397 | startedAt: null, | ||
| 1398 | completedAt: null, | ||
| 1399 | }; | ||
| 1400 | let delegated = false; | ||
| 1401 | const layerDependencies = Layer.mergeAll( | ||
| 1402 | NodeServices.layer, | ||
| 1403 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 1404 | hasReceipt: () => Effect.succeed(false), | ||
| 1405 | getThreadRecords: (threadId) => | ||
| 1406 | Effect.succeed( | ||
| 1407 | threadId === parentThreadId | ||
| 1408 | ? parentProjection(delegated ? [task] : []) | ||
| 1409 | : childProjection, | ||
| 1410 | ), | ||
| 1411 | // The parent's own instance is off for this project; the other | ||
| 1412 | // instance of the same driver is on. | ||
| 1413 | isProviderInstanceEnabledForProject: ({ instanceId }) => | ||
| 1414 | Effect.succeed(instanceId !== codexInstanceId), | ||
| 1415 | assertProviderInstanceEnabled: ({ projectId, instanceId }) => | ||
| 1416 | instanceId === codexInstanceId | ||
| 1417 | ? Effect.fail( | ||
| 1418 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 1419 | projectId, | ||
| 1420 | instanceId, | ||
| 1421 | }), | ||
| 1422 | ) | ||
| 1423 | : Effect.void, | ||
| 1424 | dispatch: (command) => | ||
| 1425 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( | ||
| 1426 | Effect.andThen( | ||
| 1427 | Effect.sync(() => { | ||
| 1428 | delegated = true; | ||
| 1429 | }), | ||
| 1430 | ), | ||
| 1431 | Effect.as({ | ||
| 1432 | sequence: 1, | ||
| 1433 | storedEvents: [ | ||
| 1434 | { | ||
| 1435 | sequence: 1, | ||
| 1436 | commandId: null, | ||
| 1437 | event: { type: "subagent.updated", payload: task }, | ||
| 1438 | }, | ||
| 1439 | ], | ||
| 1440 | } as never), | ||
| 1441 | ), | ||
| 1442 | }), | ||
| 1443 | providerRegistryLayer([ | ||
| 1444 | providerSnapshot({ | ||
| 1445 | instanceId: codexInstanceId, | ||
| 1446 | driver: ProviderDriverKind.make("codex"), | ||
| 1447 | model: "gpt-5.4", | ||
| 1448 | }), | ||
| 1449 | providerSnapshot({ | ||
| 1450 | instanceId: altInstanceId, | ||
| 1451 | driver: ProviderDriverKind.make("codex"), | ||
| 1452 | model: "gpt-5.4", | ||
| 1453 | }), | ||
| 1454 | ]), | ||
| 1455 | adapterRegistryLayer([codexInstanceId, altInstanceId]), | ||
| 1456 | Layer.mock(ProjectService.ProjectService)({}), | ||
| 1457 | Layer.mock(SecretRequests.SecretRequests)({}), | ||
| 1458 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | ||
| 1459 | ServerSettings.layerTest(), | ||
| 1460 | ); | ||
| 1461 | |||
| 1462 | yield* Effect.gen(function* () { | ||
| 1463 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | ||
| 1464 | const result = yield* service.delegateTask(scope, { | ||
| 1465 | task: "Summarize the diff.", | ||
| 1466 | target: { driverKind: ProviderDriverKind.make("codex") }, | ||
| 1467 | mode: "async", | ||
| 1468 | clientRequestId: "delegate-driver-skip-disabled-1", | ||
| 1469 | }); | ||
| 1470 | assert.equal(result.status, "running"); | ||
| 1471 | const commands = yield* Ref.get(dispatched); | ||
| 1472 | assert.equal(commands.length, 1); | ||
| 1473 | const request = commands[0] as { | ||
| 1474 | modelSelection: { instanceId: string; model: string }; | ||
| 1475 | }; | ||
| 1476 | assert.equal(request.modelSelection.instanceId, altInstanceId); | ||
| 1477 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | ||
| 1478 | }), | ||
| 1479 | ); | ||
| 1480 | |||
| 1481 | it.effect( | ||
| 1482 | "retrying an accepted driver-only delegation replays even after the project disables every candidate", | ||
| 1483 | () => | ||
| 1484 | Effect.gen(function* () { | ||
| 1485 | const altInstanceId = ProviderInstanceId.make("codex-alt"); | ||
| 1486 | const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]); | ||
| 1487 | const accepted = yield* Ref.make(false); | ||
| 1488 | const everyCandidateDisabled = yield* Ref.make(false); | ||
| 1489 | const task = { | ||
| 1490 | id: taskId, | ||
| 1491 | threadId: parentThreadId, | ||
| 1492 | runId: parentRunId, | ||
| 1493 | parentNodeId, | ||
| 1494 | origin: "app_owned", | ||
| 1495 | createdBy: "agent", | ||
| 1496 | driver: ProviderDriverKind.make("codex"), | ||
| 1497 | providerInstanceId: codexInstanceId, | ||
| 1498 | providerThreadId: null, | ||
| 1499 | childThreadId, | ||
| 1500 | nativeTaskRef: null, | ||
| 1501 | prompt: "Summarize the diff.", | ||
| 1502 | title: null, | ||
| 1503 | model: "gpt-5.4", | ||
| 1504 | status: "running", | ||
| 1505 | result: null, | ||
| 1506 | startedAt: null, | ||
| 1507 | completedAt: null, | ||
| 1508 | }; | ||
| 1509 | const layerDependencies = Layer.mergeAll( | ||
| 1510 | NodeServices.layer, | ||
| 1511 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 1512 | getThreadRecords: (threadId) => | ||
| 1513 | Ref.get(accepted).pipe( | ||
| 1514 | Effect.map((isAccepted) => | ||
| 1515 | threadId === parentThreadId | ||
| 1516 | ? parentProjection(isAccepted ? [task] : []) | ||
| 1517 | : childProjection, | ||
| 1518 | ), | ||
| 1519 | ), | ||
| 1520 | // A command id already accepted once, even if the project later | ||
| 1521 | // disables every candidate: its retry must replay, not re-resolve. | ||
| 1522 | hasReceipt: () => Ref.get(accepted), | ||
| 1523 | // Effective settings once the project turns every instance off, | ||
| 1524 | // ignoring the already-accepted command's replay exemption. | ||
| 1525 | isProviderInstanceEnabledForProject: () => | ||
| 1526 | Ref.get(everyCandidateDisabled).pipe(Effect.map((disabled) => !disabled)), | ||
| 1527 | assertProviderInstanceEnabled: ({ projectId, instanceId }) => | ||
| 1528 | Effect.all([Ref.get(accepted), Ref.get(everyCandidateDisabled)]).pipe( | ||
| 1529 | Effect.flatMap(([isAccepted, disabled]) => | ||
| 1530 | isAccepted || !disabled | ||
| 1531 | ? Effect.void | ||
| 1532 | : Effect.fail( | ||
| 1533 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 1534 | projectId, | ||
| 1535 | instanceId, | ||
| 1536 | }), | ||
| 1537 | ), | ||
| 1538 | ), | ||
| 1539 | ), | ||
| 1540 | dispatch: (command) => | ||
| 1541 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( | ||
| 1542 | Effect.andThen(Ref.set(accepted, true)), | ||
| 1543 | Effect.as({ | ||
| 1544 | sequence: 1, | ||
| 1545 | storedEvents: [ | ||
| 1546 | { | ||
| 1547 | sequence: 1, | ||
| 1548 | commandId: null, | ||
| 1549 | event: { type: "subagent.updated", payload: task }, | ||
| 1550 | }, | ||
| 1551 | ], | ||
| 1552 | } as never), | ||
| 1553 | ), | ||
| 1554 | }), | ||
| 1555 | providerRegistryLayer([ | ||
| 1556 | providerSnapshot({ | ||
| 1557 | instanceId: codexInstanceId, | ||
| 1558 | driver: ProviderDriverKind.make("codex"), | ||
| 1559 | model: "gpt-5.4", | ||
| 1560 | }), | ||
| 1561 | providerSnapshot({ | ||
| 1562 | instanceId: altInstanceId, | ||
| 1563 | driver: ProviderDriverKind.make("codex"), | ||
| 1564 | model: "gpt-5.4", | ||
| 1565 | }), | ||
| 1566 | ]), | ||
| 1567 | adapterRegistryLayer([codexInstanceId, altInstanceId]), | ||
| 1568 | Layer.mock(ProjectService.ProjectService)({}), | ||
| 1569 | Layer.mock(SecretRequests.SecretRequests)({}), | ||
| 1570 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | ||
| 1571 | ServerSettings.layerTest(), | ||
| 1572 | ); | ||
| 1573 | |||
| 1574 | yield* Effect.gen(function* () { | ||
| 1575 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | ||
| 1576 | const first = yield* service.delegateTask(scope, { | ||
| 1577 | task: "Summarize the diff.", | ||
| 1578 | target: { driverKind: ProviderDriverKind.make("codex") }, | ||
| 1579 | mode: "async", | ||
| 1580 | clientRequestId: "delegate-driver-retry-after-disable-1", | ||
| 1581 | }); | ||
| 1582 | assert.equal(first.status, "running"); | ||
| 1583 | |||
| 1584 | // The project turns off every instance of this driver only after | ||
| 1585 | // the delegation above was already accepted. | ||
| 1586 | yield* Ref.set(everyCandidateDisabled, true); | ||
| 1587 | |||
| 1588 | const retry = yield* service.delegateTask(scope, { | ||
| 1589 | task: "Summarize the diff.", | ||
| 1590 | target: { driverKind: ProviderDriverKind.make("codex") }, | ||
| 1591 | mode: "async", | ||
| 1592 | clientRequestId: "delegate-driver-retry-after-disable-1", | ||
| 1593 | }); | ||
| 1594 | assert.equal(retry.status, "running"); | ||
| 1595 | |||
| 1596 | const commands = yield* Ref.get(dispatched); | ||
| 1597 | assert.equal(commands.length, 2); | ||
| 1598 | const retryCommand = commands[1] as { | ||
| 1599 | modelSelection: { instanceId: string; model: string }; | ||
| 1600 | }; | ||
| 1601 | assert.equal(retryCommand.modelSelection.instanceId, codexInstanceId); | ||
| 1602 | }).pipe( | ||
| 1603 | Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), | ||
| 1604 | ); | ||
| 1605 | }), | ||
| 1606 | ); | ||
| 1607 | |||
| 1324 | it.effect("re-probes an unavailable target once before refusing it", () => | 1608 | it.effect("re-probes an unavailable target once before refusing it", () => |
| 1325 | Effect.gen(function* () { | 1609 | Effect.gen(function* () { |
| 1326 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); | 1610 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); |
| ... | @@ -1368,6 +1652,8 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1368,6 +1652,8 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1368 | const layerDependencies = Layer.mergeAll( | 1652 | const layerDependencies = Layer.mergeAll( |
| 1369 | NodeServices.layer, | 1653 | NodeServices.layer, |
| 1370 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 1654 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 1655 | hasReceipt: () => Effect.succeed(false), | ||
| 1656 | assertProviderInstanceEnabled: () => Effect.void, | ||
| 1371 | getThreadRecords: (threadId) => | 1657 | getThreadRecords: (threadId) => |
| 1372 | Effect.succeed( | 1658 | Effect.succeed( |
| 1373 | threadId === parentThreadId ? parentProjection([task]) : childProjection, | 1659 | threadId === parentThreadId ? parentProjection([task]) : childProjection, |
| ... | @@ -1513,6 +1799,9 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1513,6 +1799,9 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1513 | const layerDependencies = Layer.mergeAll( | 1799 | const layerDependencies = Layer.mergeAll( |
| 1514 | NodeServices.layer, | 1800 | NodeServices.layer, |
| 1515 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 1801 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 1802 | hasReceipt: () => Effect.succeed(false), | ||
| 1803 | assertProviderInstanceEnabled: () => Effect.void, | ||
| 1804 | isProviderInstanceEnabledForProject: () => Effect.succeed(true), | ||
| 1516 | getThreadRecords: (threadId) => | 1805 | getThreadRecords: (threadId) => |
| 1517 | Effect.succeed( | 1806 | Effect.succeed( |
| 1518 | threadId === parentThreadId | 1807 | threadId === parentThreadId |
apps/server/src/mcp/OrchestratorMcpService.ts+77-19| ... | @@ -215,6 +215,8 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure { | ... | @@ -215,6 +215,8 @@ function threadManagementFailure(error: unknown): OrchestratorMcpFailure { |
| 215 | case "ThreadManagementProjectThreadsListError": | 215 | case "ThreadManagementProjectThreadsListError": |
| 216 | case "ThreadManagementDurableRunProjectionError": | 216 | case "ThreadManagementDurableRunProjectionError": |
| 217 | return failure("orchestration_error", error.message); | 217 | return failure("orchestration_error", error.message); |
| 218 | case "ThreadManagementProviderInstanceDisabledError": | ||
| 219 | return failure("invalid_request", error.message); | ||
| 218 | } | 220 | } |
| 219 | } | 221 | } |
| 220 | 222 | ||
| ... | @@ -1135,6 +1137,19 @@ const make = Effect.gen(function* () { | ... | @@ -1135,6 +1137,19 @@ const make = Effect.gen(function* () { |
| 1135 | * unattended agent can see a provider as unavailable after it was fixed. | 1137 | * unattended agent can see a provider as unavailable after it was fixed. |
| 1136 | * Re-probe the requested instance once before refusing it. | 1138 | * Re-probe the requested instance once before refusing it. |
| 1137 | */ | 1139 | */ |
| 1140 | const assertTargetEnabledForProject = ( | ||
| 1141 | parent: Pick<OrchestrationV2ThreadProjection, "thread">, | ||
| 1142 | commandId: CommandId, | ||
| 1143 | target: ResolvedTarget, | ||
| 1144 | ) => | ||
| 1145 | threadManagement | ||
| 1146 | .assertProviderInstanceEnabled({ | ||
| 1147 | projectId: parent.thread.projectId, | ||
| 1148 | commandId, | ||
| 1149 | instanceId: target.modelSelection.instanceId, | ||
| 1150 | }) | ||
| 1151 | .pipe(Effect.mapError(threadManagementFailure)); | ||
| 1152 | |||
| 1138 | const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => { | 1153 | const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => { |
| 1139 | const instanceId = | 1154 | const instanceId = |
| 1140 | input.target?.providerInstanceId ?? | 1155 | input.target?.providerInstanceId ?? |
| ... | @@ -1158,6 +1173,16 @@ const make = Effect.gen(function* () { | ... | @@ -1158,6 +1173,16 @@ const make = Effect.gen(function* () { |
| 1158 | readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">; | 1173 | readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">; |
| 1159 | readonly target: OrchestratorMcpTarget | undefined; | 1174 | readonly target: OrchestratorMcpTarget | undefined; |
| 1160 | readonly providers: ReadonlyArray<ServerProvider>; | 1175 | readonly providers: ReadonlyArray<ServerProvider>; |
| 1176 | /** | ||
| 1177 | * Skips the per-candidate project-enablement check in driver-only | ||
| 1178 | * resolution. Set only when the command that will dispatch this target | ||
| 1179 | * already has a receipt: that dispatch can only replay the recorded | ||
| 1180 | * result, never start anything new, so filtering candidates by the | ||
| 1181 | * project's current settings would just refuse a retry that does no new | ||
| 1182 | * work. Never set this for a resolution whose dispatch uses a different | ||
| 1183 | * command id than the one checked for a receipt. | ||
| 1184 | */ | ||
| 1185 | readonly skipProjectEnablementFilter?: boolean; | ||
| 1161 | }): Effect.Effect<ResolvedTarget, OrchestratorMcpFailure> => | 1186 | }): Effect.Effect<ResolvedTarget, OrchestratorMcpFailure> => |
| 1162 | Effect.gen(function* () { | 1187 | Effect.gen(function* () { |
| 1163 | const requestedInstanceId = input.target?.providerInstanceId; | 1188 | const requestedInstanceId = input.target?.providerInstanceId; |
| ... | @@ -1179,20 +1204,37 @@ const make = Effect.gen(function* () { | ... | @@ -1179,20 +1204,37 @@ const make = Effect.gen(function* () { |
| 1179 | } | 1204 | } |
| 1180 | // Inherit the parent's instance only when it can actually serve the | 1205 | // Inherit the parent's instance only when it can actually serve the |
| 1181 | // child; an unavailable parent yields to a healthy instance of the | 1206 | // child; an unavailable parent yields to a healthy instance of the |
| 1182 | // requested driver rather than failing the delegation. | 1207 | // requested driver rather than failing the delegation. An instance |
| 1183 | const inheritedCandidate = candidates.find( | 1208 | // the project turned off cannot serve either, so selection skips it |
| 1184 | (candidate) => | 1209 | // for the next candidate instead of failing the enablement check. |
| 1185 | candidate.instanceId === input.parent.thread.modelSelection.instanceId && | 1210 | const healthyCandidates = candidates.filter( |
| 1186 | providerConstraints(candidate, true).length === 0, | ||
| 1187 | ); | ||
| 1188 | const availableCandidate = candidates.find( | ||
| 1189 | (candidate) => providerConstraints(candidate, true).length === 0, | 1211 | (candidate) => providerConstraints(candidate, true).length === 0, |
| 1190 | ); | 1212 | ); |
| 1191 | instanceId = inheritedCandidate?.instanceId ?? availableCandidate?.instanceId; | 1213 | const inheritedCandidate = healthyCandidates.find( |
| 1214 | (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId, | ||
| 1215 | ); | ||
| 1216 | const orderedCandidates = [ | ||
| 1217 | ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]), | ||
| 1218 | ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate), | ||
| 1219 | ]; | ||
| 1220 | for (const candidate of orderedCandidates) { | ||
| 1221 | const enabledForProject = | ||
| 1222 | input.skipProjectEnablementFilter === true || | ||
| 1223 | (yield* threadManagement.isProviderInstanceEnabledForProject({ | ||
| 1224 | projectId: input.parent.thread.projectId, | ||
| 1225 | instanceId: candidate.instanceId, | ||
| 1226 | })); | ||
| 1227 | if (enabledForProject) { | ||
| 1228 | instanceId = candidate.instanceId; | ||
| 1229 | break; | ||
| 1230 | } | ||
| 1231 | } | ||
| 1192 | if (instanceId === undefined) { | 1232 | if (instanceId === undefined) { |
| 1193 | return yield* failure( | 1233 | return yield* failure( |
| 1194 | "provider_unavailable", | 1234 | "provider_unavailable", |
| 1195 | `No available V2 provider instance for driver ${requestedDriver}.`, | 1235 | healthyCandidates.length === 0 |
| 1236 | ? `No available V2 provider instance for driver ${requestedDriver}.` | ||
| 1237 | : `No instance of driver ${requestedDriver} is enabled for this project.`, | ||
| 1196 | ); | 1238 | ); |
| 1197 | } | 1239 | } |
| 1198 | } | 1240 | } |
| ... | @@ -1925,10 +1967,22 @@ const make = Effect.gen(function* () { | ... | @@ -1925,10 +1967,22 @@ const make = Effect.gen(function* () { |
| 1925 | ); | 1967 | ); |
| 1926 | } | 1968 | } |
| 1927 | const providers = yield* loadProviders; | 1969 | const providers = yield* loadProviders; |
| 1970 | // Computed before resolving the target so a retry of an already | ||
| 1971 | // accepted delegation can be recognized: dispatching under the same | ||
| 1972 | // command id only replays the recorded result, so candidate | ||
| 1973 | // selection must not refuse it over the project's current settings. | ||
| 1974 | const key = yield* requestKey(input.clientRequestId); | ||
| 1975 | const commandId = stableCommandId({ | ||
| 1976 | scope, | ||
| 1977 | requestKey: key, | ||
| 1978 | operation: "delegate-task", | ||
| 1979 | }); | ||
| 1980 | const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId); | ||
| 1928 | const target = yield* resolveTargetRechecking({ | 1981 | const target = yield* resolveTargetRechecking({ |
| 1929 | parent, | 1982 | parent, |
| 1930 | target: input.target, | 1983 | target: input.target, |
| 1931 | providers, | 1984 | providers, |
| 1985 | skipProjectEnablementFilter: isRetryOfAcceptedDelegation, | ||
| 1932 | }); | 1986 | }); |
| 1933 | const runtimeMode = yield* resolveRuntimeMode( | 1987 | const runtimeMode = yield* resolveRuntimeMode( |
| 1934 | parent.thread.runtimeMode, | 1988 | parent.thread.runtimeMode, |
| ... | @@ -1939,12 +1993,9 @@ const make = Effect.gen(function* () { | ... | @@ -1939,12 +1993,9 @@ const make = Effect.gen(function* () { |
| 1939 | parent.thread.interactionMode, | 1993 | parent.thread.interactionMode, |
| 1940 | input.interactionMode, | 1994 | input.interactionMode, |
| 1941 | ); | 1995 | ); |
| 1942 | const key = yield* requestKey(input.clientRequestId); | 1996 | // ThreadManagementService.dispatch is the chokepoint for this command |
| 1943 | const commandId = stableCommandId({ | 1997 | // type's own enablement check, using the same command id and its |
| 1944 | scope, | 1998 | // receipt-replay exemption, so no separate assert runs here. |
| 1945 | requestKey: key, | ||
| 1946 | operation: "delegate-task", | ||
| 1947 | }); | ||
| 1948 | const result = yield* threadManagement | 1999 | const result = yield* threadManagement |
| 1949 | .dispatch({ | 2000 | .dispatch({ |
| 1950 | type: "delegated_task.request", | 2001 | type: "delegated_task.request", |
| ... | @@ -1966,10 +2017,12 @@ const make = Effect.gen(function* () { | ... | @@ -1966,10 +2017,12 @@ const make = Effect.gen(function* () { |
| 1966 | }) | 2017 | }) |
| 1967 | .pipe( | 2018 | .pipe( |
| 1968 | Effect.mapError((error) => | 2019 | Effect.mapError((error) => |
| 1969 | failure( | 2020 | error._tag === "ThreadManagementProviderInstanceDisabledError" |
| 1970 | "orchestration_error", | 2021 | ? threadManagementFailure(error) |
| 1971 | `Unable to create delegated task: ${errorMessage(error)}`, | 2022 | : failure( |
| 1972 | ), | 2023 | "orchestration_error", |
| 2024 | `Unable to create delegated task: ${errorMessage(error)}`, | ||
| 2025 | ), | ||
| 1973 | ), | 2026 | ), |
| 1974 | ); | 2027 | ); |
| 1975 | const taskEvent = result.storedEvents.find( | 2028 | const taskEvent = result.storedEvents.find( |
| ... | @@ -2230,6 +2283,11 @@ const make = Effect.gen(function* () { | ... | @@ -2230,6 +2283,11 @@ const make = Effect.gen(function* () { |
| 2230 | requestKey: key, | 2283 | requestKey: key, |
| 2231 | index, | 2284 | index, |
| 2232 | }); | 2285 | }); |
| 2286 | yield* assertTargetEnabledForProject( | ||
| 2287 | parent, | ||
| 2288 | stableCommandId({ scope, requestKey: key, operation: "create-thread", index }), | ||
| 2289 | target, | ||
| 2290 | ); | ||
| 2233 | const title = threadTitle({ | 2291 | const title = threadTitle({ |
| 2234 | parentTitle: parent.thread.title, | 2292 | parentTitle: parent.thread.title, |
| 2235 | prompt: request.prompt, | 2293 | prompt: request.prompt, |
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+17-3| ... | @@ -54,9 +54,12 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud | ... | @@ -54,9 +54,12 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud |
| 54 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; | 54 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 55 | import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; | 55 | import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; |
| 56 | import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts"; | 56 | import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts"; |
| 57 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; | ||
| 57 | import * as EventSink from "../orchestration-v2/EventSink.ts"; | 58 | import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| 58 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 59 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 59 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 60 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 61 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 62 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 60 | import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; | 63 | import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; |
| 61 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 64 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 62 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; | 65 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| ... | @@ -70,7 +73,6 @@ import { | ... | @@ -70,7 +73,6 @@ import { |
| 70 | import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; | 73 | import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; |
| 71 | import * as ProjectService from "../project/ProjectService.ts"; | 74 | import * as ProjectService from "../project/ProjectService.ts"; |
| 72 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 75 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 73 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 74 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 76 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 75 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 77 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 76 | import * as McpHttpServer from "./McpHttpServer.ts"; | 78 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| ... | @@ -612,7 +614,13 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -612,7 +614,13 @@ describe("orchestrator MCP toolkit", () => { |
| 612 | ).pipe(Layer.provide(layerContinuationProbe)); | 614 | ).pipe(Layer.provide(layerContinuationProbe)); |
| 613 | const layerOrchestration = Layer.merge( | 615 | const layerOrchestration = Layer.merge( |
| 614 | layerOrchestrator, | 616 | layerOrchestrator, |
| 615 | ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), | 617 | ThreadManagementService.layer.pipe( |
| 618 | Layer.provide(layerOrchestrator), | ||
| 619 | Layer.provide(ServerSettings.layerTest()), | ||
| 620 | Layer.provide( | ||
| 621 | CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), | ||
| 622 | ), | ||
| 623 | ), | ||
| 616 | ); | 624 | ); |
| 617 | const layerProviderRegistry = ProviderRegistryMock.layer([ | 625 | const layerProviderRegistry = ProviderRegistryMock.layer([ |
| 618 | makeProviderSnapshot({ | 626 | makeProviderSnapshot({ |
| ... | @@ -3809,7 +3817,13 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -3809,7 +3817,13 @@ describe("orchestrator MCP toolkit", () => { |
| 3809 | ); | 3817 | ); |
| 3810 | const layerOrchestration = Layer.merge( | 3818 | const layerOrchestration = Layer.merge( |
| 3811 | layerOrchestrator, | 3819 | layerOrchestrator, |
| 3812 | ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), | 3820 | ThreadManagementService.layer.pipe( |
| 3821 | Layer.provide(layerOrchestrator), | ||
| 3822 | Layer.provide(ServerSettings.layerTest()), | ||
| 3823 | Layer.provide( | ||
| 3824 | CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), | ||
| 3825 | ), | ||
| 3826 | ), | ||
| 3813 | ); | 3827 | ); |
| 3814 | const layerProviderRegistry = ProviderRegistryMock.layer([ | 3828 | const layerProviderRegistry = ProviderRegistryMock.layer([ |
| 3815 | makeProviderSnapshot({ | 3829 | makeProviderSnapshot({ |
apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts+10-2| ... | @@ -22,18 +22,20 @@ import * as Queue from "effect/Queue"; | ... | @@ -22,18 +22,20 @@ import * as Queue from "effect/Queue"; |
| 22 | import * as Stream from "effect/Stream"; | 22 | import * as Stream from "effect/Stream"; |
| 23 | 23 | ||
| 24 | import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; | 24 | import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 25 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; | ||
| 25 | import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; | 26 | import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; |
| 26 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | 27 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 27 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 28 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 28 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 29 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 29 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 30 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 31 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 30 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; | 32 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 31 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; | 33 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 34 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 32 | import * as ProjectService from "../project/ProjectService.ts"; | 35 | import * as ProjectService from "../project/ProjectService.ts"; |
| 33 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; | 36 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 34 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 37 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 35 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 38 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 36 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 37 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 39 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 38 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; | 40 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 39 | 41 | ||
| ... | @@ -133,7 +135,13 @@ it.effect("task_cancel ends a delegated child's background native subagent", () | ... | @@ -133,7 +135,13 @@ it.effect("task_cancel ends a delegated child's background native subagent", () |
| 133 | ); | 135 | ); |
| 134 | const layerOrchestration = Layer.merge( | 136 | const layerOrchestration = Layer.merge( |
| 135 | layerOrchestrator, | 137 | layerOrchestrator, |
| 136 | ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), | 138 | ThreadManagementService.layer.pipe( |
| 139 | Layer.provide(layerOrchestrator), | ||
| 140 | Layer.provide(ServerSettings.layerTest()), | ||
| 141 | Layer.provide( | ||
| 142 | CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), | ||
| 143 | ), | ||
| 144 | ), | ||
| 137 | ); | 145 | ); |
| 138 | const layerTest = OrchestratorMcpService.layer.pipe( | 146 | const layerTest = OrchestratorMcpService.layer.pipe( |
| 139 | Layer.provideMerge(layerOrchestration), | 147 | Layer.provideMerge(layerOrchestration), |
apps/server/src/mcp/threadAccess.ts+14-10| ... | @@ -23,16 +23,20 @@ export const unavailable = () => | ... | @@ -23,16 +23,20 @@ export const unavailable = () => |
| 23 | }); | 23 | }); |
| 24 | 24 | ||
| 25 | /** Decider string rejections are public; wrapped storage and hydration causes are not. */ | 25 | /** Decider string rejections are public; wrapped storage and hydration causes are not. */ |
| 26 | export const dispatchFailure = (error: OrchestratorV2Error) => | 26 | export const dispatchFailure = ( |
| 27 | (error._tag === "OrchestratorDispatchError" || | 27 | error: OrchestratorV2Error | ThreadManagement.ThreadManagementProviderInstanceDisabledError, |
| 28 | error._tag === "OrchestratorCommandRejectedError") && | 28 | ) => |
| 29 | typeof error.cause === "string" && | 29 | error._tag === "ThreadManagementProviderInstanceDisabledError" |
| 30 | error.cause.length > 0 | 30 | ? new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }) |
| 31 | ? new OrchestratorMcpFailure({ | 31 | : (error._tag === "OrchestratorDispatchError" || |
| 32 | code: "orchestration_error", | 32 | error._tag === "OrchestratorCommandRejectedError") && |
| 33 | message: Array.from(error.cause).slice(0, 1000).join(""), | 33 | typeof error.cause === "string" && |
| 34 | }) | 34 | error.cause.length > 0 |
| 35 | : unavailable(); | 35 | ? new OrchestratorMcpFailure({ |
| 36 | code: "orchestration_error", | ||
| 37 | message: Array.from(error.cause).slice(0, 1000).join(""), | ||
| 38 | }) | ||
| 39 | : unavailable(); | ||
| 36 | 40 | ||
| 37 | /** | 41 | /** |
| 38 | * The most a caller may hand to the threads it targets. A thread caller is | 42 | * The most a caller may hand to the threads it targets. A thread caller is |
apps/server/src/mcp/toolkits/project/handlers.test.ts+12-1| ... | @@ -19,6 +19,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -19,6 +19,7 @@ import * as Stream from "effect/Stream"; |
| 19 | import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; | 19 | import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; |
| 20 | import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; | 20 | import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; |
| 21 | import * as ServerConfig from "../../../config.ts"; | 21 | import * as ServerConfig from "../../../config.ts"; |
| 22 | import * as ServerSettings from "../../../serverSettings.ts"; | ||
| 22 | import * as Project from "../../../project/ProjectService.ts"; | 23 | import * as Project from "../../../project/ProjectService.ts"; |
| 23 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; | 24 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 24 | import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; | 25 | import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; |
| ... | @@ -47,6 +48,7 @@ it.effect("attributes a launched thread's first message to the calling thread", | ... | @@ -47,6 +48,7 @@ it.effect("attributes a launched thread's first message to the calling thread", |
| 47 | } as OrchestrationV2ThreadShell; | 48 | } as OrchestrationV2ThreadShell; |
| 48 | let launchedSender: ThreadId | undefined; | 49 | let launchedSender: ThreadId | undefined; |
| 49 | const layerDependencies = Layer.mergeAll( | 50 | const layerDependencies = Layer.mergeAll( |
| 51 | ServerSettings.layerTest(), | ||
| 50 | NodeCrypto.layer, | 52 | NodeCrypto.layer, |
| 51 | Layer.succeed(McpInvocationContext.McpInvocationContext, { | 53 | Layer.succeed(McpInvocationContext.McpInvocationContext, { |
| 52 | environmentId: EnvironmentId.make("environment"), | 54 | environmentId: EnvironmentId.make("environment"), |
| ... | @@ -118,6 +120,7 @@ it.effect("launches a scratch thread into the Scratch project", () => | ... | @@ -118,6 +120,7 @@ it.effect("launches a scratch thread into the Scratch project", () => |
| 118 | } as OrchestrationV2ThreadShell; | 120 | } as OrchestrationV2ThreadShell; |
| 119 | const launched: Array<ThreadLaunch.ThreadLaunchInput> = []; | 121 | const launched: Array<ThreadLaunch.ThreadLaunchInput> = []; |
| 120 | const layerDependencies = Layer.mergeAll( | 122 | const layerDependencies = Layer.mergeAll( |
| 123 | ServerSettings.layerTest(), | ||
| 121 | NodeCrypto.layer, | 124 | NodeCrypto.layer, |
| 122 | Layer.succeed(McpInvocationContext.McpInvocationContext, { | 125 | Layer.succeed(McpInvocationContext.McpInvocationContext, { |
| 123 | environmentId: EnvironmentId.make("environment"), | 126 | environmentId: EnvironmentId.make("environment"), |
| ... | @@ -215,6 +218,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () | ... | @@ -215,6 +218,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () |
| 215 | deletedAt: null, | 218 | deletedAt: null, |
| 216 | }; | 219 | }; |
| 217 | const layerDependencies = Layer.mergeAll( | 220 | const layerDependencies = Layer.mergeAll( |
| 221 | ServerSettings.layerTest(), | ||
| 218 | NodeCrypto.layer, | 222 | NodeCrypto.layer, |
| 219 | Layer.succeed(McpInvocationContext.McpInvocationContext, { | 223 | Layer.succeed(McpInvocationContext.McpInvocationContext, { |
| 220 | environmentId: EnvironmentId.make("environment"), | 224 | environmentId: EnvironmentId.make("environment"), |
| ... | @@ -304,8 +308,15 @@ const clientLaunchHarness = (input: { | ... | @@ -304,8 +308,15 @@ const clientLaunchHarness = (input: { |
| 304 | readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; | 308 | readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; |
| 305 | }) => { | 309 | }) => { |
| 306 | const projectId = ProjectId.make("project:client-target"); | 310 | const projectId = ProjectId.make("project:client-target"); |
| 307 | const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; | 311 | // "claudeAgent" is the real default instance id for the Claude driver |
| 312 | // (see `defaultInstanceIdForDriver`), so it resolves as enabled by default | ||
| 313 | // under the project-scoped provider instance gate the launch path now runs. | ||
| 314 | const modelSelection = { | ||
| 315 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 316 | model: "claude-opus", | ||
| 317 | }; | ||
| 308 | const layerDependencies = Layer.mergeAll( | 318 | const layerDependencies = Layer.mergeAll( |
| 319 | ServerSettings.layerTest(), | ||
| 309 | NodeCrypto.layer, | 320 | NodeCrypto.layer, |
| 310 | Layer.succeed(McpInvocationContext.McpInvocationContext, { | 321 | Layer.succeed(McpInvocationContext.McpInvocationContext, { |
| 311 | environmentId: EnvironmentId.make("environment"), | 322 | environmentId: EnvironmentId.make("environment"), |
apps/server/src/mcp/toolkits/project/handlers.ts+15-5| ... | @@ -157,11 +157,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { | ... | @@ -157,11 +157,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 157 | createdBy: "agent", | 157 | createdBy: "agent", |
| 158 | creationSource: "mcp", | 158 | creationSource: "mcp", |
| 159 | }).pipe( | 159 | }).pipe( |
| 160 | Effect.mapError((error) => | 160 | Effect.mapError((error) => { |
| 161 | error._tag === "AttachmentClaimError" | 161 | if (error._tag === "AttachmentClaimError") { |
| 162 | ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) | 162 | return new OrchestratorMcpFailure({ |
| 163 | : unavailable(), | 163 | code: "orchestration_error", |
| 164 | ), | 164 | message: error.message, |
| 165 | }); | ||
| 166 | } | ||
| 167 | if (error._tag === "ThreadManagementProviderInstanceDisabledError") { | ||
| 168 | return new OrchestratorMcpFailure({ | ||
| 169 | code: "invalid_request", | ||
| 170 | message: error.message, | ||
| 171 | }); | ||
| 172 | } | ||
| 173 | return unavailable(); | ||
| 174 | }), | ||
| 165 | ); | 175 | ); |
| 166 | const thread = result.projection.thread; | 176 | const thread = result.projection.thread; |
| 167 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); | 177 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); |
apps/server/src/mcp/toolkits/project/tools.ts+2| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import * as FileSystem from "effect/FileSystem"; | 20 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as ServerConfig from "../../../config.ts"; | 21 | import * as ServerConfig from "../../../config.ts"; |
| 22 | import * as ServerSettings from "../../../serverSettings.ts"; | ||
| 22 | import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; | 23 | import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; |
| 23 | import * as Crypto from "effect/Crypto"; | 24 | import * as Crypto from "effect/Crypto"; |
| 24 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| ... | @@ -144,6 +145,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { | ... | @@ -144,6 +145,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 144 | GitVcsDriver.GitVcsDriver, | 145 | GitVcsDriver.GitVcsDriver, |
| 145 | FileSystem.FileSystem, | 146 | FileSystem.FileSystem, |
| 146 | ServerConfig.ServerConfig, | 147 | ServerConfig.ServerConfig, |
| 148 | ServerSettings.ServerSettingsService, | ||
| 147 | ], | 149 | ], |
| 148 | }) | 150 | }) |
| 149 | .annotate(Tool.Destructive, true) | 151 | .annotate(Tool.Destructive, true) |
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+6| ... | @@ -29,6 +29,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; | ... | @@ -29,6 +29,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 29 | import * as Scheduler from "../scheduling/Scheduler.ts"; | 29 | import * as Scheduler from "../scheduling/Scheduler.ts"; |
| 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 31 | import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; | 31 | import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; |
| 32 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | ||
| 32 | import * as EffectWorker from "./EffectWorker.ts"; | 33 | import * as EffectWorker from "./EffectWorker.ts"; |
| 33 | import * as EventSink from "./EventSink.ts"; | 34 | import * as EventSink from "./EventSink.ts"; |
| 34 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 35 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| ... | @@ -38,6 +39,7 @@ import * as Orchestrator from "./Orchestrator.ts"; | ... | @@ -38,6 +39,7 @@ import * as Orchestrator from "./Orchestrator.ts"; |
| 38 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | 39 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 39 | import * as ThreadLaunchService from "./ThreadLaunchService.ts"; | 40 | import * as ThreadLaunchService from "./ThreadLaunchService.ts"; |
| 40 | import * as ThreadManagementService from "./ThreadManagementService.ts"; | 41 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 42 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 41 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; | 43 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; |
| 42 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; | 44 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 43 | 45 | ||
| ... | @@ -313,6 +315,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con | ... | @@ -313,6 +315,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con |
| 313 | Effect.provide( | 315 | Effect.provide( |
| 314 | ScheduledTaskService.layer.pipe( | 316 | ScheduledTaskService.layer.pipe( |
| 315 | Layer.provide(ThreadManagementService.layer), | 317 | Layer.provide(ThreadManagementService.layer), |
| 318 | Layer.provide(ServerSettings.layerTest()), | ||
| 316 | Layer.provide( | 319 | Layer.provide( |
| 317 | Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ | 320 | Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({ |
| 318 | ensureTranscript: () => | 321 | ensureTranscript: () => |
| ... | @@ -321,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con | ... | @@ -321,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con |
| 321 | ), | 324 | ), |
| 322 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), | 325 | Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), |
| 323 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), | 326 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), |
| 327 | Layer.provide( | ||
| 328 | CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), | ||
| 329 | ), | ||
| 324 | Layer.provide( | 330 | Layer.provide( |
| 325 | Layer.mergeAll( | 331 | Layer.mergeAll( |
| 326 | NodeCrypto.layer, | 332 | NodeCrypto.layer, |
apps/server/src/orchestration-v2/EffectWorker.ts+55-35| ... | @@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer< | ... | @@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer< |
| 256 | if (message === undefined || run === undefined) return yield* error; | 256 | if (message === undefined || run === undefined) return yield* error; |
| 257 | // Reuse the message identity and a stable command receipt so an outbox | 257 | // Reuse the message identity and a stable command receipt so an outbox |
| 258 | // retry cannot append a duplicate message or start a second follow-up. | 258 | // retry cannot append a duplicate message or start a second follow-up. |
| 259 | yield* threads.dispatch({ | 259 | yield* threads |
| 260 | type: "message.dispatch", | 260 | .dispatch({ |
| 261 | commandId: CommandId.make(`command:steer-follow-up:${effect.id}`), | 261 | type: "message.dispatch", |
| 262 | threadId: effect.threadId, | 262 | commandId: CommandId.make(`command:steer-follow-up:${effect.id}`), |
| 263 | messageId: message.id, | 263 | threadId: effect.threadId, |
| 264 | text: message.text, | 264 | messageId: message.id, |
| 265 | ...(message.context ? { context: message.context } : {}), | 265 | text: message.text, |
| 266 | attachments: message.attachments, | 266 | ...(message.context ? { context: message.context } : {}), |
| 267 | // A user's follow-up starts on the thread's saved selection, | 267 | attachments: message.attachments, |
| 268 | // which already holds the steer's choice. A delegated | 268 | // A user's follow-up starts on the thread's saved selection, |
| 269 | // completion stays pinned to the run it reports to. | 269 | // which already holds the steer's choice. A delegated |
| 270 | ...(message.delegatedCompletion === undefined | 270 | // completion stays pinned to the run it reports to. |
| 271 | ? {} | 271 | ...(message.delegatedCompletion === undefined |
| 272 | : { modelSelection: run.modelSelection }), | 272 | ? {} |
| 273 | dispatchMode: { | 273 | : { modelSelection: run.modelSelection }), |
| 274 | type: | 274 | dispatchMode: { |
| 275 | message.delegatedCompletion === undefined | 275 | type: |
| 276 | ? "start_immediately" | 276 | message.delegatedCompletion === undefined |
| 277 | : "queue_after_active", | 277 | ? "start_immediately" |
| 278 | }, | 278 | : "queue_after_active", |
| 279 | createdBy: message.createdBy, | 279 | }, |
| 280 | creationSource: message.creationSource, | 280 | createdBy: message.createdBy, |
| 281 | ...(message.delegatedCompletion === undefined | 281 | creationSource: message.creationSource, |
| 282 | ? {} | 282 | ...(message.delegatedCompletion === undefined |
| 283 | : { delegatedCompletion: message.delegatedCompletion }), | 283 | ? {} |
| 284 | ...(message.notification === undefined | 284 | : { delegatedCompletion: message.delegatedCompletion }), |
| 285 | ? {} | 285 | ...(message.notification === undefined |
| 286 | : { notification: message.notification }), | 286 | ? {} |
| 287 | ...(message.scheduledTaskId === undefined | 287 | : { notification: message.notification }), |
| 288 | ? {} | 288 | ...(message.scheduledTaskId === undefined |
| 289 | : { scheduledTaskId: message.scheduledTaskId }), | 289 | ? {} |
| 290 | ...(message.senderThreadId === undefined | 290 | : { scheduledTaskId: message.scheduledTaskId }), |
| 291 | ? {} | 291 | ...(message.senderThreadId === undefined |
| 292 | : { senderThreadId: message.senderThreadId }), | 292 | ? {} |
| 293 | }); | 293 | : { senderThreadId: message.senderThreadId }), |
| 294 | }) | ||
| 295 | .pipe( | ||
| 296 | // The turn that just completed leaves no further retry | ||
| 297 | // target once the instance is off: the completed turn | ||
| 298 | // stands as the thread's result instead of going | ||
| 299 | // through the outbox's bounded retry on a refusal that | ||
| 300 | // retrying cannot change. | ||
| 301 | Effect.catchTags({ | ||
| 302 | ThreadManagementProviderInstanceDisabledError: (disabledError) => | ||
| 303 | Effect.logInfo( | ||
| 304 | "orchestration-v2.effect-worker.steer-follow-up-disabled", | ||
| 305 | { | ||
| 306 | threadId: effect.threadId, | ||
| 307 | messageId: message.id, | ||
| 308 | projectId: disabledError.projectId, | ||
| 309 | instanceId: disabledError.instanceId, | ||
| 310 | }, | ||
| 311 | ), | ||
| 312 | }), | ||
| 313 | ); | ||
| 294 | }), | 314 | }), |
| 295 | ), | 315 | ), |
| 296 | Effect.mapError( | 316 | Effect.mapError( |
apps/server/src/orchestration-v2/Orchestrator.ts+25| ... | @@ -53,6 +53,7 @@ import { | ... | @@ -53,6 +53,7 @@ import { |
| 53 | orchestrationV2RunWorkStartedAt, | 53 | orchestrationV2RunWorkStartedAt, |
| 54 | ProviderInstanceId, | 54 | ProviderInstanceId, |
| 55 | ProviderInteractionMode, | 55 | ProviderInteractionMode, |
| 56 | resolveProviderInstanceEnabled, | ||
| 56 | type ProviderSessionId, | 57 | type ProviderSessionId, |
| 57 | RunId, | 58 | RunId, |
| 58 | RuntimeMode, | 59 | RuntimeMode, |
| ... | @@ -131,6 +132,7 @@ import { | ... | @@ -131,6 +132,7 @@ import { |
| 131 | } from "./ThreadForkService.ts"; | 132 | } from "./ThreadForkService.ts"; |
| 132 | import { planThreadDeletion } from "./ThreadDeletion.ts"; | 133 | import { planThreadDeletion } from "./ThreadDeletion.ts"; |
| 133 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 134 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 135 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 134 | 136 | ||
| 135 | export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDispatchError>()( | 137 | export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDispatchError>()( |
| 136 | "OrchestratorDispatchError", | 138 | "OrchestratorDispatchError", |
| ... | @@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 832 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | 834 | const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; |
| 833 | const providerSessions = yield* ProviderSessionManagerV2; | 835 | const providerSessions = yield* ProviderSessionManagerV2; |
| 834 | const providerSwitchService = yield* ProviderSwitchServiceV2; | 836 | const providerSwitchService = yield* ProviderSwitchServiceV2; |
| 837 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 835 | const runtimePolicy = yield* RuntimePolicyV2; | 838 | const runtimePolicy = yield* RuntimePolicyV2; |
| 836 | const threadForkService = yield* ThreadForkServiceV2; | 839 | const threadForkService = yield* ThreadForkServiceV2; |
| 837 | const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; | 840 | const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; |
| ... | @@ -1318,8 +1321,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -1318,8 +1321,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 1318 | failedRun?.id === options?.endedRunId && | 1321 | failedRun?.id === options?.endedRunId && |
| 1319 | failedRun?.workspacePreparation?.type === "worktree" && | 1322 | failedRun?.workspacePreparation?.type === "worktree" && |
| 1320 | projection.thread.worktreePath === null; | 1323 | projection.thread.worktreePath === null; |
| 1324 | // A project can turn a provider off while a message waits in the queue, | ||
| 1325 | // including between a run ending and this check, or while the queue is | ||
| 1326 | // held and someone clicks resume. Neither path may start that run. Only | ||
| 1327 | // an explicit turn-off holds the queue: an instance with no settings | ||
| 1328 | // entry was already admitted when the message was queued. | ||
| 1329 | const settings = yield* serverSettings.getSettings.pipe(Effect.orDie); | ||
| 1330 | const projectOverride = | ||
| 1331 | settings.projectSettingsOverrides[projection.thread.projectId]?.providerInstances?.[ | ||
| 1332 | queuedRun.providerInstanceId | ||
| 1333 | ]?.enabled; | ||
| 1334 | const machineInstance = Object.hasOwn( | ||
| 1335 | settings.providerInstances, | ||
| 1336 | queuedRun.providerInstanceId, | ||
| 1337 | ) | ||
| 1338 | ? settings.providerInstances[queuedRun.providerInstanceId] | ||
| 1339 | : undefined; | ||
| 1340 | const instanceDisabledForProject = | ||
| 1341 | projectOverride !== undefined | ||
| 1342 | ? !projectOverride | ||
| 1343 | : machineInstance !== undefined && !resolveProviderInstanceEnabled(machineInstance); | ||
| 1321 | if ( | 1344 | if ( |
| 1322 | worktreeMissing || | 1345 | worktreeMissing || |
| 1346 | instanceDisabledForProject || | ||
| 1323 | (failureClass !== undefined && | 1347 | (failureClass !== undefined && |
| 1324 | failureClass !== "validation_error" && | 1348 | failureClass !== "validation_error" && |
| 1325 | failedRun?.providerInstanceId === queuedRun.providerInstanceId) | 1349 | failedRun?.providerInstanceId === queuedRun.providerInstanceId) |
| ... | @@ -11437,6 +11461,7 @@ export const layer: Layer.Layer< | ... | @@ -11437,6 +11461,7 @@ export const layer: Layer.Layer< |
| 11437 | | ProviderSwitchServiceV2 | 11461 | | ProviderSwitchServiceV2 |
| 11438 | | ProjectionStoreV2 | 11462 | | ProjectionStoreV2 |
| 11439 | | RuntimePolicyV2 | 11463 | | RuntimePolicyV2 |
| 11464 | | ServerSettings.ServerSettingsService | ||
| 11440 | | ThreadForkServiceV2 | 11465 | | ThreadForkServiceV2 |
| 11441 | > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( | 11466 | > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( |
| 11442 | Layer.provide(ThreadCommandExecutor.layer), | 11467 | Layer.provide(ThreadCommandExecutor.layer), |
apps/server/src/orchestration-v2/ProviderContinuationService.test.ts+188| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 3 | MessageId, | 3 | MessageId, |
| 4 | ProjectId, | ||
| 4 | ProviderDriverKind, | 5 | ProviderDriverKind, |
| 6 | ProviderInstanceId, | ||
| 5 | ProviderThreadId, | 7 | ProviderThreadId, |
| 6 | RunId, | 8 | RunId, |
| 7 | ThreadId, | 9 | ThreadId, |
| ... | @@ -11,6 +13,7 @@ import { | ... | @@ -11,6 +13,7 @@ import { |
| 11 | import * as Deferred from "effect/Deferred"; | 13 | import * as Deferred from "effect/Deferred"; |
| 12 | import * as DateTime from "effect/DateTime"; | 14 | import * as DateTime from "effect/DateTime"; |
| 13 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| 16 | import * as Queue from "effect/Queue"; | 19 | import * as Queue from "effect/Queue"; |
| ... | @@ -529,6 +532,191 @@ describe("ProviderContinuationService", () => { | ... | @@ -529,6 +532,191 @@ describe("ProviderContinuationService", () => { |
| 529 | }); | 532 | }); |
| 530 | }); | 533 | }); |
| 531 | 534 | ||
| 535 | it.effect( | ||
| 536 | "a project-disabled instance refuses a delegated-completion wake exactly once, acknowledges it, and never retries", | ||
| 537 | () => { | ||
| 538 | return Effect.gen(function* () { | ||
| 539 | const attempts = yield* Ref.make(0); | ||
| 540 | const cleared = yield* Ref.make(false); | ||
| 541 | const acknowledged = yield* Queue.unbounded<unknown>(); | ||
| 542 | const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 543 | getThreadRecords: () => Effect.succeed(delegatedProjection()), | ||
| 544 | dispatch: (command) => { | ||
| 545 | if ((command as { type: string }).type === "message.dispatch") { | ||
| 546 | return Ref.update(attempts, (count) => count + 1).pipe( | ||
| 547 | Effect.andThen( | ||
| 548 | Effect.fail( | ||
| 549 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 550 | projectId: ProjectId.make("project-provider-continuation-disabled"), | ||
| 551 | instanceId: ProviderInstanceId.make( | ||
| 552 | "instance-provider-continuation-disabled", | ||
| 553 | ), | ||
| 554 | }) as never, | ||
| 555 | ), | ||
| 556 | ), | ||
| 557 | ); | ||
| 558 | } | ||
| 559 | return Queue.offer(acknowledged, command).pipe(Effect.as({} as never)); | ||
| 560 | }, | ||
| 561 | }); | ||
| 562 | const layerWorker = ProviderContinuationService.layer.pipe( | ||
| 563 | Layer.provide( | ||
| 564 | Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), | ||
| 565 | ), | ||
| 566 | ); | ||
| 567 | |||
| 568 | yield* Effect.gen(function* () { | ||
| 569 | const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | ||
| 570 | yield* requests.offer({ | ||
| 571 | threadId, | ||
| 572 | providerThreadId, | ||
| 573 | driver, | ||
| 574 | detail: null, | ||
| 575 | delivery: "message_text", | ||
| 576 | clearIfCurrent: () => Ref.set(cleared, true), | ||
| 577 | delegatedCompletion: { | ||
| 578 | parentRunId, | ||
| 579 | generation: 1, | ||
| 580 | messageId: delegatedMessageId, | ||
| 581 | }, | ||
| 582 | }); | ||
| 583 | yield* Effect.yieldNow; | ||
| 584 | assert.equal(yield* Ref.get(attempts), 1); | ||
| 585 | assert.isTrue(yield* Ref.get(cleared)); | ||
| 586 | |||
| 587 | // The refused delivery is acknowledged administratively, task by | ||
| 588 | // task, so the cohort's delivery is consumed instead of left open. | ||
| 589 | const acknowledgements = (yield* Queue.takeAll(acknowledged)) as ReadonlyArray<{ | ||
| 590 | type: string; | ||
| 591 | taskId: string; | ||
| 592 | observedByRunId: string | null; | ||
| 593 | }>; | ||
| 594 | assert.deepEqual( | ||
| 595 | acknowledgements.map((command) => command.taskId), | ||
| 596 | ["node-provider-continuation-first", "node-provider-continuation-second"], | ||
| 597 | ); | ||
| 598 | for (const command of acknowledgements) { | ||
| 599 | assert.equal(command.type, "delegated_task.completion-delivery.acknowledge"); | ||
| 600 | assert.isNull(command.observedByRunId); | ||
| 601 | } | ||
| 602 | |||
| 603 | // A refusal is terminal: no backoff timer is pending behind it, | ||
| 604 | // so no further dispatch attempt can ever land. | ||
| 605 | yield* TestClock.adjust("1 hour"); | ||
| 606 | yield* Effect.yieldNow; | ||
| 607 | assert.equal(yield* Ref.get(attempts), 1); | ||
| 608 | assert.equal(yield* Queue.size(acknowledged), 0); | ||
| 609 | }).pipe( | ||
| 610 | Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), | ||
| 611 | Effect.scoped, | ||
| 612 | ); | ||
| 613 | }); | ||
| 614 | }, | ||
| 615 | ); | ||
| 616 | |||
| 617 | it.effect( | ||
| 618 | "clears the adapter's sticky continuation flag when a non-delegated wake is refused", | ||
| 619 | () => { | ||
| 620 | return Effect.gen(function* () { | ||
| 621 | const requested = yield* Ref.make(true); | ||
| 622 | const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 623 | getThreadRecords: () => Effect.succeed(projection), | ||
| 624 | dispatch: () => | ||
| 625 | Effect.fail( | ||
| 626 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 627 | projectId: ProjectId.make("project-provider-continuation-disabled"), | ||
| 628 | instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"), | ||
| 629 | }) as never, | ||
| 630 | ), | ||
| 631 | }); | ||
| 632 | const layerWorker = ProviderContinuationService.layer.pipe( | ||
| 633 | Layer.provide( | ||
| 634 | Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), | ||
| 635 | ), | ||
| 636 | ); | ||
| 637 | |||
| 638 | // Mirrors the ACP adapter's own dispatchIfCurrent: on a failed effect | ||
| 639 | // it clears its sticky "continuation requested" flag before | ||
| 640 | // re-raising. If this service pre-recovered the failure before | ||
| 641 | // handing the effect here, this flag would stay stuck set and | ||
| 642 | // hasPendingBackgroundWork would never clear. | ||
| 643 | const dispatchIfCurrent = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 644 | Effect.gen(function* () { | ||
| 645 | const exit = yield* Effect.exit(effect); | ||
| 646 | if (Exit.isFailure(exit)) { | ||
| 647 | yield* Ref.set(requested, false); | ||
| 648 | return yield* Effect.failCause(exit.cause); | ||
| 649 | } | ||
| 650 | return Option.some(exit.value); | ||
| 651 | }); | ||
| 652 | |||
| 653 | yield* Effect.gen(function* () { | ||
| 654 | const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | ||
| 655 | yield* requests.offer(request(dispatchIfCurrent)); | ||
| 656 | yield* Effect.yieldNow; | ||
| 657 | yield* Effect.yieldNow; | ||
| 658 | assert.isFalse( | ||
| 659 | yield* Ref.get(requested), | ||
| 660 | "a disabled instance must still clear the adapter's sticky offer flag", | ||
| 661 | ); | ||
| 662 | }).pipe( | ||
| 663 | Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), | ||
| 664 | Effect.scoped, | ||
| 665 | ); | ||
| 666 | }); | ||
| 667 | }, | ||
| 668 | ); | ||
| 669 | |||
| 670 | it.effect( | ||
| 671 | "clears a sticky continuation offer for an adapter whose dispatchIfCurrent does not self-clear on failure", | ||
| 672 | () => { | ||
| 673 | return Effect.gen(function* () { | ||
| 674 | const wakeHeld = yield* Ref.make(true); | ||
| 675 | const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 676 | getThreadRecords: () => Effect.succeed(projection), | ||
| 677 | dispatch: () => | ||
| 678 | Effect.fail( | ||
| 679 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 680 | projectId: ProjectId.make("project-provider-continuation-disabled"), | ||
| 681 | instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"), | ||
| 682 | }) as never, | ||
| 683 | ), | ||
| 684 | }); | ||
| 685 | const layerWorker = ProviderContinuationService.layer.pipe( | ||
| 686 | Layer.provide( | ||
| 687 | Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads), | ||
| 688 | ), | ||
| 689 | ); | ||
| 690 | |||
| 691 | // Mirrors the Muse adapter: dispatchIfCurrent forwards the dispatch | ||
| 692 | // effect without any failure handling of its own (unlike ACP or Pi), | ||
| 693 | // so only the explicit clearIfCurrent callback can clear the sticky | ||
| 694 | // "wake held" flag. Without it, a disabled instance leaves the flag | ||
| 695 | // set forever and no later background turn is ever offered. | ||
| 696 | const dispatchIfCurrent = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 697 | Effect.asSome(effect); | ||
| 698 | |||
| 699 | yield* Effect.gen(function* () { | ||
| 700 | const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests; | ||
| 701 | yield* requests.offer({ | ||
| 702 | ...request(dispatchIfCurrent), | ||
| 703 | clearIfCurrent: () => Ref.set(wakeHeld, false), | ||
| 704 | }); | ||
| 705 | yield* Effect.yieldNow; | ||
| 706 | yield* Effect.yieldNow; | ||
| 707 | assert.isFalse( | ||
| 708 | yield* Ref.get(wakeHeld), | ||
| 709 | "a disabled instance must clear the offer via the explicit callback " + | ||
| 710 | "when the adapter itself does not clear on dispatch failure", | ||
| 711 | ); | ||
| 712 | }).pipe( | ||
| 713 | Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)), | ||
| 714 | Effect.scoped, | ||
| 715 | ); | ||
| 716 | }); | ||
| 717 | }, | ||
| 718 | ); | ||
| 719 | |||
| 532 | it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => { | 720 | it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => { |
| 533 | return Effect.gen(function* () { | 721 | return Effect.gen(function* () { |
| 534 | const dispatched = yield* Queue.unbounded<unknown>(); | 722 | const dispatched = yield* Queue.unbounded<unknown>(); |
apps/server/src/orchestration-v2/ProviderContinuationService.ts+102-18| ... | @@ -126,22 +126,77 @@ export const layer = Layer.effectDiscard( | ... | @@ -126,22 +126,77 @@ export const layer = Layer.effectDiscard( |
| 126 | fixtureName: "delegated-completion", | 126 | fixtureName: "delegated-completion", |
| 127 | commandName: "dispatch", | 127 | commandName: "dispatch", |
| 128 | }); | 128 | }); |
| 129 | yield* threads.dispatch({ | 129 | yield* threads |
| 130 | type: "message.dispatch", | 130 | .dispatch({ |
| 131 | commandId, | 131 | type: "message.dispatch", |
| 132 | threadId: request.threadId, | 132 | commandId, |
| 133 | messageId: delivery.messageId, | 133 | threadId: request.threadId, |
| 134 | text: delegatedCompletionText(delivery.taskIds), | 134 | messageId: delivery.messageId, |
| 135 | attachments: [], | 135 | text: delegatedCompletionText(delivery.taskIds), |
| 136 | dispatchMode: { type: "queue_after_active" }, | 136 | attachments: [], |
| 137 | createdBy: "agent", | 137 | dispatchMode: { type: "queue_after_active" }, |
| 138 | creationSource: "server", | 138 | createdBy: "agent", |
| 139 | delegatedCompletion: { | 139 | creationSource: "server", |
| 140 | parentRunId: request.delegatedCompletion.parentRunId, | 140 | delegatedCompletion: { |
| 141 | generation: delivery.generation, | 141 | parentRunId: request.delegatedCompletion.parentRunId, |
| 142 | taskIds: delivery.taskIds, | 142 | generation: delivery.generation, |
| 143 | }, | 143 | taskIds: delivery.taskIds, |
| 144 | }); | 144 | }, |
| 145 | }) | ||
| 146 | .pipe( | ||
| 147 | // The project turned the target instance off between the cohort | ||
| 148 | // read above and this dispatch. The finalize that claimed this | ||
| 149 | // delivery already wrote the children's results to the parent's | ||
| 150 | // history unconditionally, so nothing is lost: acknowledge each | ||
| 151 | // task administratively (observedByRunId: null, the same shape | ||
| 152 | // task_status uses when no run observed it) to consume this | ||
| 153 | // delivery instead of leaving it open to retry. The parent's | ||
| 154 | // next turn against an enabled instance reads those results from | ||
| 155 | // history the same way any turn does; nothing here re-offers | ||
| 156 | // this generation. | ||
| 157 | Effect.catchTags({ | ||
| 158 | ThreadManagementProviderInstanceDisabledError: (error) => | ||
| 159 | Effect.logInfo( | ||
| 160 | "orchestration-v2.provider-continuation.delegated-completion-disabled", | ||
| 161 | { | ||
| 162 | threadId: request.threadId, | ||
| 163 | projectId: error.projectId, | ||
| 164 | instanceId: error.instanceId, | ||
| 165 | }, | ||
| 166 | ).pipe( | ||
| 167 | Effect.andThen( | ||
| 168 | Effect.forEach( | ||
| 169 | delivery.taskIds, | ||
| 170 | (taskId) => | ||
| 171 | ids.allocate | ||
| 172 | .command({ | ||
| 173 | fixtureName: "delegated-completion", | ||
| 174 | commandName: "acknowledge-disabled", | ||
| 175 | }) | ||
| 176 | .pipe( | ||
| 177 | Effect.flatMap((ackCommandId) => | ||
| 178 | threads.dispatch({ | ||
| 179 | type: "delegated_task.completion-delivery.acknowledge", | ||
| 180 | commandId: ackCommandId, | ||
| 181 | parentThreadId: request.threadId, | ||
| 182 | taskId, | ||
| 183 | observedByRunId: null, | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | ), | ||
| 187 | { discard: true }, | ||
| 188 | ), | ||
| 189 | ), | ||
| 190 | Effect.andThen( | ||
| 191 | request.clearIfCurrent !== undefined | ||
| 192 | ? request.clearIfCurrent() | ||
| 193 | : request.dispatchIfCurrent !== undefined | ||
| 194 | ? request.dispatchIfCurrent(Effect.void) | ||
| 195 | : Effect.void, | ||
| 196 | ), | ||
| 197 | ), | ||
| 198 | }), | ||
| 199 | ); | ||
| 145 | yield* clearRetryAttempt(retryKey); | 200 | yield* clearRetryAttempt(retryKey); |
| 146 | return; | 201 | return; |
| 147 | } | 202 | } |
| ... | @@ -174,11 +229,40 @@ export const layer = Layer.effectDiscard( | ... | @@ -174,11 +229,40 @@ export const layer = Layer.effectDiscard( |
| 174 | // marker or the turn settles immediately having prompted nothing. | 229 | // marker or the turn settles immediately having prompted nothing. |
| 175 | creationSource: request.delivery === "message_text" ? "server" : "provider", | 230 | creationSource: request.delivery === "message_text" ? "server" : "provider", |
| 176 | }); | 231 | }); |
| 232 | // The adapter's buffered wake has nowhere else to go once the | ||
| 233 | // instance is off: drop it rather than warn, since nothing here | ||
| 234 | // retries it either way. This catch sits outside dispatchIfCurrent so | ||
| 235 | // an adapter that clears its own sticky offer flag on dispatch | ||
| 236 | // failure (inside dispatchIfCurrent) observes the raw failure first. | ||
| 237 | // Not every adapter self-clears that way, so the explicit callback is | ||
| 238 | // called here too; clearIfCurrent implementations are idempotent, so | ||
| 239 | // a source that already cleared is unaffected. | ||
| 240 | const logInstanceDisabled = ( | ||
| 241 | error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError, | ||
| 242 | ) => | ||
| 243 | Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", { | ||
| 244 | threadId: request.threadId, | ||
| 245 | providerThreadId: request.providerThreadId, | ||
| 246 | projectId: error.projectId, | ||
| 247 | instanceId: error.instanceId, | ||
| 248 | }).pipe( | ||
| 249 | Effect.andThen( | ||
| 250 | request.clearIfCurrent !== undefined ? request.clearIfCurrent() : Effect.void, | ||
| 251 | ), | ||
| 252 | ); | ||
| 177 | if (request.dispatchIfCurrent === undefined) { | 253 | if (request.dispatchIfCurrent === undefined) { |
| 178 | yield* dispatch; | 254 | yield* dispatch.pipe( |
| 255 | Effect.catchTags({ | ||
| 256 | ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, | ||
| 257 | }), | ||
| 258 | ); | ||
| 179 | return; | 259 | return; |
| 180 | } | 260 | } |
| 181 | yield* request.dispatchIfCurrent(dispatch); | 261 | yield* request.dispatchIfCurrent(dispatch).pipe( |
| 262 | Effect.catchTags({ | ||
| 263 | ThreadManagementProviderInstanceDisabledError: logInstanceDisabled, | ||
| 264 | }), | ||
| 265 | ); | ||
| 182 | }, | 266 | }, |
| 183 | ); | 267 | ); |
| 184 | 268 |
apps/server/src/orchestration-v2/RestartContinuation.ts+39-22| ... | @@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta | ... | @@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta |
| 159 | : note.settled | 159 | : note.settled |
| 160 | ? noteText | 160 | ? noteText |
| 161 | : `${noteText}\n\n${CONTINUE_PROMPT}`; | 161 | : `${noteText}\n\n${CONTINUE_PROMPT}`; |
| 162 | yield* threads.dispatch({ | 162 | yield* threads |
| 163 | type: "message.dispatch", | 163 | .dispatch({ |
| 164 | commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), | 164 | type: "message.dispatch", |
| 165 | threadId: input.threadId, | 165 | commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), |
| 166 | messageId, | 166 | threadId: input.threadId, |
| 167 | text, | 167 | messageId, |
| 168 | // Shown as a work log row; the prompt the agent got is its detail. | 168 | text, |
| 169 | notification: { | 169 | // Shown as a work log row; the prompt the agent got is its detail. |
| 170 | source: { kind: "system" }, | 170 | notification: { |
| 171 | outcome: "updated", | 171 | source: { kind: "system" }, |
| 172 | summary: note.settled | 172 | outcome: "updated", |
| 173 | ? "T3 Code restarted and stopped background work" | 173 | summary: note.settled |
| 174 | : "T3 Code restarted and resumed this turn", | 174 | ? "T3 Code restarted and stopped background work" |
| 175 | detail: text, | 175 | : "T3 Code restarted and resumed this turn", |
| 176 | }, | 176 | detail: text, |
| 177 | attachments: [], | 177 | }, |
| 178 | modelSelection: source.modelSelection, | 178 | attachments: [], |
| 179 | dispatchMode: { type: "start_immediately" }, | 179 | modelSelection: source.modelSelection, |
| 180 | createdBy: "agent", | 180 | dispatchMode: { type: "start_immediately" }, |
| 181 | creationSource: "server", | 181 | createdBy: "agent", |
| 182 | restartContinuationOfRunId: input.sourceRunId, | 182 | creationSource: "server", |
| 183 | }); | 183 | restartContinuationOfRunId: input.sourceRunId, |
| 184 | }) | ||
| 185 | .pipe( | ||
| 186 | // The project turned this instance off since the restart. No turn | ||
| 187 | // resumes, and the success path below still runs so a delegated | ||
| 188 | // child waiting on this run is recovered immediately instead of | ||
| 189 | // after EffectWorker exhausts its retries on a refusal that will | ||
| 190 | // not change. | ||
| 191 | Effect.catchTags({ | ||
| 192 | ThreadManagementProviderInstanceDisabledError: (error) => | ||
| 193 | Effect.logInfo("orchestration-v2.restart-continuation.instance-disabled", { | ||
| 194 | threadId: input.threadId, | ||
| 195 | sourceRunId: input.sourceRunId, | ||
| 196 | projectId: error.projectId, | ||
| 197 | instanceId: error.instanceId, | ||
| 198 | }), | ||
| 199 | }), | ||
| 200 | ); | ||
| 184 | }, | 201 | }, |
| 185 | // A delegated child this declined to continue still owes its parent a | 202 | // A delegated child this declined to continue still owes its parent a |
| 186 | // result. Once a continuation run exists this is a no-op; that run settles it. | 203 | // result. Once a continuation run exists this is a no-op; that run settles it. |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+182-7| ... | @@ -118,8 +118,13 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -118,8 +118,13 @@ function makeHarness(options: HarnessOptions = {}) { |
| 118 | layerRegistry, | 118 | layerRegistry, |
| 119 | { databaseLayer: layerDatabase, runEffectWorker: false }, | 119 | { databaseLayer: layerDatabase, runEffectWorker: false }, |
| 120 | ); | 120 | ); |
| 121 | const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); | 121 | const layerServerSettings = ServerSettings.layerTest(options.serverSettings); |
| 122 | const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); | 122 | const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase)); |
| 123 | const layerThreadManagement = ThreadManagement.layer.pipe( | ||
| 124 | Layer.provide(layerOrchestrator), | ||
| 125 | Layer.provide(layerServerSettings), | ||
| 126 | Layer.provide(layerReceipts), | ||
| 127 | ); | ||
| 123 | const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); | 128 | const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); |
| 124 | const createWorktree = vi.fn( | 129 | const createWorktree = vi.fn( |
| 125 | options.createWorktree ?? | 130 | options.createWorktree ?? |
| ... | @@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 185 | generateThreadTitle, | 190 | generateThreadTitle, |
| 186 | generateBranchName, | 191 | generateBranchName, |
| 187 | }), | 192 | }), |
| 188 | ServerSettings.layerTest(options.serverSettings), | 193 | layerServerSettings, |
| 189 | ProviderRegistryMock.layer(options.providers), | 194 | ProviderRegistryMock.layer(options.providers), |
| 190 | options.managedFolders ?? | 195 | options.managedFolders ?? |
| 191 | Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ | 196 | Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ |
| ... | @@ -1765,6 +1770,70 @@ it.effect("replays a server-allocated launch", () => | ... | @@ -1765,6 +1770,70 @@ it.effect("replays a server-allocated launch", () => |
| 1765 | }), | 1770 | }), |
| 1766 | ); | 1771 | ); |
| 1767 | 1772 | ||
| 1773 | it.effect("refuses a launch when the project disables the model's provider instance", () => { | ||
| 1774 | const harness = makeHarness({ | ||
| 1775 | serverSettings: { | ||
| 1776 | projectSettingsOverrides: { | ||
| 1777 | [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, | ||
| 1778 | }, | ||
| 1779 | }, | ||
| 1780 | }); | ||
| 1781 | return Effect.gen(function* () { | ||
| 1782 | const launches = yield* ThreadLaunch.ThreadLaunchService; | ||
| 1783 | const failed = yield* launches | ||
| 1784 | .launch( | ||
| 1785 | launchInput({ | ||
| 1786 | command: "command:launch:project-disabled-instance", | ||
| 1787 | thread: "thread:launch:project-disabled-instance", | ||
| 1788 | message: "Hello", | ||
| 1789 | }), | ||
| 1790 | ) | ||
| 1791 | .pipe(Effect.flip); | ||
| 1792 | if (failed._tag !== "ThreadManagementProviderInstanceDisabledError") { | ||
| 1793 | assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${failed._tag}`); | ||
| 1794 | } | ||
| 1795 | assert.equal(failed.projectId, projectId); | ||
| 1796 | assert.equal(failed.instanceId, modelSelection.instanceId); | ||
| 1797 | }).pipe(Effect.provide(harness.layer)); | ||
| 1798 | }); | ||
| 1799 | |||
| 1800 | it.effect("replays an accepted launch after the project disables the instance", () => { | ||
| 1801 | const harness = makeHarness(); | ||
| 1802 | return Effect.gen(function* () { | ||
| 1803 | const launches = yield* ThreadLaunch.ThreadLaunchService; | ||
| 1804 | const settings = yield* ServerSettings.ServerSettingsService; | ||
| 1805 | const input = launchInput({ | ||
| 1806 | command: "command:launch:retry-after-disable", | ||
| 1807 | thread: "thread:launch:retry-after-disable", | ||
| 1808 | message: "Hello", | ||
| 1809 | }); | ||
| 1810 | const first = yield* launches.launch(input); | ||
| 1811 | |||
| 1812 | yield* settings.updateSettings({ | ||
| 1813 | projectSettingsOverrides: { | ||
| 1814 | [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, | ||
| 1815 | }, | ||
| 1816 | }); | ||
| 1817 | |||
| 1818 | const retry = yield* launches.launch(input); | ||
| 1819 | assert.equal(retry.threadId, first.threadId); | ||
| 1820 | assert.isTrue(retry.resumed); | ||
| 1821 | |||
| 1822 | const fresh = yield* launches | ||
| 1823 | .launch( | ||
| 1824 | launchInput({ | ||
| 1825 | command: "command:launch:fresh-after-disable", | ||
| 1826 | thread: "thread:launch:fresh-after-disable", | ||
| 1827 | message: "Hello", | ||
| 1828 | }), | ||
| 1829 | ) | ||
| 1830 | .pipe(Effect.flip); | ||
| 1831 | if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { | ||
| 1832 | assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); | ||
| 1833 | } | ||
| 1834 | }).pipe(Effect.provide(harness.layer)); | ||
| 1835 | }); | ||
| 1836 | |||
| 1768 | it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { | 1837 | it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { |
| 1769 | const harness = makeHarness(); | 1838 | const harness = makeHarness(); |
| 1770 | return Effect.gen(function* () { | 1839 | return Effect.gen(function* () { |
| ... | @@ -1782,7 +1851,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id | ... | @@ -1782,7 +1851,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id |
| 1782 | }) | 1851 | }) |
| 1783 | .pipe(Effect.flip); | 1852 | .pipe(Effect.flip); |
| 1784 | assert.notEqual(first.threadId, ThreadId.make("thread:launch:allocated-mismatch")); | 1853 | assert.notEqual(first.threadId, ThreadId.make("thread:launch:allocated-mismatch")); |
| 1785 | assert.equal(failed._tag, "ThreadLaunchError"); | 1854 | if (failed._tag !== "ThreadLaunchError") { |
| 1855 | assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); | ||
| 1856 | } | ||
| 1786 | assert.equal(failed.operation, "create-thread"); | 1857 | assert.equal(failed.operation, "create-thread"); |
| 1787 | assert.include(String(failed.cause), "cannot be replayed"); | 1858 | assert.include(String(failed.cause), "cannot be replayed"); |
| 1788 | }).pipe(Effect.provide(harness.layer)); | 1859 | }).pipe(Effect.provide(harness.layer)); |
| ... | @@ -1804,7 +1875,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () = | ... | @@ -1804,7 +1875,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () = |
| 1804 | projectId: otherProjectId, | 1875 | projectId: otherProjectId, |
| 1805 | }) | 1876 | }) |
| 1806 | .pipe(Effect.flip); | 1877 | .pipe(Effect.flip); |
| 1807 | assert.equal(failed._tag, "ThreadLaunchError"); | 1878 | if (failed._tag !== "ThreadLaunchError") { |
| 1879 | assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); | ||
| 1880 | } | ||
| 1808 | assert.equal(failed.operation, "resolve-project"); | 1881 | assert.equal(failed.operation, "resolve-project"); |
| 1809 | assert.equal(failed.threadId, first.threadId); | 1882 | assert.equal(failed.threadId, first.threadId); |
| 1810 | assert.equal(failed.cause, "Project identity changed."); | 1883 | assert.equal(failed.cause, "Project identity changed."); |
| ... | @@ -1828,7 +1901,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted", | ... | @@ -1828,7 +1901,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted", |
| 1828 | threadId: first.threadId, | 1901 | threadId: first.threadId, |
| 1829 | }); | 1902 | }); |
| 1830 | const failed = yield* launches.launch(rest).pipe(Effect.flip); | 1903 | const failed = yield* launches.launch(rest).pipe(Effect.flip); |
| 1831 | assert.equal(failed._tag, "ThreadLaunchError"); | 1904 | if (failed._tag !== "ThreadLaunchError") { |
| 1905 | assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); | ||
| 1906 | } | ||
| 1832 | assert.equal(failed.operation, "create-thread"); | 1907 | assert.equal(failed.operation, "create-thread"); |
| 1833 | assert.equal(failed.threadId, first.threadId); | 1908 | assert.equal(failed.threadId, first.threadId); |
| 1834 | assert.equal(failed.cause, "Thread not found."); | 1909 | assert.equal(failed.cause, "Thread not found."); |
| ... | @@ -1869,7 +1944,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", () | ... | @@ -1869,7 +1944,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", () |
| 1869 | message: "Should not become a launch", | 1944 | message: "Should not become a launch", |
| 1870 | }); | 1945 | }); |
| 1871 | const failed = yield* launches.launch(rest).pipe(Effect.flip); | 1946 | const failed = yield* launches.launch(rest).pipe(Effect.flip); |
| 1872 | assert.equal(failed._tag, "ThreadLaunchError"); | 1947 | if (failed._tag !== "ThreadLaunchError") { |
| 1948 | assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); | ||
| 1949 | } | ||
| 1873 | assert.equal(failed.operation, "create-thread"); | 1950 | assert.equal(failed.operation, "create-thread"); |
| 1874 | assert.include(String(failed.cause), "cannot be replayed"); | 1951 | assert.include(String(failed.cause), "cannot be replayed"); |
| 1875 | const projection = yield* threads.getThreadProjection(threadId); | 1952 | const projection = yield* threads.getThreadProjection(threadId); |
| ... | @@ -1914,7 +1991,9 @@ it.effect("bounds concurrent first launches to one thread per command", () => | ... | @@ -1914,7 +1991,9 @@ it.effect("bounds concurrent first launches to one thread per command", () => |
| 1914 | continue; | 1991 | continue; |
| 1915 | } | 1992 | } |
| 1916 | const error = Cause.findErrorOption(result.cause).pipe(Option.getOrThrow); | 1993 | const error = Cause.findErrorOption(result.cause).pipe(Option.getOrThrow); |
| 1917 | assert.equal(error._tag, "ThreadLaunchError"); | 1994 | if (error._tag !== "ThreadLaunchError") { |
| 1995 | assert.fail(`expected ThreadLaunchError, got ${error._tag}`); | ||
| 1996 | } | ||
| 1918 | assert.equal(error.operation, "create-thread"); | 1997 | assert.equal(error.operation, "create-thread"); |
| 1919 | assert.include(String(error.cause), "cannot be replayed"); | 1998 | assert.include(String(error.cause), "cannot be replayed"); |
| 1920 | const retried = yield* launches.launch(rest); | 1999 | const retried = yield* launches.launch(rest); |
| ... | @@ -2530,3 +2609,99 @@ it.effect.each([ | ... | @@ -2530,3 +2609,99 @@ it.effect.each([ |
| 2530 | }).pipe(Effect.provide(harness.layer)); | 2609 | }).pipe(Effect.provide(harness.layer)); |
| 2531 | }), | 2610 | }), |
| 2532 | ); | 2611 | ); |
| 2612 | |||
| 2613 | it.effect("replays an accepted send after the project disables the instance", () => { | ||
| 2614 | const harness = makeHarness(); | ||
| 2615 | const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-send-retry-" }).pipe( | ||
| 2616 | Layer.provideMerge(NodeServices.layer), | ||
| 2617 | ); | ||
| 2618 | return Effect.gen(function* () { | ||
| 2619 | const launches = yield* ThreadLaunch.ThreadLaunchService; | ||
| 2620 | const settings = yield* ServerSettings.ServerSettingsService; | ||
| 2621 | const created = yield* launches.launch( | ||
| 2622 | launchInput({ | ||
| 2623 | command: "command:send:retry-setup", | ||
| 2624 | thread: "thread:send:retry-after-disable", | ||
| 2625 | }), | ||
| 2626 | ); | ||
| 2627 | |||
| 2628 | const sendInput = { | ||
| 2629 | commandId: CommandId.make("command:send:retry-after-disable"), | ||
| 2630 | projectId, | ||
| 2631 | threadId: created.threadId, | ||
| 2632 | messageId: MessageId.make("message:send:retry-after-disable"), | ||
| 2633 | text: "Hello again", | ||
| 2634 | attachments: [], | ||
| 2635 | mode: "auto" as const, | ||
| 2636 | createdBy: "user" as const, | ||
| 2637 | creationSource: "web" as const, | ||
| 2638 | }; | ||
| 2639 | const first = yield* ThreadMessageIntake.sendToThread(sendInput); | ||
| 2640 | |||
| 2641 | yield* settings.updateSettings({ | ||
| 2642 | projectSettingsOverrides: { | ||
| 2643 | [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, | ||
| 2644 | }, | ||
| 2645 | }); | ||
| 2646 | |||
| 2647 | const retry = yield* ThreadMessageIntake.sendToThread(sendInput); | ||
| 2648 | assert.equal(retry.message.id, first.message.id); | ||
| 2649 | |||
| 2650 | const fresh = yield* ThreadMessageIntake.sendToThread({ | ||
| 2651 | ...sendInput, | ||
| 2652 | commandId: CommandId.make("command:send:fresh-after-disable"), | ||
| 2653 | messageId: MessageId.make("message:send:fresh-after-disable"), | ||
| 2654 | }).pipe(Effect.flip); | ||
| 2655 | if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { | ||
| 2656 | assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); | ||
| 2657 | } | ||
| 2658 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); | ||
| 2659 | }); | ||
| 2660 | |||
| 2661 | it.effect("replays an accepted message.dispatch after the project disables the instance", () => { | ||
| 2662 | const harness = makeHarness(); | ||
| 2663 | const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-dispatch-retry-" }).pipe( | ||
| 2664 | Layer.provideMerge(NodeServices.layer), | ||
| 2665 | ); | ||
| 2666 | return Effect.gen(function* () { | ||
| 2667 | const launches = yield* ThreadLaunch.ThreadLaunchService; | ||
| 2668 | const settings = yield* ServerSettings.ServerSettingsService; | ||
| 2669 | const created = yield* launches.launch( | ||
| 2670 | launchInput({ | ||
| 2671 | command: "command:dispatch:retry-setup", | ||
| 2672 | thread: "thread:dispatch:retry-after-disable", | ||
| 2673 | }), | ||
| 2674 | ); | ||
| 2675 | |||
| 2676 | const dispatchInput = { | ||
| 2677 | type: "message.dispatch" as const, | ||
| 2678 | commandId: CommandId.make("command:dispatch:retry-after-disable"), | ||
| 2679 | threadId: created.threadId, | ||
| 2680 | messageId: MessageId.make("message:dispatch:retry-after-disable"), | ||
| 2681 | text: "Hello again", | ||
| 2682 | attachments: [], | ||
| 2683 | dispatchMode: { type: "queue_after_active" as const }, | ||
| 2684 | createdBy: "user" as const, | ||
| 2685 | creationSource: "web" as const, | ||
| 2686 | }; | ||
| 2687 | const first = yield* ThreadMessageIntake.dispatchCommand(dispatchInput); | ||
| 2688 | |||
| 2689 | yield* settings.updateSettings({ | ||
| 2690 | projectSettingsOverrides: { | ||
| 2691 | [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } }, | ||
| 2692 | }, | ||
| 2693 | }); | ||
| 2694 | |||
| 2695 | const retry = yield* ThreadMessageIntake.dispatchCommand(dispatchInput); | ||
| 2696 | assert.deepEqual(retry, first); | ||
| 2697 | |||
| 2698 | const fresh = yield* ThreadMessageIntake.dispatchCommand({ | ||
| 2699 | ...dispatchInput, | ||
| 2700 | commandId: CommandId.make("command:dispatch:fresh-after-disable"), | ||
| 2701 | messageId: MessageId.make("message:dispatch:fresh-after-disable"), | ||
| 2702 | }).pipe(Effect.flip); | ||
| 2703 | if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") { | ||
| 2704 | assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`); | ||
| 2705 | } | ||
| 2706 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles))); | ||
| 2707 | }); |
apps/server/src/orchestration-v2/ThreadLaunchService.ts+17-4| ... | @@ -167,7 +167,10 @@ export class ThreadLaunchService extends Context.Service< | ... | @@ -167,7 +167,10 @@ export class ThreadLaunchService extends Context.Service< |
| 167 | { | 167 | { |
| 168 | readonly launch: ( | 168 | readonly launch: ( |
| 169 | input: ThreadLaunchInput, | 169 | input: ThreadLaunchInput, |
| 170 | ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>; | 170 | ) => Effect.Effect< |
| 171 | ThreadLaunchResult, | ||
| 172 | ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError | ||
| 173 | >; | ||
| 171 | /** | 174 | /** |
| 172 | * Fails when a new worktree's base ref cannot give provisioning a commit. | 175 | * Fails when a new worktree's base ref cannot give provisioning a commit. |
| 173 | * `launch` accepts such a launch and reports the failure inside the | 176 | * `launch` accepts such a launch and reports the failure inside the |
| ... | @@ -177,9 +180,13 @@ export class ThreadLaunchService extends Context.Service< | ... | @@ -177,9 +180,13 @@ export class ThreadLaunchService extends Context.Service< |
| 177 | input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">, | 180 | input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">, |
| 178 | ) => Effect.Effect<void, ThreadLaunchBaseRefError>; | 181 | ) => Effect.Effect<void, ThreadLaunchBaseRefError>; |
| 179 | /** Dispatches prepared-run.retry and prepares the run's workspace again. */ | 182 | /** Dispatches prepared-run.retry and prepares the run's workspace again. */ |
| 180 | readonly retryPreparation: ( | 183 | readonly retryPreparation: (input: ThreadLaunchRetryInput) => Effect.Effect< |
| 181 | input: ThreadLaunchRetryInput, | 184 | Orchestrator.OrchestratorV2DispatchResult, |
| 182 | ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>; | 185 | // Dispatches prepared-run.retry, a command the enablement chokepoint |
| 186 | // never gates; the type stays accurate anyway. | ||
| 187 | | Orchestrator.OrchestratorV2Error | ||
| 188 | | ThreadManagement.ThreadManagementProviderInstanceDisabledError | ||
| 189 | >; | ||
| 183 | } | 190 | } |
| 184 | >()("t3/orchestration-v2/ThreadLaunchService") {} | 191 | >()("t3/orchestration-v2/ThreadLaunchService") {} |
| 185 | 192 | ||
| ... | @@ -341,6 +348,7 @@ const make = Effect.gen(function* () { | ... | @@ -341,6 +348,7 @@ const make = Effect.gen(function* () { |
| 341 | : ServerSettings.resolveSourceControlWriterModelSelection( | 348 | : ServerSettings.resolveSourceControlWriterModelSelection( |
| 342 | settings, | 349 | settings, |
| 343 | yield* providerRegistry.getProviders, | 350 | yield* providerRegistry.getProviders, |
| 351 | input.projectId, | ||
| 344 | ); | 352 | ); |
| 345 | return yield* textGeneration | 353 | return yield* textGeneration |
| 346 | .generateBranchName({ | 354 | .generateBranchName({ |
| ... | @@ -764,6 +772,11 @@ const make = Effect.gen(function* () { | ... | @@ -764,6 +772,11 @@ const make = Effect.gen(function* () { |
| 764 | } | 772 | } |
| 765 | 773 | ||
| 766 | const launchReceipt = yield* readReceipt(input, input.commandId); | 774 | const launchReceipt = yield* readReceipt(input, input.commandId); |
| 775 | yield* threads.assertProviderInstanceEnabled({ | ||
| 776 | projectId: input.projectId, | ||
| 777 | commandId: input.commandId, | ||
| 778 | instanceId: input.modelSelection.instanceId, | ||
| 779 | }); | ||
| 767 | return yield* Effect.gen(function* () { | 780 | return yield* Effect.gen(function* () { |
| 768 | // A retried launch has no client-supplied id to replay against, so | 781 | // A retried launch has no client-supplied id to replay against, so |
| 769 | // recover the thread id its accepted create was recorded under before | 782 | // recover the thread id its accepted create was recorded under before |
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+217| ... | @@ -31,6 +31,9 @@ import * as Stream from "effect/Stream"; | ... | @@ -31,6 +31,9 @@ import * as Stream from "effect/Stream"; |
| 31 | import * as TestClock from "effect/testing/TestClock"; | 31 | import * as TestClock from "effect/testing/TestClock"; |
| 32 | 32 | ||
| 33 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; | 33 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; |
| 34 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 35 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 36 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | ||
| 34 | import * as Orchestrator from "./Orchestrator.ts"; | 37 | import * as Orchestrator from "./Orchestrator.ts"; |
| 35 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | 38 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| 36 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 39 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| ... | @@ -273,6 +276,206 @@ it("derives thread management messages from structural error attributes", () => | ... | @@ -273,6 +276,206 @@ it("derives thread management messages from structural error attributes", () => |
| 273 | ); | 276 | ); |
| 274 | }); | 277 | }); |
| 275 | 278 | ||
| 279 | const layerReceiptsTest = CommandReceiptStore.layer.pipe( | ||
| 280 | Layer.provide(SqlitePersistence.layerMemory), | ||
| 281 | ); | ||
| 282 | |||
| 283 | const layerThreadManagementTest = (settingsLayer: ReturnType<typeof ServerSettings.layerTest>) => | ||
| 284 | ThreadManagementService.layer.pipe( | ||
| 285 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | ||
| 286 | Layer.provide(Layer.mock(Orchestrator.OrchestratorV2)({})), | ||
| 287 | Layer.provide(settingsLayer), | ||
| 288 | Layer.provide(layerReceiptsTest), | ||
| 289 | ); | ||
| 290 | |||
| 291 | it.effect("refuses a new turn when the project effectively disables the instance", () => { | ||
| 292 | const projectId = ProjectId.make("project:thread-management:gate-disabled"); | ||
| 293 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 294 | const commandId = CommandId.make("command:thread-management:gate-disabled"); | ||
| 295 | const layerTest = layerThreadManagementTest( | ||
| 296 | ServerSettings.layerTest({ | ||
| 297 | providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, | ||
| 298 | projectSettingsOverrides: { | ||
| 299 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 300 | }, | ||
| 301 | }), | ||
| 302 | ); | ||
| 303 | |||
| 304 | return Effect.gen(function* () { | ||
| 305 | const threads = yield* ThreadManagementService.ThreadManagementService; | ||
| 306 | const error = yield* Effect.flip( | ||
| 307 | threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), | ||
| 308 | ); | ||
| 309 | |||
| 310 | expect(error).toBeInstanceOf( | ||
| 311 | ThreadManagementService.ThreadManagementProviderInstanceDisabledError, | ||
| 312 | ); | ||
| 313 | expect(error).toMatchObject({ projectId, instanceId }); | ||
| 314 | expect(error.message).toBe( | ||
| 315 | `Provider instance "${instanceId}" is disabled for project ${projectId}.`, | ||
| 316 | ); | ||
| 317 | }).pipe(Effect.provide(layerTest)); | ||
| 318 | }); | ||
| 319 | |||
| 320 | it.effect("allows a new turn when the project enables an instance the machine disabled", () => { | ||
| 321 | const projectId = ProjectId.make("project:thread-management:gate-enabled"); | ||
| 322 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 323 | const commandId = CommandId.make("command:thread-management:gate-enabled"); | ||
| 324 | const layerTest = layerThreadManagementTest( | ||
| 325 | ServerSettings.layerTest({ | ||
| 326 | providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, | ||
| 327 | projectSettingsOverrides: { | ||
| 328 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 329 | }, | ||
| 330 | }), | ||
| 331 | ); | ||
| 332 | |||
| 333 | return Effect.gen(function* () { | ||
| 334 | const threads = yield* ThreadManagementService.ThreadManagementService; | ||
| 335 | yield* threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }); | ||
| 336 | }).pipe(Effect.provide(layerTest)); | ||
| 337 | }); | ||
| 338 | |||
| 339 | it.effect("inherits the machine value when the project has no override", () => { | ||
| 340 | const projectId = ProjectId.make("project:thread-management:gate-inherit"); | ||
| 341 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 342 | const commandId = CommandId.make("command:thread-management:gate-inherit"); | ||
| 343 | const layerTest = layerThreadManagementTest( | ||
| 344 | ServerSettings.layerTest({ | ||
| 345 | providerInstances: { [instanceId]: { driver: "codex", enabled: false } }, | ||
| 346 | }), | ||
| 347 | ); | ||
| 348 | |||
| 349 | return Effect.gen(function* () { | ||
| 350 | const threads = yield* ThreadManagementService.ThreadManagementService; | ||
| 351 | const error = yield* Effect.flip( | ||
| 352 | threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }), | ||
| 353 | ); | ||
| 354 | |||
| 355 | expect(error).toBeInstanceOf( | ||
| 356 | ThreadManagementService.ThreadManagementProviderInstanceDisabledError, | ||
| 357 | ); | ||
| 358 | }).pipe(Effect.provide(layerTest)); | ||
| 359 | }); | ||
| 360 | |||
| 361 | const layerDispatchChokepointTest = | ||
| 362 | (settingsLayer: ReturnType<typeof ServerSettings.layerTest>) => (dispatched: Array<string>) => | ||
| 363 | ThreadManagementService.layer.pipe( | ||
| 364 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | ||
| 365 | Layer.provide( | ||
| 366 | Layer.mock(Orchestrator.OrchestratorV2)({ | ||
| 367 | getThreadRecords: () => | ||
| 368 | Effect.succeed({ | ||
| 369 | thread: { | ||
| 370 | id: ThreadId.make("thread:thread-management:chokepoint"), | ||
| 371 | projectId: ProjectId.make("project:thread-management:chokepoint"), | ||
| 372 | deletedAt: null, | ||
| 373 | modelSelection: { | ||
| 374 | instanceId: ProviderInstanceId.make("codex_work"), | ||
| 375 | model: "test", | ||
| 376 | }, | ||
| 377 | }, | ||
| 378 | } as unknown as OrchestrationV2ThreadProjection), | ||
| 379 | dispatch: (command) => | ||
| 380 | Effect.sync(() => { | ||
| 381 | dispatched.push(command.type); | ||
| 382 | return { sequence: 1, storedEvents: [] } as never; | ||
| 383 | }), | ||
| 384 | }), | ||
| 385 | ), | ||
| 386 | Layer.provide(settingsLayer), | ||
| 387 | Layer.provideMerge(layerReceiptsTest), | ||
| 388 | ); | ||
| 389 | |||
| 390 | it.effect( | ||
| 391 | "the dispatch chokepoint refuses a continuation message.dispatch on a project-disabled instance before reaching the orchestrator", | ||
| 392 | () => { | ||
| 393 | const projectId = ProjectId.make("project:thread-management:chokepoint"); | ||
| 394 | const threadId = ThreadId.make("thread:thread-management:chokepoint"); | ||
| 395 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 396 | const commandId = CommandId.make("command:thread-management:chokepoint-disabled"); | ||
| 397 | const dispatched: Array<string> = []; | ||
| 398 | const layerTest = layerDispatchChokepointTest( | ||
| 399 | ServerSettings.layerTest({ | ||
| 400 | providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, | ||
| 401 | projectSettingsOverrides: { | ||
| 402 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 403 | }, | ||
| 404 | }), | ||
| 405 | )(dispatched); | ||
| 406 | |||
| 407 | return Effect.gen(function* () { | ||
| 408 | const threads = yield* ThreadManagementService.ThreadManagementService; | ||
| 409 | const error = yield* Effect.flip( | ||
| 410 | threads.dispatch({ | ||
| 411 | type: "message.dispatch", | ||
| 412 | commandId, | ||
| 413 | threadId, | ||
| 414 | messageId: MessageId.make("message:thread-management:chokepoint-disabled"), | ||
| 415 | text: "Background task completed.", | ||
| 416 | attachments: [], | ||
| 417 | dispatchMode: { type: "queue_after_active" }, | ||
| 418 | createdBy: "agent", | ||
| 419 | creationSource: "server", | ||
| 420 | }), | ||
| 421 | ); | ||
| 422 | |||
| 423 | expect(error).toBeInstanceOf( | ||
| 424 | ThreadManagementService.ThreadManagementProviderInstanceDisabledError, | ||
| 425 | ); | ||
| 426 | expect(error).toMatchObject({ projectId, instanceId }); | ||
| 427 | expect(dispatched).toEqual([]); | ||
| 428 | }).pipe(Effect.provide(layerTest)); | ||
| 429 | }, | ||
| 430 | ); | ||
| 431 | |||
| 432 | it.effect( | ||
| 433 | "a replay of an already-accepted command id dispatches despite the instance now being disabled", | ||
| 434 | () => { | ||
| 435 | const projectId = ProjectId.make("project:thread-management:chokepoint"); | ||
| 436 | const threadId = ThreadId.make("thread:thread-management:chokepoint"); | ||
| 437 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 438 | const commandId = CommandId.make("command:thread-management:chokepoint-replay"); | ||
| 439 | const dispatched: Array<string> = []; | ||
| 440 | const layerTest = layerDispatchChokepointTest( | ||
| 441 | ServerSettings.layerTest({ | ||
| 442 | providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, | ||
| 443 | projectSettingsOverrides: { | ||
| 444 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 445 | }, | ||
| 446 | }), | ||
| 447 | )(dispatched); | ||
| 448 | |||
| 449 | return Effect.gen(function* () { | ||
| 450 | const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; | ||
| 451 | yield* receipts.insertIfAbsent({ | ||
| 452 | commandId, | ||
| 453 | threadId, | ||
| 454 | commandType: "message.dispatch", | ||
| 455 | acceptedAt: yield* DateTime.now, | ||
| 456 | resultSequence: 1, | ||
| 457 | status: "accepted", | ||
| 458 | error: null, | ||
| 459 | }); | ||
| 460 | |||
| 461 | const threads = yield* ThreadManagementService.ThreadManagementService; | ||
| 462 | yield* threads.dispatch({ | ||
| 463 | type: "message.dispatch", | ||
| 464 | commandId, | ||
| 465 | threadId, | ||
| 466 | messageId: MessageId.make("message:thread-management:chokepoint-replay"), | ||
| 467 | text: "Background task completed.", | ||
| 468 | attachments: [], | ||
| 469 | dispatchMode: { type: "queue_after_active" }, | ||
| 470 | createdBy: "agent", | ||
| 471 | creationSource: "server", | ||
| 472 | }); | ||
| 473 | |||
| 474 | expect(dispatched).toEqual(["message.dispatch"]); | ||
| 475 | }).pipe(Effect.provide(layerTest)); | ||
| 476 | }, | ||
| 477 | ); | ||
| 478 | |||
| 276 | it.effect("classifies projection infrastructure failures separately from a missing thread", () => { | 479 | it.effect("classifies projection infrastructure failures separately from a missing thread", () => { |
| 277 | const projectId = ProjectId.make("project:thread-management:projection-failure"); | 480 | const projectId = ProjectId.make("project:thread-management:projection-failure"); |
| 278 | const threadId = ThreadId.make("thread:thread-management:projection-failure"); | 481 | const threadId = ThreadId.make("thread:thread-management:projection-failure"); |
| ... | @@ -288,6 +491,8 @@ it.effect("classifies projection infrastructure failures separately from a missi | ... | @@ -288,6 +491,8 @@ it.effect("classifies projection infrastructure failures separately from a missi |
| 288 | getThreadProjection: () => Effect.fail(projectionError), | 491 | getThreadProjection: () => Effect.fail(projectionError), |
| 289 | }), | 492 | }), |
| 290 | ), | 493 | ), |
| 494 | Layer.provide(ServerSettings.layerTest()), | ||
| 495 | Layer.provide(layerReceiptsTest), | ||
| 291 | ); | 496 | ); |
| 292 | 497 | ||
| 293 | return Effect.gen(function* () { | 498 | return Effect.gen(function* () { |
| ... | @@ -322,6 +527,8 @@ it.effect("uses thread-not-found only after a projection loads outside the proje | ... | @@ -322,6 +527,8 @@ it.effect("uses thread-not-found only after a projection loads outside the proje |
| 322 | getThreadProjection: () => Effect.succeed(projection), | 527 | getThreadProjection: () => Effect.succeed(projection), |
| 323 | }), | 528 | }), |
| 324 | ), | 529 | ), |
| 530 | Layer.provide(ServerSettings.layerTest()), | ||
| 531 | Layer.provide(layerReceiptsTest), | ||
| 325 | ); | 532 | ); |
| 326 | 533 | ||
| 327 | return Effect.gen(function* () { | 534 | return Effect.gen(function* () { |
| ... | @@ -354,6 +561,8 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte | ... | @@ -354,6 +561,8 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte |
| 354 | }), | 561 | }), |
| 355 | ), | 562 | ), |
| 356 | ), | 563 | ), |
| 564 | Layer.provide(ServerSettings.layerTest()), | ||
| 565 | Layer.provide(layerReceiptsTest), | ||
| 357 | ); | 566 | ); |
| 358 | return Effect.gen(function* () { | 567 | return Effect.gen(function* () { |
| 359 | const service = yield* ThreadManagementService.ThreadManagementService; | 568 | const service = yield* ThreadManagementService.ThreadManagementService; |
| ... | @@ -402,6 +611,8 @@ it.effect.each([ | ... | @@ -402,6 +611,8 @@ it.effect.each([ |
| 402 | }), | 611 | }), |
| 403 | }), | 612 | }), |
| 404 | ), | 613 | ), |
| 614 | Layer.provide(ServerSettings.layerTest()), | ||
| 615 | Layer.provide(layerReceiptsTest), | ||
| 405 | ); | 616 | ); |
| 406 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( | 617 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( |
| 407 | Effect.provide(layerTest), | 618 | Effect.provide(layerTest), |
| ... | @@ -473,6 +684,8 @@ it.effect("waitForThread reads the run again only when the run updates", () => | ... | @@ -473,6 +684,8 @@ it.effect("waitForThread reads the run again only when the run updates", () => |
| 473 | }), | 684 | }), |
| 474 | }), | 685 | }), |
| 475 | ), | 686 | ), |
| 687 | Layer.provide(ServerSettings.layerTest()), | ||
| 688 | Layer.provide(layerReceiptsTest), | ||
| 476 | ); | 689 | ); |
| 477 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( | 690 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( |
| 478 | Effect.provide(layerTest), | 691 | Effect.provide(layerTest), |
| ... | @@ -617,6 +830,8 @@ it.effect.each([ | ... | @@ -617,6 +830,8 @@ it.effect.each([ |
| 617 | }), | 830 | }), |
| 618 | ), | 831 | ), |
| 619 | ), | 832 | ), |
| 833 | Layer.provide(ServerSettings.layerTest()), | ||
| 834 | Layer.provide(layerReceiptsTest), | ||
| 620 | ); | 835 | ); |
| 621 | yield* Effect.gen(function* () { | 836 | yield* Effect.gen(function* () { |
| 622 | const service = yield* ThreadManagementService.ThreadManagementService; | 837 | const service = yield* ThreadManagementService.ThreadManagementService; |
| ... | @@ -735,6 +950,8 @@ it.effect.each([ | ... | @@ -735,6 +950,8 @@ it.effect.each([ |
| 735 | }), | 950 | }), |
| 736 | }), | 951 | }), |
| 737 | ), | 952 | ), |
| 953 | Layer.provide(ServerSettings.layerTest()), | ||
| 954 | Layer.provide(layerReceiptsTest), | ||
| 738 | ); | 955 | ); |
| 739 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( | 956 | const service = yield* ThreadManagementService.ThreadManagementService.pipe( |
| 740 | Effect.provide(layerTest), | 957 | Effect.provide(layerTest), |
apps/server/src/orchestration-v2/ThreadManagementService.ts+148-9| ... | @@ -26,6 +26,8 @@ import { | ... | @@ -26,6 +26,8 @@ import { |
| 26 | type OrchestrationV2ThreadShell, | 26 | type OrchestrationV2ThreadShell, |
| 27 | type OrchestrationV2TurnItem, | 27 | type OrchestrationV2TurnItem, |
| 28 | ProjectId, | 28 | ProjectId, |
| 29 | ProviderInstanceId, | ||
| 30 | resolveProjectProviderInstanceEnabled, | ||
| 29 | RunId, | 31 | RunId, |
| 30 | type ScheduledTaskId, | 32 | type ScheduledTaskId, |
| 31 | ThreadId, | 33 | ThreadId, |
| ... | @@ -41,12 +43,14 @@ import * as Ref from "effect/Ref"; | ... | @@ -41,12 +43,14 @@ import * as Ref from "effect/Ref"; |
| 41 | import * as Schema from "effect/Schema"; | 43 | import * as Schema from "effect/Schema"; |
| 42 | import * as Stream from "effect/Stream"; | 44 | import * as Stream from "effect/Stream"; |
| 43 | 45 | ||
| 46 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | ||
| 44 | import * as Orchestrator from "./Orchestrator.ts"; | 47 | import * as Orchestrator from "./Orchestrator.ts"; |
| 45 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | 48 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| 46 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | 49 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; |
| 47 | import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; | 50 | import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; |
| 48 | import { projectTurnItemForDetail } from "./WireProjection.ts"; | 51 | import { projectTurnItemForDetail } from "./WireProjection.ts"; |
| 49 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; | 52 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; |
| 53 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 50 | 54 | ||
| 51 | export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; | 55 | export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; |
| 52 | 56 | ||
| ... | @@ -266,6 +270,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro | ... | @@ -266,6 +270,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro |
| 266 | } | 270 | } |
| 267 | } | 271 | } |
| 268 | 272 | ||
| 273 | export class ThreadManagementProviderInstanceDisabledError extends Schema.TaggedError<ThreadManagementProviderInstanceDisabledError>()( | ||
| 274 | "ThreadManagementProviderInstanceDisabledError", | ||
| 275 | { | ||
| 276 | projectId: ProjectId, | ||
| 277 | instanceId: ProviderInstanceId, | ||
| 278 | }, | ||
| 279 | ) { | ||
| 280 | override get message(): string { | ||
| 281 | return `Provider instance "${this.instanceId}" is disabled for project ${this.projectId}.`; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 269 | export const ThreadManagementError = Schema.Union([ | 285 | export const ThreadManagementError = Schema.Union([ |
| 270 | ThreadManagementThreadNotFoundError, | 286 | ThreadManagementThreadNotFoundError, |
| 271 | ThreadManagementRunNotFoundError, | 287 | ThreadManagementRunNotFoundError, |
| ... | @@ -275,6 +291,7 @@ export const ThreadManagementError = Schema.Union([ | ... | @@ -275,6 +291,7 @@ export const ThreadManagementError = Schema.Union([ |
| 275 | ThreadManagementProjectionLoadError, | 291 | ThreadManagementProjectionLoadError, |
| 276 | ThreadManagementProjectThreadsListError, | 292 | ThreadManagementProjectThreadsListError, |
| 277 | ThreadManagementDurableRunProjectionError, | 293 | ThreadManagementDurableRunProjectionError, |
| 294 | ThreadManagementProviderInstanceDisabledError, | ||
| 278 | ]); | 295 | ]); |
| 279 | export type ThreadManagementError = typeof ThreadManagementError.Type; | 296 | export type ThreadManagementError = typeof ThreadManagementError.Type; |
| 280 | 297 | ||
| ... | @@ -290,9 +307,18 @@ export interface ThreadManagementServiceShape { | ... | @@ -290,9 +307,18 @@ export interface ThreadManagementServiceShape { |
| 290 | readonly ensureLegacyTranscript: ( | 307 | readonly ensureLegacyTranscript: ( |
| 291 | threadId: ThreadId, | 308 | threadId: ThreadId, |
| 292 | ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>; | 309 | ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>; |
| 310 | /** | ||
| 311 | * The single chokepoint for commands that can start or wake provider work: | ||
| 312 | * `message.dispatch` and `delegated_task.request` are refused here when the | ||
| 313 | * resolved instance is off for the project, except a command id that | ||
| 314 | * already has a receipt, which only replays its recorded result. | ||
| 315 | */ | ||
| 293 | readonly dispatch: ( | 316 | readonly dispatch: ( |
| 294 | command: OrchestrationV2ServerCommand, | 317 | command: OrchestrationV2ServerCommand, |
| 295 | ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>; | 318 | ) => Effect.Effect< |
| 319 | Orchestrator.OrchestratorV2DispatchResult, | ||
| 320 | Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError | ||
| 321 | >; | ||
| 296 | readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"]; | 322 | readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"]; |
| 297 | readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; | 323 | readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; |
| 298 | readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; | 324 | readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; |
| ... | @@ -340,6 +366,32 @@ export interface ThreadManagementServiceShape { | ... | @@ -340,6 +366,32 @@ export interface ThreadManagementServiceShape { |
| 340 | readonly sendToThread: ( | 366 | readonly sendToThread: ( |
| 341 | input: ThreadManagementSendInput, | 367 | input: ThreadManagementSendInput, |
| 342 | ) => Effect.Effect<ThreadManagementSendResult, ThreadManagementFailure>; | 368 | ) => Effect.Effect<ThreadManagementSendResult, ThreadManagementFailure>; |
| 369 | /** | ||
| 370 | * Refuses a new turn on an instance the project has effectively disabled. | ||
| 371 | * A command that already has a receipt was let through before, so its | ||
| 372 | * retry replays instead of being refused under newer settings. | ||
| 373 | */ | ||
| 374 | readonly assertProviderInstanceEnabled: (input: { | ||
| 375 | readonly projectId: ProjectId; | ||
| 376 | readonly commandId: CommandId; | ||
| 377 | readonly instanceId: ProviderInstanceId; | ||
| 378 | }) => Effect.Effect<void, ThreadManagementProviderInstanceDisabledError>; | ||
| 379 | /** | ||
| 380 | * Effective enablement for candidate selection, without the receipt-replay | ||
| 381 | * exemption `assertProviderInstanceEnabled` grants an accepted command. | ||
| 382 | */ | ||
| 383 | readonly isProviderInstanceEnabledForProject: (input: { | ||
| 384 | readonly projectId: ProjectId; | ||
| 385 | readonly instanceId: ProviderInstanceId; | ||
| 386 | }) => Effect.Effect<boolean>; | ||
| 387 | /** | ||
| 388 | * Whether `commandId` already has a receipt. A caller resolving a target | ||
| 389 | * ahead of a dispatch under this same command id can use it to skip | ||
| 390 | * project-enablement filtering: the dispatch will only replay the recorded | ||
| 391 | * result, never start anything new, so the filter would just refuse a | ||
| 392 | * retry the project's settings no longer allow fresh. | ||
| 393 | */ | ||
| 394 | readonly hasReceipt: (commandId: CommandId) => Effect.Effect<boolean>; | ||
| 343 | readonly waitForThread: ( | 395 | readonly waitForThread: ( |
| 344 | input: ThreadManagementWaitInput, | 396 | input: ThreadManagementWaitInput, |
| 345 | ) => Effect.Effect<ThreadManagementWaitResult, ThreadManagementError>; | 397 | ) => Effect.Effect<ThreadManagementWaitResult, ThreadManagementError>; |
| ... | @@ -365,7 +417,9 @@ export interface ThreadManagementServiceShape { | ... | @@ -365,7 +417,9 @@ export interface ThreadManagementServiceShape { |
| 365 | readonly byOwnAgent: boolean; | 417 | readonly byOwnAgent: boolean; |
| 366 | }) => Effect.Effect< | 418 | }) => Effect.Effect< |
| 367 | { readonly sequence: number } | { readonly settlesWhenTurnEnds: true }, | 419 | { readonly sequence: number } | { readonly settlesWhenTurnEnds: true }, |
| 368 | Orchestrator.OrchestratorV2Error | 420 | // Dispatches thread.settle, a command the enablement chokepoint never |
| 421 | // gates; the type stays accurate anyway. | ||
| 422 | Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError | ||
| 369 | >; | 423 | >; |
| 370 | readonly interruptThread: ( | 424 | readonly interruptThread: ( |
| 371 | input: ThreadManagementInterruptInput, | 425 | input: ThreadManagementInterruptInput, |
| ... | @@ -386,7 +440,12 @@ export interface ThreadManagementServiceShape { | ... | @@ -386,7 +440,12 @@ export interface ThreadManagementServiceShape { |
| 386 | readonly threadId: ThreadId; | 440 | readonly threadId: ThreadId; |
| 387 | readonly commandId: CommandId; | 441 | readonly commandId: CommandId; |
| 388 | readonly reason?: string | undefined; | 442 | readonly reason?: string | undefined; |
| 389 | }) => Effect.Effect<void, Orchestrator.OrchestratorV2Error>; | 443 | }) => Effect.Effect< |
| 444 | void, | ||
| 445 | // Dispatches thread.stop, a command the enablement chokepoint never | ||
| 446 | // gates; the type stays accurate anyway. | ||
| 447 | Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError | ||
| 448 | >; | ||
| 390 | readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; | 449 | readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; |
| 391 | readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; | 450 | readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; |
| 392 | readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; | 451 | readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; |
| ... | @@ -458,6 +517,44 @@ const make = Effect.gen(function* () { | ... | @@ -458,6 +517,44 @@ const make = Effect.gen(function* () { |
| 458 | const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | 517 | const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 459 | const layerScope = yield* Effect.scope; | 518 | const layerScope = yield* Effect.scope; |
| 460 | const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; | 519 | const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; |
| 520 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 521 | const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; | ||
| 522 | |||
| 523 | const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] = | ||
| 524 | (input) => | ||
| 525 | receipts.getByCommandId(input.commandId).pipe( | ||
| 526 | Effect.orElseSucceed(() => Option.none()), | ||
| 527 | Effect.flatMap((receipt) => | ||
| 528 | Option.isSome(receipt) | ||
| 529 | ? Effect.void | ||
| 530 | : serverSettings.getSettings.pipe( | ||
| 531 | Effect.orDie, | ||
| 532 | Effect.flatMap((current) => | ||
| 533 | resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId) | ||
| 534 | ? Effect.void | ||
| 535 | : new ThreadManagementProviderInstanceDisabledError({ | ||
| 536 | projectId: input.projectId, | ||
| 537 | instanceId: input.instanceId, | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ), | ||
| 541 | ), | ||
| 542 | ); | ||
| 543 | |||
| 544 | const isProviderInstanceEnabledForProject: ThreadManagementServiceShape["isProviderInstanceEnabledForProject"] = | ||
| 545 | (input) => | ||
| 546 | serverSettings.getSettings.pipe( | ||
| 547 | Effect.orDie, | ||
| 548 | Effect.map((current) => | ||
| 549 | resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId), | ||
| 550 | ), | ||
| 551 | ); | ||
| 552 | |||
| 553 | const hasReceipt: ThreadManagementServiceShape["hasReceipt"] = (commandId) => | ||
| 554 | receipts.getByCommandId(commandId).pipe( | ||
| 555 | Effect.orElseSucceed(() => Option.none()), | ||
| 556 | Effect.map(Option.isSome), | ||
| 557 | ); | ||
| 461 | 558 | ||
| 462 | const ensureLegacyTranscript = Effect.fn( | 559 | const ensureLegacyTranscript = Effect.fn( |
| 463 | "orchestrationV2.threadManagement.ensureLegacyTranscript", | 560 | "orchestrationV2.threadManagement.ensureLegacyTranscript", |
| ... | @@ -524,8 +621,41 @@ const make = Effect.gen(function* () { | ... | @@ -524,8 +621,41 @@ const make = Effect.gen(function* () { |
| 524 | Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), | 621 | Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), |
| 525 | ); | 622 | ); |
| 526 | 623 | ||
| 624 | // Every path that can start or wake a provider turn funnels through this | ||
| 625 | // dispatch, so resolving and checking the target instance here (instead of | ||
| 626 | // at each caller) is the one place "off" is enforced. | ||
| 627 | const assertDispatchTargetEnabled = (command: OrchestrationV2ServerCommand) => { | ||
| 628 | switch (command.type) { | ||
| 629 | case "message.dispatch": | ||
| 630 | return orchestrator.getThreadRecords(command.threadId, []).pipe( | ||
| 631 | Effect.flatMap((projection) => | ||
| 632 | assertProviderInstanceEnabled({ | ||
| 633 | projectId: projection.thread.projectId, | ||
| 634 | commandId: command.commandId, | ||
| 635 | instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId, | ||
| 636 | }), | ||
| 637 | ), | ||
| 638 | ); | ||
| 639 | case "delegated_task.request": | ||
| 640 | return orchestrator.getThreadRecords(command.parentThreadId, []).pipe( | ||
| 641 | Effect.flatMap((parent) => | ||
| 642 | assertProviderInstanceEnabled({ | ||
| 643 | projectId: parent.thread.projectId, | ||
| 644 | commandId: command.commandId, | ||
| 645 | instanceId: command.modelSelection.instanceId, | ||
| 646 | }), | ||
| 647 | ), | ||
| 648 | ); | ||
| 649 | default: | ||
| 650 | return Effect.void; | ||
| 651 | } | ||
| 652 | }; | ||
| 653 | |||
| 527 | const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => | 654 | const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => |
| 528 | ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command))); | 655 | ensureCommandTranscripts(command).pipe( |
| 656 | Effect.andThen(assertDispatchTargetEnabled(command)), | ||
| 657 | Effect.andThen(orchestrator.dispatch(command)), | ||
| 658 | ); | ||
| 529 | 659 | ||
| 530 | const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => | 660 | const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => |
| 531 | getThreadProjection(input.threadId).pipe( | 661 | getThreadProjection(input.threadId).pipe( |
| ... | @@ -608,7 +738,6 @@ const make = Effect.gen(function* () { | ... | @@ -608,7 +738,6 @@ const make = Effect.gen(function* () { |
| 608 | threadId: input.threadId, | 738 | threadId: input.threadId, |
| 609 | }); | 739 | }); |
| 610 | } | 740 | } |
| 611 | |||
| 612 | const steerableRun = latestSteerableRun(target); | 741 | const steerableRun = latestSteerableRun(target); |
| 613 | let dispatchMode: Extract< | 742 | let dispatchMode: Extract< |
| 614 | OrchestrationV2Command, | 743 | OrchestrationV2Command, |
| ... | @@ -633,7 +762,7 @@ const make = Effect.gen(function* () { | ... | @@ -633,7 +762,7 @@ const make = Effect.gen(function* () { |
| 633 | }; | 762 | }; |
| 634 | } | 763 | } |
| 635 | 764 | ||
| 636 | const dispatch = yield* orchestrator.dispatch({ | 765 | const dispatched = yield* dispatch({ |
| 637 | type: "message.dispatch", | 766 | type: "message.dispatch", |
| 638 | commandId: input.commandId, | 767 | commandId: input.commandId, |
| 639 | threadId: input.threadId, | 768 | threadId: input.threadId, |
| ... | @@ -685,7 +814,7 @@ const make = Effect.gen(function* () { | ... | @@ -685,7 +814,7 @@ const make = Effect.gen(function* () { |
| 685 | : input.mode === "restart" | 814 | : input.mode === "restart" |
| 686 | ? "restarted" | 815 | ? "restarted" |
| 687 | : "steered"; | 816 | : "steered"; |
| 688 | return { dispatch, projection, message, run, turnItem, delivery }; | 817 | return { dispatch: dispatched, projection, message, run, turnItem, delivery }; |
| 689 | }); | 818 | }); |
| 690 | 819 | ||
| 691 | const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) => | 820 | const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) => |
| ... | @@ -943,7 +1072,9 @@ const make = Effect.gen(function* () { | ... | @@ -943,7 +1072,9 @@ const make = Effect.gen(function* () { |
| 943 | const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => | 1072 | const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => |
| 944 | Effect.gen(function* () { | 1073 | Effect.gen(function* () { |
| 945 | const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); | 1074 | const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); |
| 946 | const failures: Array<Orchestrator.OrchestratorV2Error> = []; | 1075 | const failures: Array< |
| 1076 | Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError | ||
| 1077 | > = []; | ||
| 947 | for (const task of subagents) { | 1078 | for (const task of subagents) { |
| 948 | if (task.origin !== "app_owned" || task.childThreadId === null) continue; | 1079 | if (task.origin !== "app_owned" || task.childThreadId === null) continue; |
| 949 | const threadId = task.childThreadId; | 1080 | const threadId = task.childThreadId; |
| ... | @@ -1013,6 +1144,9 @@ const make = Effect.gen(function* () { | ... | @@ -1013,6 +1144,9 @@ const make = Effect.gen(function* () { |
| 1013 | getThreadShell: orchestrator.getThreadShell, | 1144 | getThreadShell: orchestrator.getThreadShell, |
| 1014 | listProjectThreads, | 1145 | listProjectThreads, |
| 1015 | sendToThread, | 1146 | sendToThread, |
| 1147 | assertProviderInstanceEnabled, | ||
| 1148 | isProviderInstanceEnabledForProject, | ||
| 1149 | hasReceipt, | ||
| 1016 | waitForThread, | 1150 | waitForThread, |
| 1017 | settleAfterRun, | 1151 | settleAfterRun, |
| 1018 | settleThread, | 1152 | settleThread, |
| ... | @@ -1041,7 +1175,10 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( | ... | @@ -1041,7 +1175,10 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( |
| 1041 | export const layer: Layer.Layer< | 1175 | export const layer: Layer.Layer< |
| 1042 | ThreadManagementService, | 1176 | ThreadManagementService, |
| 1043 | never, | 1177 | never, |
| 1044 | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 | 1178 | | Orchestrator.OrchestratorV2 |
| 1179 | | ProviderSessionManager.ProviderSessionManagerV2 | ||
| 1180 | | ServerSettings.ServerSettingsService | ||
| 1181 | | CommandReceiptStore.CommandReceiptStoreV2 | ||
| 1045 | > = Layer.effect(ThreadManagementService, make).pipe( | 1182 | > = Layer.effect(ThreadManagementService, make).pipe( |
| 1046 | Layer.provide(layerLegacyV1ThreadImporterNoop), | 1183 | Layer.provide(layerLegacyV1ThreadImporterNoop), |
| 1047 | Layer.provide(ThreadCommandExecutor.layer), | 1184 | Layer.provide(ThreadCommandExecutor.layer), |
| ... | @@ -1053,4 +1190,6 @@ export const layerWithLegacyImporter: Layer.Layer< | ... | @@ -1053,4 +1190,6 @@ export const layerWithLegacyImporter: Layer.Layer< |
| 1053 | | LegacyV1ThreadImporter.LegacyV1ThreadImporter | 1190 | | LegacyV1ThreadImporter.LegacyV1ThreadImporter |
| 1054 | | Orchestrator.OrchestratorV2 | 1191 | | Orchestrator.OrchestratorV2 |
| 1055 | | ProviderSessionManager.ProviderSessionManagerV2 | 1192 | | ProviderSessionManager.ProviderSessionManagerV2 |
| 1193 | | ServerSettings.ServerSettingsService | ||
| 1194 | | CommandReceiptStore.CommandReceiptStoreV2 | ||
| 1056 | > = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); | 1195 | > = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); |
apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts+56-2| ... | @@ -7,6 +7,9 @@ import { | ... | @@ -7,6 +7,9 @@ import { |
| 7 | ChatAttachmentId, | 7 | ChatAttachmentId, |
| 8 | CommandId, | 8 | CommandId, |
| 9 | EventId, | 9 | EventId, |
| 10 | MessageId, | ||
| 11 | ProjectId, | ||
| 12 | ProviderInstanceId, | ||
| 10 | RuntimeRequestId, | 13 | RuntimeRequestId, |
| 11 | ThreadId, | 14 | ThreadId, |
| 12 | TurnItemId, | 15 | TurnItemId, |
| ... | @@ -20,16 +23,17 @@ import * as Effect from "effect/Effect"; | ... | @@ -20,16 +23,17 @@ import * as Effect from "effect/Effect"; |
| 20 | import * as Layer from "effect/Layer"; | 23 | import * as Layer from "effect/Layer"; |
| 21 | import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; | 24 | import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; |
| 22 | import * as ServerConfig from "../config.ts"; | 25 | import * as ServerConfig from "../config.ts"; |
| 26 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 23 | import { | 27 | import { |
| 24 | OrchestratorCommandPreviouslyRejectedError, | 28 | OrchestratorCommandPreviouslyRejectedError, |
| 25 | OrchestratorDispatchError, | 29 | OrchestratorDispatchError, |
| 26 | } from "./Orchestrator.ts"; | 30 | } from "./Orchestrator.ts"; |
| 27 | import * as ThreadManagementService from "./ThreadManagementService.ts"; | 31 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 28 | import { dispatchCommand } from "./ThreadMessageIntake.ts"; | 32 | import { dispatchCommand, sendToThread } from "./ThreadMessageIntake.ts"; |
| 29 | 33 | ||
| 30 | const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { | 34 | const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { |
| 31 | prefix: "t3-question-intake-", | 35 | prefix: "t3-question-intake-", |
| 32 | }).pipe(Layer.provideMerge(NodeServices.layer)); | 36 | }).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest())); |
| 33 | 37 | ||
| 34 | const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) => | 38 | const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) => |
| 35 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 39 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| ... | @@ -764,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () => | ... | @@ -764,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () => |
| 764 | expect(captured).toEqual([]); | 768 | expect(captured).toEqual([]); |
| 765 | }).pipe(Effect.provide(layerIntakeTest)), | 769 | }).pipe(Effect.provide(layerIntakeTest)), |
| 766 | ); | 770 | ); |
| 771 | |||
| 772 | it.effect("releases claimed copies when a project disables the message's provider instance", () => | ||
| 773 | Effect.gen(function* () { | ||
| 774 | const config = yield* ServerConfig.ServerConfig; | ||
| 775 | const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!); | ||
| 776 | NodeFS.writeFileSync( | ||
| 777 | NodePath.join(config.attachmentsDir, `${pendingId}.png`), | ||
| 778 | new Uint8Array([1, 2, 3]), | ||
| 779 | ); | ||
| 780 | const result = yield* sendToThread({ | ||
| 781 | projectId: ProjectId.make("project-disabled-send"), | ||
| 782 | commandId: CommandId.make("command-disabled-send"), | ||
| 783 | threadId: ThreadId.make("thread-disabled-send"), | ||
| 784 | messageId: MessageId.make("message-disabled-send"), | ||
| 785 | text: "Hello", | ||
| 786 | attachments: [ | ||
| 787 | { | ||
| 788 | type: "image", | ||
| 789 | id: pendingId, | ||
| 790 | name: "screen.png", | ||
| 791 | mimeType: "image/png", | ||
| 792 | sizeBytes: 3, | ||
| 793 | }, | ||
| 794 | ], | ||
| 795 | mode: "auto", | ||
| 796 | createdBy: "user", | ||
| 797 | creationSource: "web", | ||
| 798 | }).pipe( | ||
| 799 | Effect.provide( | ||
| 800 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 801 | sendToThread: (input) => | ||
| 802 | Effect.fail( | ||
| 803 | new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ | ||
| 804 | projectId: input.projectId, | ||
| 805 | instanceId: ProviderInstanceId.make("codex"), | ||
| 806 | }), | ||
| 807 | ), | ||
| 808 | }), | ||
| 809 | ), | ||
| 810 | Effect.result, | ||
| 811 | ); | ||
| 812 | expect(result._tag).toBe("Failure"); | ||
| 813 | expect( | ||
| 814 | NodeFS.readdirSync(config.attachmentsDir).filter((entry) => | ||
| 815 | entry.startsWith("thread-disabled-send-"), | ||
| 816 | ), | ||
| 817 | ).toEqual([]); | ||
| 818 | expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true); | ||
| 819 | }).pipe(Effect.provide(layerIntakeTest)), | ||
| 820 | ); |
apps/server/src/orchestration-v2/ThreadMessageIntake.ts+11-1| ... | @@ -13,6 +13,8 @@ import * as ThreadManagement from "./ThreadManagementService.ts"; | ... | @@ -13,6 +13,8 @@ import * as ThreadManagement from "./ThreadManagementService.ts"; |
| 13 | 13 | ||
| 14 | // These dispatcher failures occur in receipt validation or planning, before | 14 | // These dispatcher failures occur in receipt validation or planning, before |
| 15 | // commitCommand. Generic dispatch errors can follow a commit and remain uncertain. | 15 | // commitCommand. Generic dispatch errors can follow a commit and remain uncertain. |
| 16 | // A disabled-instance refusal comes from ThreadManagementService.dispatch's | ||
| 17 | // own enablement check, which also runs before any commit is attempted. | ||
| 16 | function dispatchWasNotAccepted( | 18 | function dispatchWasNotAccepted( |
| 17 | error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError, | 19 | error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError, |
| 18 | ) { | 20 | ) { |
| ... | @@ -24,12 +26,14 @@ function dispatchWasNotAccepted( | ... | @@ -24,12 +26,14 @@ function dispatchWasNotAccepted( |
| 24 | case "OrchestratorCommandIdConflictError": | 26 | case "OrchestratorCommandIdConflictError": |
| 25 | case "OrchestratorSubagentThreadReadOnlyError": | 27 | case "OrchestratorSubagentThreadReadOnlyError": |
| 26 | case "OrchestratorThreadAboveModeLimitError": | 28 | case "OrchestratorThreadAboveModeLimitError": |
| 29 | case "ThreadManagementProviderInstanceDisabledError": | ||
| 27 | return true; | 30 | return true; |
| 28 | default: | 31 | default: |
| 29 | return false; | 32 | return false; |
| 30 | } | 33 | } |
| 31 | } | 34 | } |
| 32 | const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error); | 35 | const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error); |
| 36 | const isThreadManagementError = Schema.is(ThreadManagement.ThreadManagementError); | ||
| 33 | 37 | ||
| 34 | const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* ( | 38 | const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* ( |
| 35 | claimedPaths: ReadonlyArray<string>, | 39 | claimedPaths: ReadonlyArray<string>, |
| ... | @@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi | ... | @@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi |
| 221 | ), | 225 | ), |
| 222 | ), | 226 | ), |
| 223 | Effect.tapError((error) => { | 227 | Effect.tapError((error) => { |
| 228 | if (error._tag === "ThreadManagementProviderInstanceDisabledError") { | ||
| 229 | return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths); | ||
| 230 | } | ||
| 224 | // Project/receipt reads precede message dispatch. The create-thread error | 231 | // Project/receipt reads precede message dispatch. The create-thread error |
| 225 | // also wraps post-message projection reads, so its tag alone is not proof. | 232 | // also wraps post-message projection reads, so its tag alone is not proof. |
| 233 | // The initial message's own dispatch can still find the instance newly | ||
| 234 | // disabled, which wraps the same tag handled above instead of surfacing | ||
| 235 | // it directly, so the guard below also recognizes it. | ||
| 226 | const notAccepted = | 236 | const notAccepted = |
| 227 | error.operation === "resolve-project" || | 237 | error.operation === "resolve-project" || |
| 228 | error.operation === "read-receipt" || | 238 | error.operation === "read-receipt" || |
| 229 | ((error.operation === "create-thread" || error.operation === "dispatch-message") && | 239 | ((error.operation === "create-thread" || error.operation === "dispatch-message") && |
| 230 | isOrchestratorError(error.cause) && | 240 | (isOrchestratorError(error.cause) || isThreadManagementError(error.cause)) && |
| 231 | // Projection errors under create-thread can occur after the message commit. | 241 | // Projection errors under create-thread can occur after the message commit. |
| 232 | (error.operation !== "create-thread" || | 242 | (error.operation !== "create-thread" || |
| 233 | error.cause._tag !== "OrchestratorProjectionError") && | 243 | error.cause._tag !== "OrchestratorProjectionError") && |
apps/server/src/orchestration-v2/ThreadStop.test.ts+4| ... | @@ -21,8 +21,10 @@ import * as SqlClient from "effect/sql/SqlClient"; | ... | @@ -21,8 +21,10 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 21 | import * as TestClock from "effect/testing/TestClock"; | 21 | import * as TestClock from "effect/testing/TestClock"; |
| 22 | 22 | ||
| 23 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 23 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 24 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 24 | import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; | 25 | import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; |
| 25 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 26 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 27 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | ||
| 26 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; | 28 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; |
| 27 | import * as Orchestrator from "./Orchestrator.ts"; | 29 | import * as Orchestrator from "./Orchestrator.ts"; |
| 28 | import * as ProjectionStore from "./ProjectionStore.ts"; | 30 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| ... | @@ -54,6 +56,8 @@ const layerTest = ThreadManagementService.layer.pipe( | ... | @@ -54,6 +56,8 @@ const layerTest = ThreadManagementService.layer.pipe( |
| 54 | ), | 56 | ), |
| 55 | ), | 57 | ), |
| 56 | ), | 58 | ), |
| 59 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 60 | Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), | ||
| 57 | ); | 61 | ); |
| 58 | 62 | ||
| 59 | const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2)); | 63 | const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2)); |
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+8-2| ... | @@ -21,6 +21,7 @@ import * as ProjectStore from "./ProjectStore.ts"; | ... | @@ -21,6 +21,7 @@ import * as ProjectStore from "./ProjectStore.ts"; |
| 21 | import * as ServerSettings from "../serverSettings.ts"; | 21 | import * as ServerSettings from "../serverSettings.ts"; |
| 22 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 22 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 23 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 23 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 24 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | ||
| 24 | import * as EffectOutbox from "./EffectOutbox.ts"; | 25 | import * as EffectOutbox from "./EffectOutbox.ts"; |
| 25 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | 26 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 26 | import * as ThreadManagement from "./ThreadManagementService.ts"; | 27 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| ... | @@ -54,7 +55,12 @@ function makeHarness( | ... | @@ -54,7 +55,12 @@ function makeHarness( |
| 54 | layerRegistry, | 55 | layerRegistry, |
| 55 | { databaseLayer: layerDatabase, runEffectWorker: false }, | 56 | { databaseLayer: layerDatabase, runEffectWorker: false }, |
| 56 | ); | 57 | ); |
| 57 | const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator)); | 58 | const layerServerSettings = ServerSettings.layerTest({}); |
| 59 | const layerThreadManagement = ThreadManagement.layer.pipe( | ||
| 60 | Layer.provide(layerOrchestrator), | ||
| 61 | Layer.provide(layerServerSettings), | ||
| 62 | Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), | ||
| 63 | ); | ||
| 58 | const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); | 64 | const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase)); |
| 59 | const generateThreadTitle = vi.fn( | 65 | const generateThreadTitle = vi.fn( |
| 60 | options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), | 66 | options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), |
| ... | @@ -86,7 +92,7 @@ function makeHarness( | ... | @@ -86,7 +92,7 @@ function makeHarness( |
| 86 | layerThreadManagement, | 92 | layerThreadManagement, |
| 87 | layerProjectedProjects, | 93 | layerProjectedProjects, |
| 88 | Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }), | 94 | Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }), |
| 89 | ServerSettings.layerTest({}), | 95 | layerServerSettings, |
| 90 | ), | 96 | ), |
| 91 | ), | 97 | ), |
| 92 | ); | 98 | ); |
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts+6-1| ... | @@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service< | ... | @@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service< |
| 33 | | { readonly type: "regenerate" }; | 33 | | { readonly type: "regenerate" }; |
| 34 | }) => Effect.Effect< | 34 | }) => Effect.Effect< |
| 35 | void, | 35 | void, |
| 36 | OrchestratorV2Error | ProjectStore.ProjectStoreV2Error | ServerSettingsError | 36 | | OrchestratorV2Error |
| 37 | | ProjectStore.ProjectStoreV2Error | ||
| 38 | | ServerSettingsError | ||
| 39 | // `complete` dispatches thread.title.regeneration.complete, a command | ||
| 40 | // the enablement chokepoint never gates; the type stays accurate anyway. | ||
| 41 | | ThreadManagementService.ThreadManagementProviderInstanceDisabledError | ||
| 37 | >; | 42 | >; |
| 38 | } | 43 | } |
| 39 | >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} | 44 | >()("t3/orchestration-v2/ThreadTitleRegenerationService") {} |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+130| ... | @@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => { | ... | @@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => { |
| 5453 | }), | 5453 | }), |
| 5454 | ); | 5454 | ); |
| 5455 | }); | 5455 | }); |
| 5456 | |||
| 5457 | const queueResumeDisabledInstanceProjectId = ProjectId.make( | ||
| 5458 | "project:queue-resume-disabled-instance", | ||
| 5459 | ); | ||
| 5460 | |||
| 5461 | const layerQueueResumeDisabledInstanceTest = Layer.mergeAll( | ||
| 5462 | RuntimeLayer.layer, | ||
| 5463 | RuntimeLayer.layerEventSink, | ||
| 5464 | ProjectStore.layer, | ||
| 5465 | ProjectionStore.layer, | ||
| 5466 | EffectOutbox.layer, | ||
| 5467 | ThreadCommandExecutor.layer, | ||
| 5468 | ).pipe( | ||
| 5469 | Layer.provide(McpSessionRegistryTestkit.layer), | ||
| 5470 | Layer.provide(McpProviderSessions.layer), | ||
| 5471 | Layer.provide(SqlitePersistence.layerMemory), | ||
| 5472 | Layer.provide(layerCheckpointStoreTest), | ||
| 5473 | Layer.provide(layerServerConfig), | ||
| 5474 | Layer.provide( | ||
| 5475 | ServerSettings.layerTest({ | ||
| 5476 | providerInstances: { | ||
| 5477 | [alternateInstanceId]: { driver: "codex", enabled: true }, | ||
| 5478 | }, | ||
| 5479 | projectSettingsOverrides: { | ||
| 5480 | [queueResumeDisabledInstanceProjectId]: { | ||
| 5481 | providerInstances: { [alternateInstanceId]: { enabled: false } }, | ||
| 5482 | }, | ||
| 5483 | }, | ||
| 5484 | }), | ||
| 5485 | ), | ||
| 5486 | Layer.provide(layerTestProviderInstanceRegistry), | ||
| 5487 | Layer.provide(layerGitWorkflowTest), | ||
| 5488 | Layer.provide(layerProjectServiceTest), | ||
| 5489 | Layer.provide(layerPlatformTest), | ||
| 5490 | ); | ||
| 5491 | |||
| 5492 | it.layer(layerQueueResumeDisabledInstanceTest)( | ||
| 5493 | "queue.resume on a project-disabled instance", | ||
| 5494 | (it) => { | ||
| 5495 | it.effect( | ||
| 5496 | "holds a queued run instead of starting it on an instance the project turned off", | ||
| 5497 | () => | ||
| 5498 | Effect.gen(function* () { | ||
| 5499 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 5500 | const events = yield* EventSink.EventSinkV2; | ||
| 5501 | const threadId = ThreadId.make("queue-resume-disabled-instance-thread"); | ||
| 5502 | const queuedModelSelection = { | ||
| 5503 | instanceId: alternateInstanceId, | ||
| 5504 | model: "gpt-5.5", | ||
| 5505 | } satisfies ModelSelection; | ||
| 5506 | |||
| 5507 | yield* orchestrator.dispatch({ | ||
| 5508 | type: "thread.create", | ||
| 5509 | createdBy: "user", | ||
| 5510 | creationSource: "web", | ||
| 5511 | commandId: CommandId.make(`${threadId}:create`), | ||
| 5512 | threadId, | ||
| 5513 | projectId: queueResumeDisabledInstanceProjectId, | ||
| 5514 | title: "Queue resume disabled instance", | ||
| 5515 | modelSelection, | ||
| 5516 | runtimeMode: "full-access", | ||
| 5517 | interactionMode: "default", | ||
| 5518 | branch: null, | ||
| 5519 | worktreePath: null, | ||
| 5520 | }); | ||
| 5521 | yield* orchestrator.dispatch({ | ||
| 5522 | type: "message.dispatch", | ||
| 5523 | createdBy: "user", | ||
| 5524 | creationSource: "web", | ||
| 5525 | commandId: CommandId.make(`${threadId}:message:active`), | ||
| 5526 | threadId, | ||
| 5527 | messageId: MessageId.make(`${threadId}:message:active`), | ||
| 5528 | text: "Active", | ||
| 5529 | attachments: [], | ||
| 5530 | dispatchMode: { type: "defer_start" }, | ||
| 5531 | }); | ||
| 5532 | yield* orchestrator.dispatch({ | ||
| 5533 | type: "message.dispatch", | ||
| 5534 | createdBy: "user", | ||
| 5535 | creationSource: "web", | ||
| 5536 | commandId: CommandId.make(`${threadId}:message:queued`), | ||
| 5537 | threadId, | ||
| 5538 | messageId: MessageId.make(`${threadId}:message:queued`), | ||
| 5539 | text: "Queued", | ||
| 5540 | attachments: [], | ||
| 5541 | modelSelection: queuedModelSelection, | ||
| 5542 | dispatchMode: { type: "queue_after_active" }, | ||
| 5543 | }); | ||
| 5544 | |||
| 5545 | const before = yield* orchestrator.getThreadProjection(threadId); | ||
| 5546 | const activeRun = before.runs.find((run) => run.status !== "queued")!; | ||
| 5547 | const queuedRun = before.runs.find((run) => run.status === "queued")!; | ||
| 5548 | const now = yield* DateTime.now; | ||
| 5549 | // A raw event ends the active run without going through dispatch, | ||
| 5550 | // so nothing has yet tried to advance the queue onto the disabled | ||
| 5551 | // instance. | ||
| 5552 | yield* events.write({ | ||
| 5553 | events: [ | ||
| 5554 | { | ||
| 5555 | id: EventId.make(`${threadId}:active-completed`), | ||
| 5556 | type: "run.updated", | ||
| 5557 | threadId, | ||
| 5558 | runId: activeRun.id, | ||
| 5559 | occurredAt: now, | ||
| 5560 | payload: { ...activeRun, status: "completed", completedAt: now }, | ||
| 5561 | }, | ||
| 5562 | ], | ||
| 5563 | }); | ||
| 5564 | |||
| 5565 | yield* orchestrator.dispatch({ | ||
| 5566 | type: "queue.resume", | ||
| 5567 | commandId: CommandId.make(`${threadId}:resume`), | ||
| 5568 | threadId, | ||
| 5569 | }); | ||
| 5570 | |||
| 5571 | const resumed = yield* orchestrator.getThreadProjection(threadId); | ||
| 5572 | const resumedQueuedRun = resumed.runs.find((run) => run.id === queuedRun.id); | ||
| 5573 | assert.equal( | ||
| 5574 | resumedQueuedRun?.status, | ||
| 5575 | "queued", | ||
| 5576 | "a disabled instance must not start the queued run", | ||
| 5577 | ); | ||
| 5578 | assert.isTrue( | ||
| 5579 | resumedQueuedRun?.queueHeld, | ||
| 5580 | "a refused resume re-holds the queue instead of leaving it looking resumable", | ||
| 5581 | ); | ||
| 5582 | }), | ||
| 5583 | ); | ||
| 5584 | }, | ||
| 5585 | ); |
apps/server/src/orchestration-v2/runtimeLayer.ts+1| ... | @@ -244,6 +244,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp | ... | @@ -244,6 +244,7 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp |
| 244 | Layer.mergeAll( | 244 | Layer.mergeAll( |
| 245 | layerOrchestratorProvided, | 245 | layerOrchestratorProvided, |
| 246 | layerLegacyV1ThreadImporterProvided, | 246 | layerLegacyV1ThreadImporterProvided, |
| 247 | layerCommandReceiptStoreProvided, | ||
| 247 | layerProviderSessionManagerProvided, | 248 | layerProviderSessionManagerProvided, |
| 248 | ), | 249 | ), |
| 249 | ), | 250 | ), |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+1| ... | @@ -450,6 +450,7 @@ export function layerWithRegistry<Error>( | ... | @@ -450,6 +450,7 @@ export function layerWithRegistry<Error>( |
| 450 | layerProviderSessionManagerProvided, | 450 | layerProviderSessionManagerProvided, |
| 451 | layerProviderSwitchServiceProvided, | 451 | layerProviderSwitchServiceProvided, |
| 452 | layerRunExecutionServiceProvided, | 452 | layerRunExecutionServiceProvided, |
| 453 | layerServerSettings, | ||
| 453 | ThreadForkService.layer, | 454 | ThreadForkService.layer, |
| 454 | ), | 455 | ), |
| 455 | ), | 456 | ), |
apps/server/src/project/ManagedProjectFolders.test.ts+2| ... | @@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; | ... | @@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 23 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 23 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import * as ProcessRunner from "../processRunner.ts"; | 25 | import * as ProcessRunner from "../processRunner.ts"; |
| 26 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 26 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 27 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 27 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 28 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 28 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 29 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| ... | @@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => | ... | @@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) => |
| 87 | Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)), | 88 | Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)), |
| 88 | Layer.provideMerge(options?.git ?? layerRealGit), | 89 | Layer.provideMerge(options?.git ?? layerRealGit), |
| 89 | Layer.provideMerge(SqlitePersistence.layerMemory), | 90 | Layer.provideMerge(SqlitePersistence.layerMemory), |
| 91 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 90 | Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)), | 92 | Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)), |
| 91 | Layer.provideMerge(NodeServices.layer), | 93 | Layer.provideMerge(NodeServices.layer), |
| 92 | ); | 94 | ); |
apps/server/src/project/ProjectService.deletion.test.ts+30| ... | @@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ... | @@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 28 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; | 28 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; |
| 29 | import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; | 29 | import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; |
| 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 31 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 31 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 32 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 32 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; | 33 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; |
| 33 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | 34 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| ... | @@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll( | ... | @@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll( |
| 63 | ), | 64 | ), |
| 64 | ), | 65 | ), |
| 65 | ), | 66 | ), |
| 67 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 66 | ); | 68 | ); |
| 67 | const layerDatabase = SqlitePersistence.layerMemory.pipe( | 69 | const layerDatabase = SqlitePersistence.layerMemory.pipe( |
| 68 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })), | 70 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })), |
| ... | @@ -494,3 +496,31 @@ it.effect("deletes a project without force once its imported threads were delete | ... | @@ -494,3 +496,31 @@ it.effect("deletes a project without force once its imported threads were delete |
| 494 | }).pipe(Effect.provide(layerServices)); | 496 | }).pipe(Effect.provide(layerServices)); |
| 495 | }).pipe(Effect.provide(layerDatabase)), | 497 | }).pipe(Effect.provide(layerDatabase)), |
| 496 | ); | 498 | ); |
| 499 | |||
| 500 | it.effect( | ||
| 501 | "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive", | ||
| 502 | () => | ||
| 503 | Effect.gen(function* () { | ||
| 504 | const projectId = ProjectId.make("project:overrides-cleanup"); | ||
| 505 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 506 | yield* seedProject(projectId); | ||
| 507 | |||
| 508 | yield* Effect.gen(function* () { | ||
| 509 | const service = yield* ProjectService.make; | ||
| 510 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 511 | yield* serverSettings.updateSettings({ | ||
| 512 | projectSettingsOverrides: { | ||
| 513 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 514 | }, | ||
| 515 | }); | ||
| 516 | |||
| 517 | yield* service.delete({ | ||
| 518 | commandId: CommandId.make("command:overrides-cleanup:project-delete"), | ||
| 519 | projectId, | ||
| 520 | }); | ||
| 521 | |||
| 522 | const settingsAfterDelete = yield* serverSettings.getSettings; | ||
| 523 | assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]); | ||
| 524 | }).pipe(Effect.provide(layerServices)); | ||
| 525 | }).pipe(Effect.provide(layerDatabase)), | ||
| 526 | ); |
apps/server/src/project/ProjectService.test.ts+3| ... | @@ -19,6 +19,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ... | @@ -19,6 +19,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 19 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 19 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 20 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; | 20 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; |
| 21 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | 21 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 22 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 23 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 23 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; | 24 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; |
| 24 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | 25 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| ... | @@ -60,6 +61,7 @@ const layerTestFor = ( | ... | @@ -60,6 +61,7 @@ const layerTestFor = ( |
| 60 | Layer.provideMerge(layerWorkspacePaths), | 61 | Layer.provideMerge(layerWorkspacePaths), |
| 61 | Layer.provideMerge(projectMetadataLayer), | 62 | Layer.provideMerge(projectMetadataLayer), |
| 62 | Layer.provideMerge(SqlitePersistence.layerMemory), | 63 | Layer.provideMerge(SqlitePersistence.layerMemory), |
| 64 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 63 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), | 65 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), |
| 64 | Layer.provide(NodeServices.layer), | 66 | Layer.provide(NodeServices.layer), |
| 65 | ); | 67 | ); |
| ... | @@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( | ... | @@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( |
| 79 | Layer.provideMerge(layerWorkspacePaths), | 81 | Layer.provideMerge(layerWorkspacePaths), |
| 80 | Layer.provideMerge(layerMetadata), | 82 | Layer.provideMerge(layerMetadata), |
| 81 | Layer.provideMerge(SqlitePersistence.layerMemory), | 83 | Layer.provideMerge(SqlitePersistence.layerMemory), |
| 84 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 82 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), | 85 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), |
| 83 | Layer.provide(NodeServices.layer), | 86 | Layer.provide(NodeServices.layer), |
| 84 | ); | 87 | ); |
apps/server/src/project/ProjectService.ts+23-1| ... | @@ -30,6 +30,7 @@ import { | ... | @@ -30,6 +30,7 @@ import { |
| 30 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | 30 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 31 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; | 31 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; |
| 32 | import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; | 32 | import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; |
| 33 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 33 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; | 34 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; |
| 34 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 35 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 35 | 36 | ||
| ... | @@ -153,6 +154,7 @@ export const make = Effect.gen(function* () { | ... | @@ -153,6 +154,7 @@ export const make = Effect.gen(function* () { |
| 153 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 154 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 154 | const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; | 155 | const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; |
| 155 | const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; | 156 | const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; |
| 157 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 156 | // Commands for one project run in order. Commands that claim a workspace root | 158 | // Commands for one project run in order. Commands that claim a workspace root |
| 157 | // also hold that root, so two projects cannot both claim it. | 159 | // also hold that root, so two projects cannot both claim it. |
| 158 | const projectLocks = yield* KeyedLock.make<ProjectId>(); | 160 | const projectLocks = yield* KeyedLock.make<ProjectId>(); |
| ... | @@ -486,6 +488,22 @@ export const make = Effect.gen(function* () { | ... | @@ -486,6 +488,22 @@ export const make = Effect.gen(function* () { |
| 486 | ); | 488 | ); |
| 487 | }); | 489 | }); |
| 488 | 490 | ||
| 491 | // A machine-disabled provider instance that a deleted project's override | ||
| 492 | // once turned on must not stay probed and alive forever. Nothing else | ||
| 493 | // removes the entry, so deletion is where it goes; best-effort because a | ||
| 494 | // settings write failure should not block the deletion it is cleaning up | ||
| 495 | // after, and a server crash before a replay never revisits this project. | ||
| 496 | const clearProjectSettingsOverrides = (projectId: ProjectId) => | ||
| 497 | serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( | ||
| 498 | Effect.asVoid, | ||
| 499 | Effect.catchCause((cause) => | ||
| 500 | Effect.logWarning( | ||
| 501 | `Failed to clear settings overrides for deleted project ${projectId}.`, | ||
| 502 | cause, | ||
| 503 | ), | ||
| 504 | ), | ||
| 505 | ); | ||
| 506 | |||
| 489 | const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( | 507 | const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( |
| 490 | function* (input) { | 508 | function* (input) { |
| 491 | const { projectId } = input; | 509 | const { projectId } = input; |
| ... | @@ -496,10 +514,14 @@ export const make = Effect.gen(function* () { | ... | @@ -496,10 +514,14 @@ export const make = Effect.gen(function* () { |
| 496 | return yield* new ProjectNotFoundError({ projectId }); | 514 | return yield* new ProjectNotFoundError({ projectId }); |
| 497 | } | 515 | } |
| 498 | 516 | ||
| 499 | if (existing.value.deletedAt === null) { | 517 | const isFirstDelete = existing.value.deletedAt === null; |
| 518 | if (isFirstDelete) { | ||
| 500 | yield* deleteChildThreads(input); | 519 | yield* deleteChildThreads(input); |
| 501 | } | 520 | } |
| 502 | yield* commit({ type: "project.delete", commandId: input.commandId, projectId }); | 521 | yield* commit({ type: "project.delete", commandId: input.commandId, projectId }); |
| 522 | if (isFirstDelete) { | ||
| 523 | yield* clearProjectSettingsOverrides(projectId); | ||
| 524 | } | ||
| 503 | yield* projectEnrichment.invalidate([existing.value.workspaceRoot]); | 525 | yield* projectEnrichment.invalidate([existing.value.workspaceRoot]); |
| 504 | return yield* readCommitted(projectId); | 526 | return yield* readCommitted(projectId); |
| 505 | }, | 527 | }, |
apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { findOrphanedProjectIds } from "./ProjectSettingsOverrideSweep.ts"; | ||
| 5 | |||
| 6 | const liveProjectId = ProjectId.make("project-live"); | ||
| 7 | const deletedProjectId = ProjectId.make("project-deleted"); | ||
| 8 | |||
| 9 | function settingsWith(overrides: Partial<ServerSettings>): ServerSettings { | ||
| 10 | return { ...DEFAULT_SERVER_SETTINGS, ...overrides }; | ||
| 11 | } | ||
| 12 | |||
| 13 | describe("findOrphanedProjectIds", () => { | ||
| 14 | it("reports a project override whose project is no longer live", () => { | ||
| 15 | const settings = settingsWith({ | ||
| 16 | projectSettingsOverrides: { | ||
| 17 | [liveProjectId]: { providerInstances: {} }, | ||
| 18 | [deletedProjectId]: { providerInstances: {} }, | ||
| 19 | }, | ||
| 20 | }); | ||
| 21 | |||
| 22 | const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); | ||
| 23 | |||
| 24 | expect(orphaned).toEqual([deletedProjectId]); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("reports nothing when every override's project is still live", () => { | ||
| 28 | const settings = settingsWith({ | ||
| 29 | projectSettingsOverrides: { | ||
| 30 | [liveProjectId]: { providerInstances: {} }, | ||
| 31 | }, | ||
| 32 | }); | ||
| 33 | |||
| 34 | const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); | ||
| 35 | |||
| 36 | expect(orphaned).toEqual([]); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("reports nothing when there are no overrides at all", () => { | ||
| 40 | const settings = settingsWith({ projectSettingsOverrides: {} }); | ||
| 41 | |||
| 42 | const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); | ||
| 43 | |||
| 44 | expect(orphaned).toEqual([]); | ||
| 45 | }); | ||
| 46 | }); | ||
apps/server/src/provider/ProjectSettingsOverrideSweep.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | /** | ||
| 2 | * Removes `projectSettingsOverrides` entries | ||
| 3 | * whose project no longer exists. | ||
| 4 | * | ||
| 5 | * `ProjectService.delete` clears a project's entry as part of deleting the | ||
| 6 | * project, but that only covers deletions a server new enough to run that | ||
| 7 | * code actually processes. A project deleted by an older server, or one | ||
| 8 | * whose cleanup write failed, leaves its override behind: | ||
| 9 | * `ProviderInstanceRegistryHydration` folds every project's override in | ||
| 10 | * unconditionally, so a machine-disabled provider instance the override once | ||
| 11 | * turned on stays probed and alive forever, with no UI left to turn it off. | ||
| 12 | * | ||
| 13 | * This sweep runs once at boot and removes overrides left over from projects | ||
| 14 | * that no longer exist. The settings watcher's normal reconcile picks up | ||
| 15 | * whatever it clears, so the registry does not need its own live-project | ||
| 16 | * check on every settings change. | ||
| 17 | * | ||
| 18 | * @module provider/ProjectSettingsOverrideSweep | ||
| 19 | */ | ||
| 20 | import { type ProjectId, type ServerSettings } from "@t3tools/contracts"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | |||
| 23 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 24 | import * as Settings from "../serverSettings.ts"; | ||
| 25 | |||
| 26 | /** Pure so the orphan rule can be tested without layers. */ | ||
| 27 | export const findOrphanedProjectIds = ( | ||
| 28 | settings: Pick<ServerSettings, "projectSettingsOverrides">, | ||
| 29 | liveProjectIds: ReadonlySet<ProjectId>, | ||
| 30 | ): ReadonlyArray<ProjectId> => | ||
| 31 | Object.keys(settings.projectSettingsOverrides) | ||
| 32 | .map((projectId) => projectId as ProjectId) | ||
| 33 | .filter((projectId) => !liveProjectIds.has(projectId)); | ||
| 34 | |||
| 35 | /** | ||
| 36 | * Best-effort: a failed read or write here should not block server startup, | ||
| 37 | * and a sweep that does not run to completion is simply retried on the next | ||
| 38 | * boot. | ||
| 39 | */ | ||
| 40 | export const sweep = Effect.gen(function* () { | ||
| 41 | const serverSettings = yield* Settings.ServerSettingsService; | ||
| 42 | const projectStore = yield* ProjectStore.ProjectStoreV2; | ||
| 43 | |||
| 44 | const settings = yield* serverSettings.getSettings; | ||
| 45 | const liveShells = yield* projectStore.listShells(); | ||
| 46 | const liveProjectIds = new Set(liveShells.map((shell) => shell.id)); | ||
| 47 | |||
| 48 | const orphanedProjectIds = findOrphanedProjectIds(settings, liveProjectIds); | ||
| 49 | if (orphanedProjectIds.length === 0) return; | ||
| 50 | |||
| 51 | yield* serverSettings.updateSettings({ | ||
| 52 | projectSettingsOverrides: Object.fromEntries( | ||
| 53 | orphanedProjectIds.map((projectId) => [projectId, null]), | ||
| 54 | ), | ||
| 55 | }); | ||
| 56 | }).pipe( | ||
| 57 | Effect.asVoid, | ||
| 58 | Effect.catchCause((cause) => | ||
| 59 | Effect.logWarning("Failed to sweep orphaned project settings overrides.", cause), | ||
| 60 | ), | ||
| 61 | ); | ||
apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts created+122| ... | @@ -0,0 +1,122 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | ProjectId, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ServerSettings, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; | ||
| 11 | |||
| 12 | const projectId = ProjectId.make("project-a"); | ||
| 13 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 14 | |||
| 15 | function settingsWith(overrides: Partial<ServerSettings>): ServerSettings { | ||
| 16 | return { ...DEFAULT_SERVER_SETTINGS, ...overrides }; | ||
| 17 | } | ||
| 18 | |||
| 19 | describe("deriveProviderInstanceConfigMap", () => { | ||
| 20 | it("turns a machine-disabled instance on when a project enables it", () => { | ||
| 21 | const settings = settingsWith({ | ||
| 22 | providerInstances: { | ||
| 23 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 24 | }, | ||
| 25 | projectSettingsOverrides: { | ||
| 26 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 27 | }, | ||
| 28 | }); | ||
| 29 | |||
| 30 | const merged = deriveProviderInstanceConfigMap(settings); | ||
| 31 | |||
| 32 | expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("clears a nested config.enabled:false flag when a project enables the instance", () => { | ||
| 36 | const settings = settingsWith({ | ||
| 37 | providerInstances: { | ||
| 38 | [instanceId]: { | ||
| 39 | driver: ProviderDriverKind.make("codex"), | ||
| 40 | config: { enabled: false }, | ||
| 41 | }, | ||
| 42 | }, | ||
| 43 | projectSettingsOverrides: { | ||
| 44 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 45 | }, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const merged = deriveProviderInstanceConfigMap(settings); | ||
| 49 | |||
| 50 | expect(merged[instanceId]).toEqual({ | ||
| 51 | driver: ProviderDriverKind.make("codex"), | ||
| 52 | enabled: true, | ||
| 53 | config: { enabled: true }, | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("leaves an already machine-enabled instance untouched", () => { | ||
| 58 | const settings = settingsWith({ | ||
| 59 | providerInstances: { | ||
| 60 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 61 | }, | ||
| 62 | projectSettingsOverrides: { | ||
| 63 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 64 | }, | ||
| 65 | }); | ||
| 66 | |||
| 67 | const merged = deriveProviderInstanceConfigMap(settings); | ||
| 68 | |||
| 69 | expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("does not revive an instance a project override references but settings no longer configure", () => { | ||
| 73 | const settings = settingsWith({ | ||
| 74 | providerInstances: {}, | ||
| 75 | projectSettingsOverrides: { | ||
| 76 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 77 | }, | ||
| 78 | }); | ||
| 79 | |||
| 80 | const merged = deriveProviderInstanceConfigMap(settings); | ||
| 81 | |||
| 82 | expect(merged[instanceId]).toBeUndefined(); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("ignores a project override that disables an instance, leaving the machine's config in charge", () => { | ||
| 86 | const settings = settingsWith({ | ||
| 87 | providerInstances: { | ||
| 88 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 89 | }, | ||
| 90 | projectSettingsOverrides: { | ||
| 91 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 92 | }, | ||
| 93 | }); | ||
| 94 | |||
| 95 | const merged = deriveProviderInstanceConfigMap(settings); | ||
| 96 | |||
| 97 | expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("folds overrides from multiple projects", () => { | ||
| 101 | const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); | ||
| 102 | const otherProjectId = ProjectId.make("project-b"); | ||
| 103 | const settings = settingsWith({ | ||
| 104 | providerInstances: { | ||
| 105 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 106 | [otherInstanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false }, | ||
| 107 | }, | ||
| 108 | projectSettingsOverrides: { | ||
| 109 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 110 | [otherProjectId]: { providerInstances: { [otherInstanceId]: { enabled: true } } }, | ||
| 111 | }, | ||
| 112 | }); | ||
| 113 | |||
| 114 | const merged = deriveProviderInstanceConfigMap(settings); | ||
| 115 | |||
| 116 | expect(merged[instanceId]).toEqual({ driver: ProviderDriverKind.make("codex"), enabled: true }); | ||
| 117 | expect(merged[otherInstanceId]).toEqual({ | ||
| 118 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 119 | enabled: true, | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | }); | ||
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+26| ... | @@ -25,8 +25,11 @@ | ... | @@ -25,8 +25,11 @@ |
| 25 | */ | 25 | */ |
| 26 | import { | 26 | import { |
| 27 | defaultInstanceIdForDriver, | 27 | defaultInstanceIdForDriver, |
| 28 | providerInstanceConfigEnabledFlag, | ||
| 29 | resolveProviderInstanceEnabled, | ||
| 28 | type ProviderInstanceConfig, | 30 | type ProviderInstanceConfig, |
| 29 | type ProviderInstanceConfigMap, | 31 | type ProviderInstanceConfigMap, |
| 32 | type ProviderInstanceId, | ||
| 30 | ServerSettings, | 33 | ServerSettings, |
| 31 | } from "@t3tools/contracts"; | 34 | } from "@t3tools/contracts"; |
| 32 | import * as Effect from "effect/Effect"; | 35 | import * as Effect from "effect/Effect"; |
| ... | @@ -73,6 +76,29 @@ export const deriveProviderInstanceConfigMap = ( | ... | @@ -73,6 +76,29 @@ export const deriveProviderInstanceConfigMap = ( |
| 73 | merged[instanceId] = { driver: driver.driverKind }; | 76 | merged[instanceId] = { driver: driver.driverKind }; |
| 74 | } | 77 | } |
| 75 | 78 | ||
| 79 | // A project that turns an instance on needs it alive and probed even when | ||
| 80 | // the machine itself has it off, or a thread in that project would route | ||
| 81 | // to an instance nobody ever checked in on. Fold every project's | ||
| 82 | // `providerInstances[id].enabled` overrides in: an instance already enabled | ||
| 83 | // is untouched, and one absent from settings entirely (deleted) stays out. | ||
| 84 | for (const overrides of Object.values(settings.projectSettingsOverrides)) { | ||
| 85 | for (const [rawInstanceId, projectOverride] of Object.entries( | ||
| 86 | overrides?.providerInstances ?? {}, | ||
| 87 | )) { | ||
| 88 | if (projectOverride.enabled !== true) continue; | ||
| 89 | const instanceId = rawInstanceId as ProviderInstanceId; | ||
| 90 | const existing = merged[instanceId]; | ||
| 91 | if (existing === undefined || resolveProviderInstanceEnabled(existing)) continue; | ||
| 92 | merged[instanceId] = { | ||
| 93 | ...existing, | ||
| 94 | enabled: true, | ||
| 95 | ...(providerInstanceConfigEnabledFlag(existing.config) === false | ||
| 96 | ? { config: { ...(existing.config as Record<string, unknown>), enabled: true } } | ||
| 97 | : {}), | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 76 | return merged as ProviderInstanceConfigMap; | 102 | return merged as ProviderInstanceConfigMap; |
| 77 | }; | 103 | }; |
| 78 | 104 |
apps/server/src/pullRequest/PullRequestReviewGuideService.ts+1| ... | @@ -328,6 +328,7 @@ const make = Effect.gen(function* () { | ... | @@ -328,6 +328,7 @@ const make = Effect.gen(function* () { |
| 328 | const modelSelection = ServerSettings.resolveReviewGuideModelSelection( | 328 | const modelSelection = ServerSettings.resolveReviewGuideModelSelection( |
| 329 | projectSettings, | 329 | projectSettings, |
| 330 | providers, | 330 | providers, |
| 331 | input.projectId, | ||
| 331 | ); | 332 | ); |
| 332 | // The checkout lives only as long as the model is reading it. The change's hunks are read | 333 | // The checkout lives only as long as the model is reading it. The change's hunks are read |
| 333 | // from it too, so the guide can be held to the lines the change actually touched. | 334 | // from it too, so the guide can be held to the lines the change actually touched. |
apps/server/src/relay/AgentAwarenessRelay.test.ts+3| ... | @@ -208,6 +208,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( | ... | @@ -208,6 +208,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( |
| 208 | getProjectThread: unused, | 208 | getProjectThread: unused, |
| 209 | listProjectThreads: unused, | 209 | listProjectThreads: unused, |
| 210 | sendToThread: unused, | 210 | sendToThread: unused, |
| 211 | assertProviderInstanceEnabled: unused, | ||
| 212 | isProviderInstanceEnabledForProject: unused, | ||
| 213 | hasReceipt: unused, | ||
| 211 | waitForThread: unused, | 214 | waitForThread: unused, |
| 212 | settleAfterRun: unused, | 215 | settleAfterRun: unused, |
| 213 | settleThread: unused, | 216 | settleThread: unused, |
apps/server/src/server.ts+5| ... | @@ -58,6 +58,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; | ... | @@ -58,6 +58,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 58 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; | 58 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 59 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 59 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 60 | import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; | 60 | import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; |
| 61 | import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; | ||
| 61 | import * as TerminalManager from "./terminal/Manager.ts"; | 62 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 62 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; | 63 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; |
| 63 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; | 64 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; |
| ... | @@ -578,6 +579,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -578,6 +579,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 578 | ProviderUsageLimitsIngestion.layer, | 579 | ProviderUsageLimitsIngestion.layer, |
| 579 | layerProviderInstallationRefresh, | 580 | layerProviderInstallationRefresh, |
| 580 | ReplayMarkers.layer, | 581 | ReplayMarkers.layer, |
| 582 | // Catches overrides left behind by a project deleted before this cleanup | ||
| 583 | // existed, or by a cleanup write that failed. See ProjectService.delete | ||
| 584 | // for the normal (on-delete) path. | ||
| 585 | Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), | ||
| 581 | ).pipe( | 586 | ).pipe( |
| 582 | // Core Services | 587 | // Core Services |
| 583 | Layer.provideMerge(layerOrchestrationApplication), | 588 | Layer.provideMerge(layerOrchestrationApplication), |
apps/server/src/serverSettings.test.ts+2-2| ... | @@ -873,7 +873,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -873,7 +873,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 873 | 873 | ||
| 874 | assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection); | 874 | assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection); |
| 875 | assert.deepEqual( | 875 | assert.deepEqual( |
| 876 | ServerSettingsModule.resolveSourceControlWriterModelSelection(next), | 876 | ServerSettingsModule.resolveSourceControlWriterModelSelection(next, undefined, null), |
| 877 | next.textGenerationModelSelection, | 877 | next.textGenerationModelSelection, |
| 878 | ); | 878 | ); |
| 879 | assert.deepEqual( | 879 | assert.deepEqual( |
| ... | @@ -897,7 +897,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -897,7 +897,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 897 | }, | 897 | }, |
| 898 | }); | 898 | }); |
| 899 | assert.deepEqual( | 899 | assert.deepEqual( |
| 900 | ServerSettingsModule.resolveSourceControlWriterModelSelection(restored), | 900 | ServerSettingsModule.resolveSourceControlWriterModelSelection(restored, undefined, null), |
| 901 | sourceControlWriterModelSelection, | 901 | sourceControlWriterModelSelection, |
| 902 | ); | 902 | ); |
| 903 | }).pipe(Effect.provide(layerServerSettings())), | 903 | }).pipe(Effect.provide(layerServerSettings())), |
apps/server/src/serverSettings.ts+9-34| ... | @@ -11,9 +11,6 @@ | ... | @@ -11,9 +11,6 @@ |
| 11 | * @module ServerSettings | 11 | * @module ServerSettings |
| 12 | */ | 12 | */ |
| 13 | import { | 13 | import { |
| 14 | DEFAULT_TEXT_GENERATION_MODEL, | ||
| 15 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, | ||
| 16 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 17 | DEFAULT_SERVER_SETTINGS, | 14 | DEFAULT_SERVER_SETTINGS, |
| 18 | ModelSelection, | 15 | ModelSelection, |
| 19 | ProjectId, | 16 | ProjectId, |
| ... | @@ -61,6 +58,7 @@ import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJso | ... | @@ -61,6 +58,7 @@ import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJso |
| 61 | import { | 58 | import { |
| 62 | applyServerSettingsPatch, | 59 | applyServerSettingsPatch, |
| 63 | deriveLegacyProjectOverrides, | 60 | deriveLegacyProjectOverrides, |
| 61 | fallbackTextGenerationModelSelection, | ||
| 64 | isModelSelectionProviderEnabled, | 62 | isModelSelectionProviderEnabled, |
| 65 | } from "@t3tools/shared/serverSettings"; | 63 | } from "@t3tools/shared/serverSettings"; |
| 66 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 64 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| ... | @@ -560,17 +558,6 @@ function migrateLegacyProviderSettings( | ... | @@ -560,17 +558,6 @@ function migrateLegacyProviderSettings( |
| 560 | 558 | ||
| 561 | const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); | 559 | const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); |
| 562 | 560 | ||
| 563 | const TEXT_GENERATION_FALLBACK_DRIVERS = [ | ||
| 564 | "codex", | ||
| 565 | "claudeAgent", | ||
| 566 | "cursor", | ||
| 567 | "grok", | ||
| 568 | "muse", | ||
| 569 | "pi", | ||
| 570 | "opencode", | ||
| 571 | "antigravity", | ||
| 572 | ].map((driver) => ProviderDriverKind.make(driver)); | ||
| 573 | |||
| 574 | /** ACP Registry instances reject every application text-generation operation. */ | 561 | /** ACP Registry instances reject every application text-generation operation. */ |
| 575 | function selectionSupportsTextGeneration( | 562 | function selectionSupportsTextGeneration( |
| 576 | settings: ServerSettings, | 563 | settings: ServerSettings, |
| ... | @@ -587,27 +574,15 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings | ... | @@ -587,27 +574,15 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings |
| 587 | } | 574 | } |
| 588 | 575 | ||
| 589 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { | 576 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 590 | // The built-in default instances in preference order. A slot without an | 577 | // A slot without an explicit instance uses its driver's default enabled state. |
| 591 | // explicit instance uses its driver's default enabled state. | 578 | const fallback = fallbackTextGenerationModelSelection( |
| 592 | const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) => | 579 | settings.providerInstances, |
| 593 | resolveProviderInstanceEnabled( | 580 | (instanceId, driver) => |
| 594 | settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, | 581 | resolveProviderInstanceEnabled( |
| 595 | ), | 582 | settings.providerInstances[instanceId] ?? { driver, config: {} }, |
| 583 | ), | ||
| 596 | ); | 584 | ); |
| 597 | if (!fallback) { | 585 | return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; |
| 598 | return settings; | ||
| 599 | } | ||
| 600 | |||
| 601 | return { | ||
| 602 | ...settings, | ||
| 603 | textGenerationModelSelection: { | ||
| 604 | instanceId: ProviderInstanceId.make(fallback), | ||
| 605 | model: | ||
| 606 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? | ||
| 607 | DEFAULT_MODEL_BY_PROVIDER[fallback] ?? | ||
| 608 | DEFAULT_TEXT_GENERATION_MODEL, | ||
| 609 | } satisfies ModelSelection, | ||
| 610 | }; | ||
| 611 | } | 586 | } |
| 612 | 587 | ||
| 613 | // Values under these keys are compared as a whole — never stripped field-by-field. | 588 | // Values under these keys are compared as a whole — never stripped field-by-field. |
apps/server/src/ws.ts+8| ... | @@ -1199,6 +1199,7 @@ const layerWsRpc = ( | ... | @@ -1199,6 +1199,7 @@ const layerWsRpc = ( |
| 1199 | | ThreadLaunchService.ThreadLaunchService | 1199 | | ThreadLaunchService.ThreadLaunchService |
| 1200 | | FileSystem.FileSystem | 1200 | | FileSystem.FileSystem |
| 1201 | | ServerConfig.ServerConfig | 1201 | | ServerConfig.ServerConfig |
| 1202 | | ServerSettings.ServerSettingsService | ||
| 1202 | >(); | 1203 | >(); |
| 1203 | const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; | 1204 | const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; |
| 1204 | const projectStore = yield* ProjectStore.ProjectStoreV2; | 1205 | const projectStore = yield* ProjectStore.ProjectStoreV2; |
| ... | @@ -2029,6 +2030,13 @@ const layerWsRpc = ( | ... | @@ -2029,6 +2030,13 @@ const layerWsRpc = ( |
| 2029 | message: "Failed to launch thread", | 2030 | message: "Failed to launch thread", |
| 2030 | cause, | 2031 | cause, |
| 2031 | }), | 2032 | }), |
| 2033 | ThreadManagementProviderInstanceDisabledError: (cause) => | ||
| 2034 | new OrchestrationV2ThreadLaunchError({ | ||
| 2035 | commandId: input.commandId, | ||
| 2036 | projectId: input.projectId, | ||
| 2037 | message: cause.message, | ||
| 2038 | cause, | ||
| 2039 | }), | ||
| 2032 | }), | 2040 | }), |
| 2033 | ), | 2041 | ), |
| 2034 | ), | 2042 | ), |
apps/web/src/components/ChatView.tsx+16-3| ... | @@ -81,6 +81,7 @@ import { | ... | @@ -81,6 +81,7 @@ import { |
| 81 | ProviderInteractionMode, | 81 | ProviderInteractionMode, |
| 82 | ProviderDriverKind, | 82 | ProviderDriverKind, |
| 83 | resolveEnvironmentMachineKind, | 83 | resolveEnvironmentMachineKind, |
| 84 | resolveProjectProviderInstanceEnabled, | ||
| 84 | RuntimeMode, | 85 | RuntimeMode, |
| 85 | TerminalOpenInput, | 86 | TerminalOpenInput, |
| 86 | type WorktreeSetupSnapshot, | 87 | type WorktreeSetupSnapshot, |
| ... | @@ -3241,9 +3242,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3241,9 +3242,13 @@ export default function ChatView(props: ChatViewProps) { |
| 3241 | const providerInstanceEntries = useMemo( | 3242 | const providerInstanceEntries = useMemo( |
| 3242 | () => | 3243 | () => |
| 3243 | sortProviderInstanceEntries( | 3244 | sortProviderInstanceEntries( |
| 3244 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), | 3245 | applyProviderInstanceSettings( |
| 3246 | deriveProviderInstanceEntries(providerStatuses), | ||
| 3247 | settings, | ||
| 3248 | activeProject?.id ?? null, | ||
| 3249 | ), | ||
| 3245 | ), | 3250 | ), |
| 3246 | [providerStatuses, settings], | 3251 | [providerStatuses, settings, activeProject?.id], |
| 3247 | ); | 3252 | ); |
| 3248 | const { selectedProviderEntry, requestedDriverKind } = useMemo( | 3253 | const { selectedProviderEntry, requestedDriverKind } = useMemo( |
| 3249 | () => | 3254 | () => |
| ... | @@ -4359,7 +4364,14 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4359,7 +4364,14 @@ export default function ChatView(props: ChatViewProps) { |
| 4359 | (activeProviderInstanceId === null || | 4364 | (activeProviderInstanceId === null || |
| 4360 | provider.instanceId === activeProviderInstanceId) && | 4365 | provider.instanceId === activeProviderInstanceId) && |
| 4361 | provider.driver === selectedProvider && | 4366 | provider.driver === selectedProvider && |
| 4362 | provider.enabled && | 4367 | // The destination project may override the machine switch. |
| 4368 | (environment.serverConfig?.settings | ||
| 4369 | ? resolveProjectProviderInstanceEnabled( | ||
| 4370 | environment.serverConfig.settings, | ||
| 4371 | candidate.projectId, | ||
| 4372 | provider.instanceId, | ||
| 4373 | ) | ||
| 4374 | : provider.enabled) && | ||
| 4363 | provider.installed && | 4375 | provider.installed && |
| 4364 | provider.status !== "error" && | 4376 | provider.status !== "error" && |
| 4365 | provider.auth.status !== "unauthenticated" && | 4377 | provider.auth.status !== "unauthenticated" && |
| ... | @@ -11759,6 +11771,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -11759,6 +11771,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11759 | activeProjectDefaultModelSelection | 11771 | activeProjectDefaultModelSelection |
| 11760 | } | 11772 | } |
| 11761 | activeThreadModelSelection={activeThread?.modelSelection} | 11773 | activeThreadModelSelection={activeThread?.modelSelection} |
| 11774 | activeProjectId={activeProject?.id ?? null} | ||
| 11762 | activeContextWindow={activeContextWindow} | 11775 | activeContextWindow={activeContextWindow} |
| 11763 | activeTasksProgress={activeComposerTasksProgress} | 11776 | activeTasksProgress={activeComposerTasksProgress} |
| 11764 | activeTaskSteps={activeComposerTaskSteps} | 11777 | activeTaskSteps={activeComposerTaskSteps} |
apps/web/src/components/CommandPalette.tsx+5-2| ... | @@ -725,9 +725,12 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -725,9 +725,12 @@ function OpenCommandPaletteDialog(props: { |
| 725 | (environment.environmentId === primaryEnvironmentId ? providers : []); | 725 | (environment.environmentId === primaryEnvironmentId ? providers : []); |
| 726 | const derived = deriveProviderInstanceEntries(environmentProviders); | 726 | const derived = deriveProviderInstanceEntries(environmentProviders); |
| 727 | // Settings fill the ACP registry identity (agent id, icon URL) the | 727 | // Settings fill the ACP registry identity (agent id, icon URL) the |
| 728 | // derived entries alone do not carry. | 728 | // derived entries alone do not carry. This map is keyed by environment, |
| 729 | // not project, because it backs thread rows across every project on | ||
| 730 | // that environment; `enabled` stays machine-scoped (`null`) since no | ||
| 731 | // single project applies. | ||
| 729 | const entries = serverConfig | 732 | const entries = serverConfig |
| 730 | ? applyProviderInstanceSettings(derived, serverConfig.settings) | 733 | ? applyProviderInstanceSettings(derived, serverConfig.settings, null) |
| 731 | : derived; | 734 | : derived; |
| 732 | for (const entry of entries) { | 735 | for (const entry of entries) { |
| 733 | map.set(`${environment.environmentId}:${entry.instanceId}`, entry); | 736 | map.set(`${environment.environmentId}:${entry.instanceId}`, entry); |
apps/web/src/components/chat/ChatComposer.tsx+9-2| ... | @@ -1607,6 +1607,8 @@ export interface ChatComposerProps { | ... | @@ -1607,6 +1607,8 @@ export interface ChatComposerProps { |
| 1607 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; | 1607 | activeProjectDefaultModelSelection: ModelSelection | null | undefined; |
| 1608 | activeThreadModelSelection: ModelSelection | null | undefined; | 1608 | activeThreadModelSelection: ModelSelection | null | undefined; |
| 1609 | reportedModelSelection?: ModelSelection | null; | 1609 | reportedModelSelection?: ModelSelection | null; |
| 1610 | /** The active project, so the picker can fold in its provider instance overrides. */ | ||
| 1611 | activeProjectId: ProjectId | null; | ||
| 1610 | 1612 | ||
| 1611 | // Context window | 1613 | // Context window |
| 1612 | activeContextWindow: ContextWindowSnapshot | null; | 1614 | activeContextWindow: ContextWindowSnapshot | null; |
| ... | @@ -1756,6 +1758,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1756,6 +1758,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1756 | activeProjectDefaultModelSelection, | 1758 | activeProjectDefaultModelSelection, |
| 1757 | activeThreadModelSelection, | 1759 | activeThreadModelSelection, |
| 1758 | reportedModelSelection, | 1760 | reportedModelSelection, |
| 1761 | activeProjectId, | ||
| 1759 | activeContextWindow, | 1762 | activeContextWindow, |
| 1760 | compactThreadUnavailable, | 1763 | compactThreadUnavailable, |
| 1761 | compactDisabled, | 1764 | compactDisabled, |
| ... | @@ -2132,9 +2135,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2132,9 +2135,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2132 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( | 2135 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( |
| 2133 | () => | 2136 | () => |
| 2134 | sortProviderInstanceEntries( | 2137 | sortProviderInstanceEntries( |
| 2135 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), | 2138 | applyProviderInstanceSettings( |
| 2139 | deriveProviderInstanceEntries(providerStatuses), | ||
| 2140 | settings, | ||
| 2141 | activeProjectId, | ||
| 2142 | ), | ||
| 2136 | ), | 2143 | ), |
| 2137 | [providerStatuses, settings], | 2144 | [providerStatuses, settings, activeProjectId], |
| 2138 | ); | 2145 | ); |
| 2139 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | 2146 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 2140 | const { | 2147 | const { |
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+6-1| ... | @@ -64,7 +64,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting | ... | @@ -64,7 +64,11 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 64 | const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; | 64 | const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; |
| 65 | const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); | 65 | const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); |
| 66 | const entries = sortProviderInstanceEntries( | 66 | const entries = sortProviderInstanceEntries( |
| 67 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), | 67 | applyProviderInstanceSettings( |
| 68 | deriveProviderInstanceEntries(providers), | ||
| 69 | settings, | ||
| 70 | target?.projectId ?? null, | ||
| 71 | ), | ||
| 68 | ); | 72 | ); |
| 69 | const modelOptions = getCustomModelOptionsByInstance( | 73 | const modelOptions = getCustomModelOptionsByInstance( |
| 70 | settings, | 74 | settings, |
| ... | @@ -105,6 +109,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting | ... | @@ -105,6 +109,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting |
| 105 | const entry = applyProviderInstanceSettings( | 109 | const entry = applyProviderInstanceSettings( |
| 106 | deriveProviderInstanceEntries(config.providers), | 110 | deriveProviderInstanceEntries(config.providers), |
| 107 | candidate.settings, | 111 | candidate.settings, |
| 112 | candidate.projectId, | ||
| 108 | ).find((option) => option.instanceId === instanceId); | 113 | ).find((option) => option.instanceId === instanceId); |
| 109 | const options = getCustomModelOptionsByInstance( | 114 | const options = getCustomModelOptionsByInstance( |
| 110 | mergeEnvironmentSettings(candidate.settings, settings), | 115 | mergeEnvironmentSettings(candidate.settings, settings), |
apps/web/src/components/settings/ProviderInstanceCard.tsx+119-5| ... | @@ -4,10 +4,12 @@ import { Spinner } from "~/components/ui/spinner"; | ... | @@ -4,10 +4,12 @@ import { Spinner } from "~/components/ui/spinner"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | AlertTriangleIcon, | 6 | AlertTriangleIcon, |
| 7 | CheckIcon, | ||
| 7 | ArrowUpCircleIcon, | 8 | ArrowUpCircleIcon, |
| 8 | CopyIcon, | 9 | CopyIcon, |
| 9 | DownloadIcon, | 10 | DownloadIcon, |
| 10 | ExternalLinkIcon, | 11 | ExternalLinkIcon, |
| 12 | LayersIcon, | ||
| 11 | PlusIcon, | 13 | PlusIcon, |
| 12 | Trash2Icon, | 14 | Trash2Icon, |
| 13 | XIcon, | 15 | XIcon, |
| ... | @@ -55,7 +57,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; | ... | @@ -55,7 +57,7 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 55 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 57 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 56 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | 58 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 57 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 59 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 58 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | 60 | import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 59 | import { AcpSessionManagementSection } from "./AcpSessionManagementSection"; | 61 | import { AcpSessionManagementSection } from "./AcpSessionManagementSection"; |
| 60 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | 62 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; |
| 61 | import { readCodexSetupMode } from "./CodexSetupSection.logic"; | 63 | import { readCodexSetupMode } from "./CodexSetupSection.logic"; |
| ... | @@ -472,6 +474,93 @@ export function ProviderEnvironmentSection(props: { | ... | @@ -472,6 +474,93 @@ export function ProviderEnvironmentSection(props: { |
| 472 | ); | 474 | ); |
| 473 | } | 475 | } |
| 474 | 476 | ||
| 477 | /** | ||
| 478 | * When a project is in scope, the enable control reads and writes that | ||
| 479 | * project's override instead of the environment's `instance.enabled`. | ||
| 480 | * `value` is the project's explicit override (`undefined` means it | ||
| 481 | * inherits the environment); `effectiveEnabled` is what the Switch shows. | ||
| 482 | */ | ||
| 483 | /** | ||
| 484 | * The project-scope counterpart of a settings row's inheritance indicator: | ||
| 485 | * hover for the summary, open for the project and device values side by side. | ||
| 486 | */ | ||
| 487 | function ProjectOverrideInheritance({ | ||
| 488 | projectEnabled, | ||
| 489 | deviceEnabled, | ||
| 490 | }: { | ||
| 491 | readonly projectEnabled: boolean; | ||
| 492 | readonly deviceEnabled: boolean; | ||
| 493 | }) { | ||
| 494 | const layers = [ | ||
| 495 | { key: "project", label: "Project", enabled: projectEnabled, effective: true }, | ||
| 496 | { key: "device", label: "Device", enabled: deviceEnabled, effective: false }, | ||
| 497 | ]; | ||
| 498 | return ( | ||
| 499 | <Popover> | ||
| 500 | <Tooltip> | ||
| 501 | <TooltipTrigger | ||
| 502 | render={ | ||
| 503 | <PopoverTrigger | ||
| 504 | render={ | ||
| 505 | <Button | ||
| 506 | size="icon-micro" | ||
| 507 | variant="ghost-muted" | ||
| 508 | aria-label="Overridden for this project. Show where this value comes from" | ||
| 509 | /> | ||
| 510 | } | ||
| 511 | /> | ||
| 512 | } | ||
| 513 | > | ||
| 514 | <LayersIcon className="size-3 text-primary" /> | ||
| 515 | </TooltipTrigger> | ||
| 516 | <TooltipPopup side="top">Overridden for this project</TooltipPopup> | ||
| 517 | </Tooltip> | ||
| 518 | <PopoverPopup align="end" width="md" padding="none"> | ||
| 519 | <ol role="list" className="px-3 py-2.5 text-sm"> | ||
| 520 | {layers.map((layer) => ( | ||
| 521 | <li | ||
| 522 | key={layer.key} | ||
| 523 | className={cn( | ||
| 524 | "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1", | ||
| 525 | layer.effective && "bg-foreground/[0.06]", | ||
| 526 | )} | ||
| 527 | > | ||
| 528 | <span | ||
| 529 | className={cn( | ||
| 530 | "min-w-0 truncate", | ||
| 531 | layer.effective ? "font-medium text-foreground" : "text-muted-foreground", | ||
| 532 | )} | ||
| 533 | > | ||
| 534 | {layer.label} | ||
| 535 | </span> | ||
| 536 | <span | ||
| 537 | className={cn( | ||
| 538 | "flex items-center gap-1.5", | ||
| 539 | layer.effective ? "text-foreground" : "text-muted-foreground", | ||
| 540 | )} | ||
| 541 | > | ||
| 542 | {layer.enabled ? "On" : "Off"} | ||
| 543 | {layer.effective ? ( | ||
| 544 | <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" /> | ||
| 545 | ) : ( | ||
| 546 | <span aria-hidden className="size-3.5 shrink-0" /> | ||
| 547 | )} | ||
| 548 | </span> | ||
| 549 | </li> | ||
| 550 | ))} | ||
| 551 | </ol> | ||
| 552 | </PopoverPopup> | ||
| 553 | </Popover> | ||
| 554 | ); | ||
| 555 | } | ||
| 556 | |||
| 557 | interface ProjectOverrideControl { | ||
| 558 | readonly value: boolean | undefined; | ||
| 559 | readonly effectiveEnabled: boolean; | ||
| 560 | readonly onChange: (next: boolean) => void; | ||
| 561 | readonly onReset: () => void; | ||
| 562 | } | ||
| 563 | |||
| 475 | interface ProviderInstanceCardProps { | 564 | interface ProviderInstanceCardProps { |
| 476 | readonly instanceId: ProviderInstanceId; | 565 | readonly instanceId: ProviderInstanceId; |
| 477 | readonly instance: ProviderInstanceConfig; | 566 | readonly instance: ProviderInstanceConfig; |
| ... | @@ -515,6 +604,8 @@ interface ProviderInstanceCardProps { | ... | @@ -515,6 +604,8 @@ interface ProviderInstanceCardProps { |
| 515 | readonly isUpdating?: boolean | undefined; | 604 | readonly isUpdating?: boolean | undefined; |
| 516 | readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; | 605 | readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; |
| 517 | readonly environmentId?: EnvironmentId | undefined; | 606 | readonly environmentId?: EnvironmentId | undefined; |
| 607 | /** Omit this prop entirely for environment-wide editing. */ | ||
| 608 | readonly projectOverride?: ProjectOverrideControl | undefined; | ||
| 518 | readonly acpProjects?: | 609 | readonly acpProjects?: |
| 519 | | ReadonlyArray<{ | 610 | | ReadonlyArray<{ |
| 520 | readonly id: ProjectId; | 611 | readonly id: ProjectId; |
| ... | @@ -544,6 +635,9 @@ const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]> | ... | @@ -544,6 +635,9 @@ const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]> |
| 544 | * driver-specific `config.enabled` into the envelope on load and both | 635 | * driver-specific `config.enabled` into the envelope on load and both |
| 545 | * sides resolve through `resolveProviderInstanceEnabled` (an explicit | 636 | * sides resolve through `resolveProviderInstanceEnabled` (an explicit |
| 546 | * false wins, then envelope, then config, then the driver default). | 637 | * false wins, then envelope, then config, then the driver default). |
| 638 | * With `projectOverride` set, the Switch instead reads and writes | ||
| 639 | * that project's override, leaving the environment's `instance.enabled` | ||
| 640 | * untouched. | ||
| 547 | */ | 641 | */ |
| 548 | export function ProviderInstanceCard({ | 642 | export function ProviderInstanceCard({ |
| 549 | instanceId, | 643 | instanceId, |
| ... | @@ -573,8 +667,9 @@ export function ProviderInstanceCard({ | ... | @@ -573,8 +667,9 @@ export function ProviderInstanceCard({ |
| 573 | onAcceptUrlAuth, | 667 | onAcceptUrlAuth, |
| 574 | environmentId, | 668 | environmentId, |
| 575 | acpProjects = EMPTY_ACP_PROJECTS, | 669 | acpProjects = EMPTY_ACP_PROJECTS, |
| 670 | projectOverride, | ||
| 576 | }: ProviderInstanceCardProps) { | 671 | }: ProviderInstanceCardProps) { |
| 577 | const enabled = resolveProviderInstanceEnabled(instance); | 672 | const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance); |
| 578 | const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; | 673 | const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; |
| 579 | // A locally disabled provider reads "Disabled" with a muted dot even if its | 674 | // A locally disabled provider reads "Disabled" with a muted dot even if its |
| 580 | // last server status is stale. Enabled providers use the server status. | 675 | // last server status is stale. Enabled providers use the server status. |
| ... | @@ -582,8 +677,9 @@ export function ProviderInstanceCard({ | ... | @@ -582,8 +677,9 @@ export function ProviderInstanceCard({ |
| 582 | ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") | 677 | ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") |
| 583 | : "disabled"; | 678 | : "disabled"; |
| 584 | const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; | 679 | const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; |
| 680 | // `liveProvider.enabled` is machine-wide; a project override can turn it on. | ||
| 585 | const summary = enabled | 681 | const summary = enabled |
| 586 | ? getProviderSummary(liveProvider) | 682 | ? getProviderSummary(liveProvider && { ...liveProvider, enabled: true }) |
| 587 | : { headline: "Disabled", detail: null }; | 683 | : { headline: "Disabled", detail: null }; |
| 588 | const authEmail = liveProvider?.auth.email?.trim(); | 684 | const authEmail = liveProvider?.auth.email?.trim(); |
| 589 | const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; | 685 | const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; |
| ... | @@ -670,6 +766,10 @@ export function ProviderInstanceCard({ | ... | @@ -670,6 +766,10 @@ export function ProviderInstanceCard({ |
| 670 | }; | 766 | }; |
| 671 | 767 | ||
| 672 | const updateEnabled = (value: boolean) => { | 768 | const updateEnabled = (value: boolean) => { |
| 769 | if (projectOverride) { | ||
| 770 | projectOverride.onChange(value); | ||
| 771 | return; | ||
| 772 | } | ||
| 673 | onUpdate({ ...instance, enabled: value }); | 773 | onUpdate({ ...instance, enabled: value }); |
| 674 | }; | 774 | }; |
| 675 | 775 | ||
| ... | @@ -968,10 +1068,24 @@ export function ProviderInstanceCard({ | ... | @@ -968,10 +1068,24 @@ export function ProviderInstanceCard({ |
| 968 | </span> | 1068 | </span> |
| 969 | </span> | 1069 | </span> |
| 970 | </div> | 1070 | </div> |
| 971 | <span className="flex h-5 shrink-0 items-center"> | 1071 | <span className="flex h-5 shrink-0 items-center gap-1"> |
| 1072 | {projectOverride?.value !== undefined ? ( | ||
| 1073 | <span className="flex items-center"> | ||
| 1074 | <ProjectOverrideInheritance | ||
| 1075 | projectEnabled={projectOverride.value} | ||
| 1076 | deviceEnabled={resolveProviderInstanceEnabled(instance)} | ||
| 1077 | /> | ||
| 1078 | <SettingResetButton | ||
| 1079 | label={`${displayName} enablement`} | ||
| 1080 | tooltip="Reset to the device setting" | ||
| 1081 | disabled={readOnly || !canWriteSettings} | ||
| 1082 | onClick={projectOverride.onReset} | ||
| 1083 | /> | ||
| 1084 | </span> | ||
| 1085 | ) : null} | ||
| 972 | <Switch | 1086 | <Switch |
| 973 | checked={enabled} | 1087 | checked={enabled} |
| 974 | disabled={readOnly} | 1088 | disabled={readOnly || (projectOverride !== undefined && !canWriteSettings)} |
| 975 | onCheckedChange={(checked) => updateEnabled(Boolean(checked))} | 1089 | onCheckedChange={(checked) => updateEnabled(Boolean(checked))} |
| 976 | aria-label={`Enable ${displayName}`} | 1090 | aria-label={`Enable ${displayName}`} |
| 977 | /> | 1091 | /> |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+1| ... | @@ -140,6 +140,7 @@ vi.mock("../../state/session", () => ({ | ... | @@ -140,6 +140,7 @@ vi.mock("../../state/session", () => ({ |
| 140 | 140 | ||
| 141 | vi.mock("../../state/entities", () => ({ | 141 | vi.mock("../../state/entities", () => ({ |
| 142 | useProjects: () => [], | 142 | useProjects: () => [], |
| 143 | useServerConfigs: () => new Map(), | ||
| 143 | })); | 144 | })); |
| 144 | 145 | ||
| 145 | import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; | 146 | import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; |
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+120-1| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts"; | 1 | import { |
| 2 | AuthProvidersManageScope, | ||
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ServerSettings, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 9 | ||
| 4 | import { | 10 | import { |
| 5 | buildProviderEnvironmentOptions, | 11 | buildProviderEnvironmentOptions, |
| 12 | buildProviderInstanceOverridePatch, | ||
| 6 | classifyProviderEnvironmentAccess, | 13 | classifyProviderEnvironmentAccess, |
| 7 | isProviderSettingsEnvironmentAvailable, | 14 | isProviderSettingsEnvironmentAvailable, |
| 8 | resolvePrimaryOperateAccess, | 15 | resolvePrimaryOperateAccess, |
| ... | @@ -227,6 +234,118 @@ describe("primary operate access", () => { | ... | @@ -227,6 +234,118 @@ describe("primary operate access", () => { |
| 227 | }); | 234 | }); |
| 228 | }); | 235 | }); |
| 229 | 236 | ||
| 237 | describe("buildProviderInstanceOverridePatch", () => { | ||
| 238 | const projectId = ProjectId.make("project-a"); | ||
| 239 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 240 | const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); | ||
| 241 | |||
| 242 | it("writes the same choice to every checkout of the project", () => { | ||
| 243 | const siblingId = ProjectId.make("project-a-worktree"); | ||
| 244 | const settings: Pick<ServerSettings, "projectSettingsOverrides"> = { | ||
| 245 | projectSettingsOverrides: { | ||
| 246 | [siblingId]: { newWorktreesStartFromOrigin: true }, | ||
| 247 | }, | ||
| 248 | }; | ||
| 249 | |||
| 250 | expect( | ||
| 251 | buildProviderInstanceOverridePatch(settings, [projectId, siblingId], instanceId, false), | ||
| 252 | ).toEqual({ | ||
| 253 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 254 | [siblingId]: { | ||
| 255 | newWorktreesStartFromOrigin: true, | ||
| 256 | providerInstances: { [instanceId]: { enabled: false } }, | ||
| 257 | }, | ||
| 258 | }); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("sets an override when the project has none yet", () => { | ||
| 262 | const settings: Pick<ServerSettings, "projectSettingsOverrides"> = { | ||
| 263 | projectSettingsOverrides: {}, | ||
| 264 | }; | ||
| 265 | |||
| 266 | expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ | ||
| 267 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 268 | }); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it("adds an instance override alongside the project's other overrides", () => { | ||
| 272 | const settings: Pick<ServerSettings, "projectSettingsOverrides"> = { | ||
| 273 | projectSettingsOverrides: { | ||
| 274 | [projectId]: { | ||
| 275 | newWorktreesStartFromOrigin: true, | ||
| 276 | providerInstances: { [otherInstanceId]: { enabled: false } }, | ||
| 277 | }, | ||
| 278 | }, | ||
| 279 | }; | ||
| 280 | |||
| 281 | expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ | ||
| 282 | [projectId]: { | ||
| 283 | newWorktreesStartFromOrigin: true, | ||
| 284 | providerInstances: { | ||
| 285 | [otherInstanceId]: { enabled: false }, | ||
| 286 | [instanceId]: { enabled: true }, | ||
| 287 | }, | ||
| 288 | }, | ||
| 289 | }); | ||
| 290 | }); | ||
| 291 | |||
| 292 | it("resets the only override by clearing the whole project entry", () => { | ||
| 293 | const settings: Pick<ServerSettings, "projectSettingsOverrides"> = { | ||
| 294 | projectSettingsOverrides: { | ||
| 295 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 296 | }, | ||
| 297 | }; | ||
| 298 | |||
| 299 | expect( | ||
| 300 | buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), | ||
| 301 | ).toEqual({ [projectId]: null }); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("resets one instance while another instance's override and other keys survive", () => { | ||
| 305 | const settings: Pick<ServerSettings, "projectSettingsOverrides"> = { | ||
| 306 | projectSettingsOverrides: { | ||
| 307 | [projectId]: { | ||
| 308 | newWorktreesStartFromOrigin: true, | ||
| 309 | providerInstances: { | ||
| 310 | [instanceId]: { enabled: true }, | ||
| 311 | [otherInstanceId]: { enabled: false }, | ||
| 312 | }, | ||
| 313 | }, | ||
| 314 | }, | ||
| 315 | }; | ||
| 316 | |||
| 317 | expect( | ||
| 318 | buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), | ||
| 319 | ).toEqual({ | ||
| 320 | [projectId]: { | ||
| 321 | newWorktreesStartFromOrigin: true, | ||
| 322 | providerInstances: { [otherInstanceId]: { enabled: false } }, | ||
| 323 | }, | ||
| 324 | }); | ||
| 325 | }); | ||
| 326 | |||
| 327 | it("sends providerInstances: {} explicitly when clearing the last instance override leaves other keys", () => { | ||
| 328 | // The server keeps a stored providerInstances map when an incoming entry | ||
| 329 | // omits the key (so older clients cannot wipe it by editing another | ||
| 330 | // setting). This clear must say so explicitly, or the server would put | ||
| 331 | // the just-cleared override right back. | ||
| 332 | const settings: Pick<ServerSettings, "projectSettingsOverrides"> = { | ||
| 333 | projectSettingsOverrides: { | ||
| 334 | [projectId]: { | ||
| 335 | newWorktreesStartFromOrigin: true, | ||
| 336 | providerInstances: { [instanceId]: { enabled: true } }, | ||
| 337 | }, | ||
| 338 | }, | ||
| 339 | }; | ||
| 340 | |||
| 341 | expect( | ||
| 342 | buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), | ||
| 343 | ).toEqual({ | ||
| 344 | [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} }, | ||
| 345 | }); | ||
| 346 | }); | ||
| 347 | }); | ||
| 348 | |||
| 230 | describe("remote operate access", () => { | 349 | describe("remote operate access", () => { |
| 231 | it("does not treat the old orchestration grant as provider management", () => { | 350 | it("does not treat the old orchestration grant as provider management", () => { |
| 232 | expect( | 351 | expect( |
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+42| ... | @@ -3,7 +3,11 @@ import { | ... | @@ -3,7 +3,11 @@ import { |
| 3 | AuthProvidersManageScope, | 3 | AuthProvidersManageScope, |
| 4 | type AuthSessionState, | 4 | type AuthSessionState, |
| 5 | type EnvironmentId, | 5 | type EnvironmentId, |
| 6 | type ProjectId, | ||
| 7 | type ProjectSettingsOverrides, | ||
| 8 | type ProviderInstanceId, | ||
| 6 | sessionGrantsScope, | 9 | sessionGrantsScope, |
| 10 | type ServerSettings, | ||
| 7 | type SessionGrantInput, | 11 | type SessionGrantInput, |
| 8 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 9 | 13 | ||
| ... | @@ -105,6 +109,44 @@ export function resolveRemoteOperateAccess(input: { | ... | @@ -105,6 +109,44 @@ export function resolveRemoteOperateAccess(input: { |
| 105 | return resolveSessionOperateAccess(input); | 109 | return resolveSessionOperateAccess(input); |
| 106 | } | 110 | } |
| 107 | 111 | ||
| 112 | /** | ||
| 113 | * The patch for one instance's project override, following the envelope | ||
| 114 | * replacement rule for `projectSettingsOverrides`: the returned entry | ||
| 115 | * replaces the project's whole override set, and `null` removes it. | ||
| 116 | * `value: undefined` resets that single instance back to inheriting the | ||
| 117 | * environment's enablement, leaving any other overridden keys untouched. | ||
| 118 | * | ||
| 119 | * `providerInstances` is always sent explicitly, even as `{}`, because the | ||
| 120 | * server keeps a stored `providerInstances` map when an incoming entry omits | ||
| 121 | * the key (so older clients that don't know the field cannot wipe it by | ||
| 122 | * editing another setting). Omitting it here would undo the very clear this | ||
| 123 | * function means to make. | ||
| 124 | */ | ||
| 125 | /** Every checkout of a project on one environment shares the same choice. */ | ||
| 126 | export function buildProviderInstanceOverridePatch( | ||
| 127 | settings: Pick<ServerSettings, "projectSettingsOverrides">, | ||
| 128 | projectIds: readonly ProjectId[], | ||
| 129 | instanceId: ProviderInstanceId, | ||
| 130 | value: boolean | undefined, | ||
| 131 | ): Record<ProjectId, ProjectSettingsOverrides | null> { | ||
| 132 | const patch: Record<ProjectId, ProjectSettingsOverrides | null> = {}; | ||
| 133 | for (const projectId of projectIds) { | ||
| 134 | const current = settings.projectSettingsOverrides[projectId]; | ||
| 135 | const instances = { ...current?.providerInstances }; | ||
| 136 | const instance = { ...instances[instanceId] }; | ||
| 137 | if (value === undefined) delete instance.enabled; | ||
| 138 | else instance.enabled = value; | ||
| 139 | if (Object.keys(instance).length > 0) instances[instanceId] = instance; | ||
| 140 | else delete instances[instanceId]; | ||
| 141 | |||
| 142 | const { providerInstances: _omit, ...rest } = current ?? {}; | ||
| 143 | const nextEntry: ProjectSettingsOverrides = { ...rest, providerInstances: instances }; | ||
| 144 | patch[projectId] = | ||
| 145 | Object.keys(rest).length === 0 && Object.keys(instances).length === 0 ? null : nextEntry; | ||
| 146 | } | ||
| 147 | return patch; | ||
| 148 | } | ||
| 149 | |||
| 108 | export function classifyProviderEnvironmentAccess(input: { | 150 | export function classifyProviderEnvironmentAccess(input: { |
| 109 | readonly connectionPhase: EnvironmentConnectionPhase; | 151 | readonly connectionPhase: EnvironmentConnectionPhase; |
| 110 | readonly hasServerConfig: boolean; | 152 | readonly hasServerConfig: boolean; |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+56-1| ... | @@ -19,9 +19,11 @@ import { | ... | @@ -19,9 +19,11 @@ import { |
| 19 | type AcpRegistryUrlAuthAction, | 19 | type AcpRegistryUrlAuthAction, |
| 20 | PROVIDER_DISPLAY_NAMES, | 20 | PROVIDER_DISPLAY_NAMES, |
| 21 | ProviderDriverKind, | 21 | ProviderDriverKind, |
| 22 | type ProjectId, | ||
| 22 | type ProviderInstanceConfig, | 23 | type ProviderInstanceConfig, |
| 23 | type ProviderInstanceId, | 24 | type ProviderInstanceId, |
| 24 | resolveEnvironmentMachineKind, | 25 | resolveEnvironmentMachineKind, |
| 26 | resolveProjectProviderInstanceEnabled, | ||
| 25 | resolveProviderInstanceEnabled, | 27 | resolveProviderInstanceEnabled, |
| 26 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 27 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 29 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| ... | @@ -54,7 +56,7 @@ import { | ... | @@ -54,7 +56,7 @@ import { |
| 54 | } from "../../state/environments"; | 56 | } from "../../state/environments"; |
| 55 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; | 57 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; |
| 56 | import { useEnvironmentSessionState } from "../../state/session"; | 58 | import { useEnvironmentSessionState } from "../../state/session"; |
| 57 | import { useProjects } from "../../state/entities"; | 59 | import { useProjects, useServerConfigs } from "../../state/entities"; |
| 58 | import { useAtomCommand } from "../../state/use-atom-command"; | 60 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 59 | import { getRelativeTimeState } from "../../timestampFormat"; | 61 | import { getRelativeTimeState } from "../../timestampFormat"; |
| 60 | import { | 62 | import { |
| ... | @@ -116,6 +118,7 @@ import { | ... | @@ -116,6 +118,7 @@ import { |
| 116 | } from "./settingsLayout"; | 118 | } from "./settingsLayout"; |
| 117 | import { | 119 | import { |
| 118 | buildProviderEnvironmentOptions, | 120 | buildProviderEnvironmentOptions, |
| 121 | buildProviderInstanceOverridePatch, | ||
| 119 | classifyProviderEnvironmentAccess, | 122 | classifyProviderEnvironmentAccess, |
| 120 | isProviderSettingsEnvironmentAvailable, | 123 | isProviderSettingsEnvironmentAvailable, |
| 121 | type ProviderEnvironmentAccess, | 124 | type ProviderEnvironmentAccess, |
| ... | @@ -279,6 +282,11 @@ interface ProviderSettingsTarget { | ... | @@ -279,6 +282,11 @@ interface ProviderSettingsTarget { |
| 279 | readonly instanceId?: ProviderInstanceId; | 282 | readonly instanceId?: ProviderInstanceId; |
| 280 | readonly scoped?: boolean; | 283 | readonly scoped?: boolean; |
| 281 | readonly environmentIds?: readonly EnvironmentId[]; | 284 | readonly environmentIds?: readonly EnvironmentId[]; |
| 285 | /** | ||
| 286 | * The project's checkouts on the selected environment, if a project is in | ||
| 287 | * scope. The enable toggle writes each one's override so they never diverge. | ||
| 288 | */ | ||
| 289 | readonly projectIds?: readonly ProjectId[]; | ||
| 282 | } | 290 | } |
| 283 | 291 | ||
| 284 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { | 292 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { |
| ... | @@ -450,6 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { | ... | @@ -450,6 +458,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { |
| 450 | ? target.instanceId | 458 | ? target.instanceId |
| 451 | : undefined | 459 | : undefined |
| 452 | } | 460 | } |
| 461 | projectIds={target.projectIds ?? []} | ||
| 453 | /> | 462 | /> |
| 454 | ) : null} | 463 | ) : null} |
| 455 | </> | 464 | </> |
| ... | @@ -460,16 +469,19 @@ function SelectedEnvironmentProviderSettings({ | ... | @@ -460,16 +469,19 @@ function SelectedEnvironmentProviderSettings({ |
| 460 | environment, | 469 | environment, |
| 461 | deviceTabs, | 470 | deviceTabs, |
| 462 | targetInstanceId, | 471 | targetInstanceId, |
| 472 | projectIds, | ||
| 463 | }: { | 473 | }: { |
| 464 | readonly environment: EnvironmentPresentation; | 474 | readonly environment: EnvironmentPresentation; |
| 465 | readonly deviceTabs?: ReactNode; | 475 | readonly deviceTabs?: ReactNode; |
| 466 | readonly targetInstanceId?: ProviderInstanceId | undefined; | 476 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 477 | readonly projectIds: readonly ProjectId[]; | ||
| 467 | }) { | 478 | }) { |
| 468 | return ( | 479 | return ( |
| 469 | <RemoteSessionGatedProviderSettings | 480 | <RemoteSessionGatedProviderSettings |
| 470 | environment={environment} | 481 | environment={environment} |
| 471 | deviceTabs={deviceTabs} | 482 | deviceTabs={deviceTabs} |
| 472 | targetInstanceId={targetInstanceId} | 483 | targetInstanceId={targetInstanceId} |
| 484 | projectIds={projectIds} | ||
| 473 | /> | 485 | /> |
| 474 | ); | 486 | ); |
| 475 | } | 487 | } |
| ... | @@ -478,10 +490,12 @@ function RemoteSessionGatedProviderSettings({ | ... | @@ -478,10 +490,12 @@ function RemoteSessionGatedProviderSettings({ |
| 478 | environment, | 490 | environment, |
| 479 | deviceTabs, | 491 | deviceTabs, |
| 480 | targetInstanceId, | 492 | targetInstanceId, |
| 493 | projectIds, | ||
| 481 | }: { | 494 | }: { |
| 482 | readonly environment: EnvironmentPresentation; | 495 | readonly environment: EnvironmentPresentation; |
| 483 | readonly deviceTabs?: ReactNode; | 496 | readonly deviceTabs?: ReactNode; |
| 484 | readonly targetInstanceId?: ProviderInstanceId | undefined; | 497 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 498 | readonly projectIds: readonly ProjectId[]; | ||
| 485 | }) { | 499 | }) { |
| 486 | const sessionState = useEnvironmentSessionState(environment.environmentId); | 500 | const sessionState = useEnvironmentSessionState(environment.environmentId); |
| 487 | const operateAccess = resolveRemoteOperateAccess({ | 501 | const operateAccess = resolveRemoteOperateAccess({ |
| ... | @@ -495,6 +509,7 @@ function RemoteSessionGatedProviderSettings({ | ... | @@ -495,6 +509,7 @@ function RemoteSessionGatedProviderSettings({ |
| 495 | operateAccess={operateAccess} | 509 | operateAccess={operateAccess} |
| 496 | deviceTabs={deviceTabs} | 510 | deviceTabs={deviceTabs} |
| 497 | targetInstanceId={targetInstanceId} | 511 | targetInstanceId={targetInstanceId} |
| 512 | projectIds={projectIds} | ||
| 498 | /> | 513 | /> |
| 499 | ); | 514 | ); |
| 500 | } | 515 | } |
| ... | @@ -504,11 +519,13 @@ function AccessGatedProviderSettings({ | ... | @@ -504,11 +519,13 @@ function AccessGatedProviderSettings({ |
| 504 | operateAccess, | 519 | operateAccess, |
| 505 | deviceTabs, | 520 | deviceTabs, |
| 506 | targetInstanceId, | 521 | targetInstanceId, |
| 522 | projectIds, | ||
| 507 | }: { | 523 | }: { |
| 508 | readonly environment: EnvironmentPresentation; | 524 | readonly environment: EnvironmentPresentation; |
| 509 | readonly operateAccess: ProviderOperateAccess; | 525 | readonly operateAccess: ProviderOperateAccess; |
| 510 | readonly deviceTabs?: ReactNode; | 526 | readonly deviceTabs?: ReactNode; |
| 511 | readonly targetInstanceId?: ProviderInstanceId | undefined; | 527 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 528 | readonly projectIds: readonly ProjectId[]; | ||
| 512 | }) { | 529 | }) { |
| 513 | const access = classifyProviderEnvironmentAccess({ | 530 | const access = classifyProviderEnvironmentAccess({ |
| 514 | connectionPhase: environment.connection.phase, | 531 | connectionPhase: environment.connection.phase, |
| ... | @@ -531,6 +548,7 @@ function AccessGatedProviderSettings({ | ... | @@ -531,6 +548,7 @@ function AccessGatedProviderSettings({ |
| 531 | readOnly={access.kind === "read-only"} | 548 | readOnly={access.kind === "read-only"} |
| 532 | deviceTabs={deviceTabs} | 549 | deviceTabs={deviceTabs} |
| 533 | targetInstanceId={targetInstanceId} | 550 | targetInstanceId={targetInstanceId} |
| 551 | projectIds={projectIds} | ||
| 534 | /> | 552 | /> |
| 535 | ); | 553 | ); |
| 536 | } | 554 | } |
| ... | @@ -541,11 +559,14 @@ export function EnvironmentProviderSettings({ | ... | @@ -541,11 +559,14 @@ export function EnvironmentProviderSettings({ |
| 541 | readOnly = false, | 559 | readOnly = false, |
| 542 | deviceTabs, | 560 | deviceTabs, |
| 543 | targetInstanceId, | 561 | targetInstanceId, |
| 562 | projectIds = [], | ||
| 544 | }: { | 563 | }: { |
| 545 | readonly environmentId: EnvironmentId; | 564 | readonly environmentId: EnvironmentId; |
| 546 | readonly environmentLabel: string; | 565 | readonly environmentLabel: string; |
| 547 | readonly deviceTabs?: ReactNode; | 566 | readonly deviceTabs?: ReactNode; |
| 548 | readonly targetInstanceId?: ProviderInstanceId | undefined; | 567 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 568 | /** The project's checkouts on this environment, if a project is in scope. */ | ||
| 569 | readonly projectIds?: readonly ProjectId[]; | ||
| 549 | /** | 570 | /** |
| 550 | * Grey out and freeze every write control when this session's credential | 571 | * Grey out and freeze every write control when this session's credential |
| 551 | * lacks `providers:manage` on the environment. Selecting providers | 572 | * lacks `providers:manage` on the environment. Selecting providers |
| ... | @@ -564,6 +585,12 @@ export function EnvironmentProviderSettings({ | ... | @@ -564,6 +585,12 @@ export function EnvironmentProviderSettings({ |
| 564 | const serverProviders = | 585 | const serverProviders = |
| 565 | useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; | 586 | useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; |
| 566 | const projects = useProjects().filter((project) => project.environmentId === environmentId); | 587 | const projects = useProjects().filter((project) => project.environmentId === environmentId); |
| 588 | // Older servers decode an unknown providerInstances key away, so sending | ||
| 589 | // a project override patch would silently do nothing. Hide the switch | ||
| 590 | // instead of offering a control that can never take effect. | ||
| 591 | const supportsProjectProviderOverrides = | ||
| 592 | useServerConfigs().get(environmentId)?.environment.capabilities | ||
| 593 | .projectProviderInstanceOverrides === true; | ||
| 567 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { | 594 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 568 | reportFailure: false, | 595 | reportFailure: false, |
| 569 | }); | 596 | }); |
| ... | @@ -901,12 +928,40 @@ export function EnvironmentProviderSettings({ | ... | @@ -901,12 +928,40 @@ export function EnvironmentProviderSettings({ |
| 901 | favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, | 928 | favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, |
| 902 | ); | 929 | ); |
| 903 | const resetLabel = driverOption?.label ?? String(row.driver); | 930 | const resetLabel = driverOption?.label ?? String(row.driver); |
| 931 | const representativeProjectId = projectIds[0]; | ||
| 932 | const writeProjectOverride = (next: boolean | undefined) => { | ||
| 933 | updateSettings({ | ||
| 934 | projectSettingsOverrides: buildProviderInstanceOverridePatch( | ||
| 935 | settings, | ||
| 936 | projectIds, | ||
| 937 | row.instanceId, | ||
| 938 | next, | ||
| 939 | ), | ||
| 940 | }); | ||
| 941 | }; | ||
| 942 | const projectOverride = | ||
| 943 | representativeProjectId === undefined || !supportsProjectProviderOverrides | ||
| 944 | ? undefined | ||
| 945 | : { | ||
| 946 | value: | ||
| 947 | settings.projectSettingsOverrides[representativeProjectId]?.providerInstances?.[ | ||
| 948 | row.instanceId | ||
| 949 | ]?.enabled, | ||
| 950 | effectiveEnabled: resolveProjectProviderInstanceEnabled( | ||
| 951 | settings, | ||
| 952 | representativeProjectId, | ||
| 953 | row.instanceId, | ||
| 954 | ), | ||
| 955 | onChange: (next: boolean) => writeProjectOverride(next), | ||
| 956 | onReset: () => writeProjectOverride(undefined), | ||
| 957 | }; | ||
| 904 | 958 | ||
| 905 | return ( | 959 | return ( |
| 906 | <ProviderInstanceCard | 960 | <ProviderInstanceCard |
| 907 | key={row.instanceId} | 961 | key={row.instanceId} |
| 908 | environmentId={environmentId} | 962 | environmentId={environmentId} |
| 909 | acpProjects={projects} | 963 | acpProjects={projects} |
| 964 | projectOverride={projectOverride} | ||
| 910 | onAcceptUrlAuth={ | 965 | onAcceptUrlAuth={ |
| 911 | readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action) | 966 | readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action) |
| 912 | } | 967 | } |
apps/web/src/components/settings/ScheduledTasksSettings.tsx+19-10| ... | @@ -804,13 +804,6 @@ function ScheduledTaskEditorDialog({ | ... | @@ -804,13 +804,6 @@ function ScheduledTaskEditorDialog({ |
| 804 | const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { | 804 | const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, { |
| 805 | label: "scheduled task upsert", | 805 | label: "scheduled task upsert", |
| 806 | }); | 806 | }); |
| 807 | const instanceEntries = useMemo( | ||
| 808 | () => | ||
| 809 | sortProviderInstanceEntries( | ||
| 810 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), | ||
| 811 | ), | ||
| 812 | [providers, settings], | ||
| 813 | ); | ||
| 814 | const [draft, setDraft] = useState<DraftState>(() => | 807 | const [draft, setDraft] = useState<DraftState>(() => |
| 815 | task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, | 808 | task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, |
| 816 | ); | 809 | ); |
| ... | @@ -827,13 +820,29 @@ function ScheduledTaskEditorDialog({ | ... | @@ -827,13 +820,29 @@ function ScheduledTaskEditorDialog({ |
| 827 | : task; | 820 | : task; |
| 828 | const selectedProjectId = draft.projectId || projects[0]?.id || ""; | 821 | const selectedProjectId = draft.projectId || projects[0]?.id || ""; |
| 829 | const selectedProject = projects.find((project) => project.id === selectedProjectId); | 822 | const selectedProject = projects.find((project) => project.id === selectedProjectId); |
| 823 | // The task runs in selectedProject, so its model picker should reflect that | ||
| 824 | // project's effective enablement, not just the machine's. | ||
| 825 | const instanceEntries = useMemo( | ||
| 826 | () => | ||
| 827 | sortProviderInstanceEntries( | ||
| 828 | applyProviderInstanceSettings( | ||
| 829 | deriveProviderInstanceEntries(providers), | ||
| 830 | settings, | ||
| 831 | selectedProject?.id ?? null, | ||
| 832 | ), | ||
| 833 | ), | ||
| 834 | [providers, settings, selectedProject?.id], | ||
| 835 | ); | ||
| 830 | 836 | ||
| 831 | // The real model picker is keyed by a `${instanceId}:${model}` string, which | 837 | // The real model picker is keyed by a `${instanceId}:${model}` string, which |
| 832 | // is exactly how the draft stores its selection. | 838 | // is exactly how the draft stores its selection. |
| 833 | const firstInstance = instanceEntries[0]; | 839 | const firstInstance = instanceEntries[0]; |
| 834 | const activeSelection = draft.modelKey | 840 | const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null; |
| 835 | ? splitModelKey(draft.modelKey) | 841 | const activeSelection = |
| 836 | : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries); | 842 | draftSelection && |
| 843 | instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled) | ||
| 844 | ? draftSelection | ||
| 845 | : scheduledTaskDefaultModel(settings, selectedProject ?? null, instanceEntries); | ||
| 837 | const activeInstanceId = | 846 | const activeInstanceId = |
| 838 | activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); | 847 | activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); |
| 839 | const activeModel = activeSelection?.model ?? ""; | 848 | const activeModel = activeSelection?.model ?? ""; |
apps/web/src/components/settings/SettingsPanels.tsx+6-2| ... | @@ -2183,7 +2183,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2183,7 +2183,7 @@ export function GeneralSettingsPanel() { |
| 2183 | const settings = useScopedSettings(); | 2183 | const settings = useScopedSettings(); |
| 2184 | const updateSettings = useUpdateScopedSettings(); | 2184 | const updateSettings = useUpdateScopedSettings(); |
| 2185 | const navigate = useNavigate(); | 2185 | const navigate = useNavigate(); |
| 2186 | const { scope, environment, connectedEnvironments } = useSettingsScope(); | 2186 | const { scope, environment, connectedEnvironments, target } = useSettingsScope(); |
| 2187 | // The representative environment supplies the provider list for pickers; | 2187 | // The representative environment supplies the provider list for pickers; |
| 2188 | // a fanned-out model choice is validated against every target before it | 2188 | // a fanned-out model choice is validated against every target before it |
| 2189 | // is written. Per-machine tuning (background activity overrides) still | 2189 | // is written. Per-machine tuning (background activity overrides) still |
| ... | @@ -2219,7 +2219,11 @@ export function GeneralSettingsPanel() { | ... | @@ -2219,7 +2219,11 @@ export function GeneralSettingsPanel() { |
| 2219 | const textGenModel = textGenerationModelSelection.model; | 2219 | const textGenModel = textGenerationModelSelection.model; |
| 2220 | const textGenModelOptions = textGenerationModelSelection.options; | 2220 | const textGenModelOptions = textGenerationModelSelection.options; |
| 2221 | const textGenerationModelInstanceEntries = sortProviderInstanceEntries( | 2221 | const textGenerationModelInstanceEntries = sortProviderInstanceEntries( |
| 2222 | applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), | 2222 | applyProviderInstanceSettings( |
| 2223 | deriveProviderInstanceEntries(textGenerationProviders), | ||
| 2224 | settings, | ||
| 2225 | target?.projectId ?? null, | ||
| 2226 | ), | ||
| 2223 | ); | 2227 | ); |
| 2224 | const hasTextGenerationProvider = textGenerationModelInstanceEntries.some( | 2228 | const hasTextGenerationProvider = textGenerationModelInstanceEntries.some( |
| 2225 | (entry) => entry.enabled && entry.isAvailable, | 2229 | (entry) => entry.enabled && entry.isAvailable, |
apps/web/src/components/settings/SourceControlWritingSettings.tsx+26-8| ... | @@ -66,7 +66,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -66,7 +66,7 @@ export function SourceControlWritingSettingsSection() { |
| 66 | const updateSettings = useUpdateScopedSettings(); | 66 | const updateSettings = useUpdateScopedSettings(); |
| 67 | const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]); | 67 | const mixedReviewGuidesEnabled = useScopedSettingsMixed(["reviewGuidesEnabled"]); |
| 68 | const navigate = useNavigate(); | 68 | const navigate = useNavigate(); |
| 69 | const { environment, connectedEnvironments, targets } = useSettingsScope(); | 69 | const { environment, connectedEnvironments, targets, target } = useSettingsScope(); |
| 70 | // The representative supplies the provider list; a model choice is checked | 70 | // The representative supplies the provider list; a model choice is checked |
| 71 | // against every target before it fans out. | 71 | // against every target before it fans out. |
| 72 | const environmentId = environment?.environmentId ?? null; | 72 | const environmentId = environment?.environmentId ?? null; |
| ... | @@ -106,20 +106,37 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -106,20 +106,37 @@ export function SourceControlWritingSettingsSection() { |
| 106 | const textGenerationProviders = serverProviders.filter( | 106 | const textGenerationProviders = serverProviders.filter( |
| 107 | (provider) => provider.supportsTextGeneration !== false, | 107 | (provider) => provider.supportsTextGeneration !== false, |
| 108 | ); | 108 | ); |
| 109 | const defaultModelSelection = resolveAppModelSelectionState(settings, textGenerationProviders); | 109 | const instanceEntries = sortProviderInstanceEntries( |
| 110 | applyProviderInstanceSettings( | ||
| 111 | deriveProviderInstanceEntries(textGenerationProviders), | ||
| 112 | settings, | ||
| 113 | target?.projectId ?? null, | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | // The environment default can name an instance this project turned off. | ||
| 117 | const projectEnabledInstanceIds = new Set( | ||
| 118 | instanceEntries | ||
| 119 | .filter((entry) => entry.enabled && entry.isAvailable) | ||
| 120 | .map((entry) => entry.instanceId), | ||
| 121 | ); | ||
| 122 | const projectEffectiveTextGenerationProviders = textGenerationProviders.filter((provider) => | ||
| 123 | projectEnabledInstanceIds.has(provider.instanceId), | ||
| 124 | ); | ||
| 125 | const defaultModelSelection = resolveAppModelSelectionState( | ||
| 126 | settings, | ||
| 127 | projectEffectiveTextGenerationProviders, | ||
| 128 | ); | ||
| 110 | const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; | 129 | const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null; |
| 111 | const activeSelection = resolveAppModelSelectionState( | 130 | const activeSelection = resolveAppModelSelectionState( |
| 112 | { | 131 | { |
| 113 | ...settings, | 132 | ...settings, |
| 114 | textGenerationModelSelection: resolveSourceControlWriterModelSelection( | 133 | textGenerationModelSelection: resolveSourceControlWriterModelSelection( |
| 115 | settings, | 134 | settings, |
| 116 | textGenerationProviders, | 135 | projectEffectiveTextGenerationProviders, |
| 136 | target?.projectId ?? null, | ||
| 117 | ), | 137 | ), |
| 118 | }, | 138 | }, |
| 119 | textGenerationProviders, | 139 | projectEffectiveTextGenerationProviders, |
| 120 | ); | ||
| 121 | const instanceEntries = sortProviderInstanceEntries( | ||
| 122 | applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), | ||
| 123 | ); | 140 | ); |
| 124 | const canEnableDedicatedModel = instanceEntries.some( | 141 | const canEnableDedicatedModel = instanceEntries.some( |
| 125 | (entry) => | 142 | (entry) => |
| ... | @@ -127,7 +144,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -127,7 +144,7 @@ export function SourceControlWritingSettingsSection() { |
| 127 | ); | 144 | ); |
| 128 | const modelOptionsByInstance = getCustomModelOptionsByInstance( | 145 | const modelOptionsByInstance = getCustomModelOptionsByInstance( |
| 129 | settings, | 146 | settings, |
| 130 | textGenerationProviders, | 147 | projectEffectiveTextGenerationProviders, |
| 131 | activeSelection.instanceId, | 148 | activeSelection.instanceId, |
| 132 | activeSelection.model, | 149 | activeSelection.model, |
| 133 | ); | 150 | ); |
| ... | @@ -139,6 +156,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -139,6 +156,7 @@ export function SourceControlWritingSettingsSection() { |
| 139 | textGenerationModelSelection: resolveReviewGuideModelSelection( | 156 | textGenerationModelSelection: resolveReviewGuideModelSelection( |
| 140 | settings, | 157 | settings, |
| 141 | textGenerationProviders, | 158 | textGenerationProviders, |
| 159 | target?.projectId ?? null, | ||
| 142 | ), | 160 | ), |
| 143 | }, | 161 | }, |
| 144 | textGenerationProviders, | 162 | textGenerationProviders, |
apps/web/src/components/settings/useScopedModelAvailability.ts+1| ... | @@ -35,6 +35,7 @@ export function useScopedModelDisabledReason( | ... | @@ -35,6 +35,7 @@ export function useScopedModelDisabledReason( |
| 35 | const entry = applyProviderInstanceSettings( | 35 | const entry = applyProviderInstanceSettings( |
| 36 | deriveProviderInstanceEntries(config.providers), | 36 | deriveProviderInstanceEntries(config.providers), |
| 37 | candidate.settings, | 37 | candidate.settings, |
| 38 | candidate.projectId, | ||
| 38 | ).find((option) => option.instanceId === instanceId); | 39 | ).find((option) => option.instanceId === instanceId); |
| 39 | const options = getCustomModelOptionsByInstance( | 40 | const options = getCustomModelOptionsByInstance( |
| 40 | mergeEnvironmentSettings(candidate.settings, settings), | 41 | mergeEnvironmentSettings(candidate.settings, settings), |
apps/web/src/providerInstances.test.ts+83-35| ... | @@ -136,14 +136,19 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -136,14 +136,19 @@ describe("applyProviderInstanceSettings", () => { |
| 136 | const entries = deriveProviderInstanceEntries([ | 136 | const entries = deriveProviderInstanceEntries([ |
| 137 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), | 137 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), |
| 138 | ]); | 138 | ]); |
| 139 | const [entry] = applyProviderInstanceSettings(entries, { | 139 | const [entry] = applyProviderInstanceSettings( |
| 140 | providerInstances: { | 140 | entries, |
| 141 | [ProviderInstanceId.make("codex")]: { | 141 | { |
| 142 | driver: ProviderDriverKind.make("codex"), | 142 | providerInstances: { |
| 143 | enabled: false, | 143 | [ProviderInstanceId.make("codex")]: { |
| 144 | driver: ProviderDriverKind.make("codex"), | ||
| 145 | enabled: false, | ||
| 146 | }, | ||
| 144 | }, | 147 | }, |
| 148 | projectSettingsOverrides: {}, | ||
| 145 | }, | 149 | }, |
| 146 | }); | 150 | null, |
| 151 | ); | ||
| 147 | 152 | ||
| 148 | expect(entry?.enabled).toBe(false); | 153 | expect(entry?.enabled).toBe(false); |
| 149 | }); | 154 | }); |
| ... | @@ -155,9 +160,14 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -155,9 +160,14 @@ describe("applyProviderInstanceSettings", () => { |
| 155 | instanceId: "claude_work", | 160 | instanceId: "claude_work", |
| 156 | }), | 161 | }), |
| 157 | ]); | 162 | ]); |
| 158 | const [entry] = applyProviderInstanceSettings(entries, { | 163 | const [entry] = applyProviderInstanceSettings( |
| 159 | providerInstances: {}, | 164 | entries, |
| 160 | }); | 165 | { |
| 166 | providerInstances: {}, | ||
| 167 | projectSettingsOverrides: {}, | ||
| 168 | }, | ||
| 169 | null, | ||
| 170 | ); | ||
| 161 | 171 | ||
| 162 | expect(entry?.enabled).toBe(false); | 172 | expect(entry?.enabled).toBe(false); |
| 163 | }); | 173 | }); |
| ... | @@ -171,9 +181,14 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -171,9 +181,14 @@ describe("applyProviderInstanceSettings", () => { |
| 171 | instanceId, | 181 | instanceId, |
| 172 | }), | 182 | }), |
| 173 | ]); | 183 | ]); |
| 174 | const [entry] = applyProviderInstanceSettings(entries, { | 184 | const [entry] = applyProviderInstanceSettings( |
| 175 | providerInstances: {}, | 185 | entries, |
| 176 | }); | 186 | { |
| 187 | providerInstances: {}, | ||
| 188 | projectSettingsOverrides: {}, | ||
| 189 | }, | ||
| 190 | null, | ||
| 191 | ); | ||
| 177 | 192 | ||
| 178 | expect(entry?.enabled).toBe(false); | 193 | expect(entry?.enabled).toBe(false); |
| 179 | }, | 194 | }, |
| ... | @@ -187,14 +202,19 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -187,14 +202,19 @@ describe("applyProviderInstanceSettings", () => { |
| 187 | instanceId, | 202 | instanceId, |
| 188 | }), | 203 | }), |
| 189 | ]); | 204 | ]); |
| 190 | const [entry] = applyProviderInstanceSettings(entries, { | 205 | const [entry] = applyProviderInstanceSettings( |
| 191 | providerInstances: { | 206 | entries, |
| 192 | [instanceId]: { | 207 | { |
| 193 | driver: ProviderDriverKind.make("claudeAgent"), | 208 | providerInstances: { |
| 194 | enabled: false, | 209 | [instanceId]: { |
| 210 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 211 | enabled: false, | ||
| 212 | }, | ||
| 195 | }, | 213 | }, |
| 214 | projectSettingsOverrides: {}, | ||
| 196 | }, | 215 | }, |
| 197 | }); | 216 | null, |
| 217 | ); | ||
| 198 | 218 | ||
| 199 | expect(entry?.enabled).toBe(false); | 219 | expect(entry?.enabled).toBe(false); |
| 200 | }); | 220 | }); |
| ... | @@ -207,9 +227,14 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -207,9 +227,14 @@ describe("applyProviderInstanceSettings", () => { |
| 207 | instanceId: "constructor", | 227 | instanceId: "constructor", |
| 208 | }), | 228 | }), |
| 209 | ]); | 229 | ]); |
| 210 | const [entry] = applyProviderInstanceSettings(entries, { | 230 | const [entry] = applyProviderInstanceSettings( |
| 211 | providerInstances: {}, | 231 | entries, |
| 212 | }); | 232 | { |
| 233 | providerInstances: {}, | ||
| 234 | projectSettingsOverrides: {}, | ||
| 235 | }, | ||
| 236 | null, | ||
| 237 | ); | ||
| 213 | 238 | ||
| 214 | expect(entry?.isDefault).toBe(true); | 239 | expect(entry?.isDefault).toBe(true); |
| 215 | expect(entry?.enabled).toBe(false); | 240 | expect(entry?.enabled).toBe(false); |
| ... | @@ -220,7 +245,14 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -220,7 +245,14 @@ describe("applyProviderInstanceSettings", () => { |
| 220 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), | 245 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), |
| 221 | provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), | 246 | provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), |
| 222 | ]); | 247 | ]); |
| 223 | const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} }); | 248 | const [codex, grok] = applyProviderInstanceSettings( |
| 249 | entries, | ||
| 250 | { | ||
| 251 | providerInstances: {}, | ||
| 252 | projectSettingsOverrides: {}, | ||
| 253 | }, | ||
| 254 | null, | ||
| 255 | ); | ||
| 224 | 256 | ||
| 225 | // Settings decide over a stale probe: Codex starts on, Grok starts off. | 257 | // Settings decide over a stale probe: Codex starts on, Grok starts off. |
| 226 | expect(codex?.enabled).toBe(true); | 258 | expect(codex?.enabled).toBe(true); |
| ... | @@ -260,6 +292,7 @@ describe("deriveProviderEntriesByEnvironment", () => { | ... | @@ -260,6 +292,7 @@ describe("deriveProviderEntriesByEnvironment", () => { |
| 260 | config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` }, | 292 | config: { agentId, registryIconUrl: `https://example.com/${agentId}.svg` }, |
| 261 | }, | 293 | }, |
| 262 | }, | 294 | }, |
| 295 | projectSettingsOverrides: {}, | ||
| 263 | }, | 296 | }, |
| 264 | ] as const, | 297 | ] as const, |
| 265 | ), | 298 | ), |
| ... | @@ -618,29 +651,44 @@ describe("provider icon metadata", () => { | ... | @@ -618,29 +651,44 @@ describe("provider icon metadata", () => { |
| 618 | { ...provider({ provider: driver, instanceId }), iconUrl }, | 651 | { ...provider({ provider: driver, instanceId }), iconUrl }, |
| 619 | ]); | 652 | ]); |
| 620 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; | 653 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; |
| 621 | const brandedEntries = applyProviderInstanceSettings(snapshots, { | 654 | const brandedEntries = applyProviderInstanceSettings( |
| 622 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | 655 | snapshots, |
| 623 | }); | 656 | { |
| 657 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | ||
| 658 | projectSettingsOverrides: {}, | ||
| 659 | }, | ||
| 660 | null, | ||
| 661 | ); | ||
| 624 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); | 662 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); |
| 625 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); | 663 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); |
| 626 | 664 | ||
| 627 | const [localEntry] = applyProviderInstanceSettings(brandedEntries, { | 665 | const [localEntry] = applyProviderInstanceSettings( |
| 628 | providerInstances: { | 666 | brandedEntries, |
| 629 | [instanceId]: { | 667 | { |
| 630 | driver, | 668 | providerInstances: { |
| 631 | enabled: false, | 669 | [instanceId]: { |
| 632 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, | 670 | driver, |
| 671 | enabled: false, | ||
| 672 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, | ||
| 673 | }, | ||
| 633 | }, | 674 | }, |
| 675 | projectSettingsOverrides: {}, | ||
| 634 | }, | 676 | }, |
| 635 | }); | 677 | null, |
| 678 | ); | ||
| 636 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); | 679 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); |
| 637 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); | 680 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); |
| 638 | expect(localEntry?.enabled).toBe(false); | 681 | expect(localEntry?.enabled).toBe(false); |
| 639 | expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); | 682 | expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); |
| 640 | 683 | ||
| 641 | const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { | 684 | const [restoredEntry] = applyProviderInstanceSettings( |
| 642 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | 685 | localEntry ? [localEntry] : [], |
| 643 | }); | 686 | { |
| 687 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | ||
| 688 | projectSettingsOverrides: {}, | ||
| 689 | }, | ||
| 690 | null, | ||
| 691 | ); | ||
| 644 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); | 692 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); |
| 645 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); | 693 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); |
| 646 | expect(restoredEntry?.enabled).toBe(true); | 694 | expect(restoredEntry?.enabled).toBe(true); |
apps/web/src/providerInstances.ts+21-10| ... | @@ -15,9 +15,9 @@ | ... | @@ -15,9 +15,9 @@ |
| 15 | import { | 15 | import { |
| 16 | DEFAULT_MODEL_BY_PROVIDER, | 16 | DEFAULT_MODEL_BY_PROVIDER, |
| 17 | defaultInstanceIdForDriver, | 17 | defaultInstanceIdForDriver, |
| 18 | isUnconfiguredDefaultInstanceEnabled, | 18 | resolveProjectProviderInstanceEnabled, |
| 19 | resolveProviderInstanceEnabled, | ||
| 20 | type ModelSelection, | 19 | type ModelSelection, |
| 20 | type ProjectId, | ||
| 21 | type ProviderDriverKind, | 21 | type ProviderDriverKind, |
| 22 | ProviderInstanceId, | 22 | ProviderInstanceId, |
| 23 | type ServerProvider, | 23 | type ServerProvider, |
| ... | @@ -154,13 +154,19 @@ export function deriveProviderInstanceEntries( | ... | @@ -154,13 +154,19 @@ export function deriveProviderInstanceEntries( |
| 154 | */ | 154 | */ |
| 155 | export function deriveProviderEntriesByEnvironment( | 155 | export function deriveProviderEntriesByEnvironment( |
| 156 | providersByEnvironment: Iterable< | 156 | providersByEnvironment: Iterable< |
| 157 | readonly [string, ReadonlyArray<ServerProvider>, Pick<ServerSettings, "providerInstances">?] | 157 | readonly [ |
| 158 | string, | ||
| 159 | ReadonlyArray<ServerProvider>, | ||
| 160 | Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">?, | ||
| 161 | ] | ||
| 158 | >, | 162 | >, |
| 159 | ): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> { | 163 | ): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> { |
| 160 | const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>(); | 164 | const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>(); |
| 161 | for (const [environmentId, providers, settings] of providersByEnvironment) { | 165 | for (const [environmentId, providers, settings] of providersByEnvironment) { |
| 162 | const derived = deriveProviderInstanceEntries(providers); | 166 | const derived = deriveProviderInstanceEntries(providers); |
| 163 | const entries = settings ? applyProviderInstanceSettings(derived, settings) : derived; | 167 | // One map per environment serves every project with a thread there, so |
| 168 | // there is no single project to scope `enabled` to; it stays machine-wide. | ||
| 169 | const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived; | ||
| 164 | byEnvironment.set( | 170 | byEnvironment.set( |
| 165 | environmentId, | 171 | environmentId, |
| 166 | new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), | 172 | new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), |
| ... | @@ -179,20 +185,25 @@ export function deriveProviderEntriesByEnvironment( | ... | @@ -179,20 +185,25 @@ export function deriveProviderEntriesByEnvironment( |
| 179 | * streamed state, since the server runs it with default config. Any other | 185 | * streamed state, since the server runs it with default config. Any other |
| 180 | * instance missing from `providerInstances` is stale (for example | 186 | * instance missing from `providerInstances` is stale (for example |
| 181 | * immediately after deletion) and is treated as disabled. | 187 | * immediately after deletion) and is treated as disabled. |
| 188 | * | ||
| 189 | * `projectId` folds in that project's `providerInstances[id].enabled` override | ||
| 190 | * (enable or disable in either direction over the machine value); `null` | ||
| 191 | * resolves the machine value only. It has no default: a caller that renders | ||
| 192 | * for a single project but forgets to pass it would silently show the | ||
| 193 | * machine's enablement instead of that project's, so passing `null` for a | ||
| 194 | * surface that isn't project-aware must be a deliberate choice, not an | ||
| 195 | * omission. | ||
| 182 | */ | 196 | */ |
| 183 | export function applyProviderInstanceSettings( | 197 | export function applyProviderInstanceSettings( |
| 184 | entries: ReadonlyArray<ProviderInstanceEntry>, | 198 | entries: ReadonlyArray<ProviderInstanceEntry>, |
| 185 | settings: Pick<ServerSettings, "providerInstances">, | 199 | settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">, |
| 200 | projectId: ProjectId | null, | ||
| 186 | ): ReadonlyArray<ProviderInstanceEntry> { | 201 | ): ReadonlyArray<ProviderInstanceEntry> { |
| 187 | return entries.map((entry) => { | 202 | return entries.map((entry) => { |
| 188 | const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) | 203 | const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) |
| 189 | ? settings.providerInstances[entry.instanceId] | 204 | ? settings.providerInstances[entry.instanceId] |
| 190 | : undefined; | 205 | : undefined; |
| 191 | const enabled = explicitInstance | 206 | const enabled = resolveProjectProviderInstanceEnabled(settings, projectId, entry.instanceId); |
| 192 | ? resolveProviderInstanceEnabled(explicitInstance) | ||
| 193 | : entry.isDefault | ||
| 194 | ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId) | ||
| 195 | : false; | ||
| 196 | if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { | 207 | if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { |
| 197 | return enabled === entry.enabled ? entry : { ...entry, enabled }; | 208 | return enabled === entry.enabled ? entry : { ...entry, enabled }; |
| 198 | } | 209 | } |
apps/web/src/routes/settings.providers.tsx+7| ... | @@ -25,6 +25,13 @@ function SettingsProvidersRoute() { | ... | @@ -25,6 +25,13 @@ function SettingsProvidersRoute() { |
| 25 | <ProviderSettingsPanel | 25 | <ProviderSettingsPanel |
| 26 | environmentId={environment.environmentId} | 26 | environmentId={environment.environmentId} |
| 27 | {...(target.instanceId ? { instanceId: target.instanceId } : {})} | 27 | {...(target.instanceId ? { instanceId: target.instanceId } : {})} |
| 28 | projectIds={ | ||
| 29 | scope.kind === "project" || scope.kind === "checkout" | ||
| 30 | ? scope.members | ||
| 31 | .filter((member) => member.environmentId === environment.environmentId) | ||
| 32 | .map((member) => member.id) | ||
| 33 | : [] | ||
| 34 | } | ||
| 28 | scoped | 35 | scoped |
| 29 | /> | 36 | /> |
| 30 | ); | 37 | ); |
docs/user/project-settings.md+8| ... | @@ -27,6 +27,14 @@ again. | ... | @@ -27,6 +27,14 @@ again. |
| 27 | Providers and diagnostics are per machine: they show one environment at a time, the primary | 27 | Providers and diagnostics are per machine: they show one environment at a time, the primary |
| 28 | one until you pick another. Every other setting fans out to the selection. | 28 | one until you pick another. Every other setting fans out to the selection. |
| 29 | 29 | ||
| 30 | With a project selected, a provider's switch in **Settings → Providers** turns it on or off for | ||
| 31 | that project only, in either direction: a provider off on the machine can be used in one project, | ||
| 32 | and one on everywhere can be kept out of another. That project's model pickers follow the switch, | ||
| 33 | and no new turn there can start on a provider it turned off, including queued messages and | ||
| 34 | follow-ups from delegated tasks. A turn already running when the switch flips finishes. A delegated | ||
| 35 | task's result still lands in its parent thread, which sees it on its next turn. Reset returns the project to the | ||
| 36 | machine's setting. Every other provider setting stays per machine. | ||
| 37 | |||
| 30 | On mobile, open **Settings** and use the filter in its header to choose connected environments | 38 | On mobile, open **Settings** and use the filter in its header to choose connected environments |
| 31 | and a project. The filter stays available in server-setting pages. With **All projects** selected, | 39 | and a project. The filter stays available in server-setting pages. With **All projects** selected, |
| 32 | the **Server settings** categories and auto-settle controls in **Thread behavior** edit the | 40 | the **Server settings** categories and auto-settle controls in **Thread behavior** edit the |
packages/contracts/src/environment.ts+5| ... | @@ -135,6 +135,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -135,6 +135,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 135 | threadRestartContinuation: Schema.optionalKey(Schema.Boolean), | 135 | threadRestartContinuation: Schema.optionalKey(Schema.Boolean), |
| 136 | /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ | 136 | /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ |
| 137 | projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), | 137 | projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), |
| 138 | /** Server resolves a project's `providerInstances` override entry. Older | ||
| 139 | servers decode the key away (unknown fields are stripped, not | ||
| 140 | rejected), so the per-project provider switch would silently do | ||
| 141 | nothing; clients hide it instead of sending a patch that goes nowhere. */ | ||
| 142 | projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean), | ||
| 138 | /** Server understands thread.snooze / thread.unsnooze commands. Same | 143 | /** Server understands thread.snooze / thread.unsnooze commands. Same |
| 139 | version-skew contract as threadSettlement. */ | 144 | version-skew contract as threadSettlement. */ |
| 140 | threadSnooze: Schema.optionalKey(Schema.Boolean), | 145 | threadSnooze: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/settings.test.ts+73| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | ClaudeSettings, | 9 | ClaudeSettings, |
| 10 | DEFAULT_SERVER_SETTINGS, | 10 | DEFAULT_SERVER_SETTINGS, |
| 11 | requiredScopesForProjectMutation, | 11 | requiredScopesForProjectMutation, |
| 12 | resolveProjectProviderInstanceEnabled, | ||
| 12 | resolveProviderInstanceEnabled, | 13 | resolveProviderInstanceEnabled, |
| 13 | ServerSettings, | 14 | ServerSettings, |
| 14 | ServerSettingsPatch, | 15 | ServerSettingsPatch, |
| ... | @@ -1063,6 +1064,78 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { | ... | @@ -1063,6 +1064,78 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { |
| 1063 | }); | 1064 | }); |
| 1064 | }); | 1065 | }); |
| 1065 | 1066 | ||
| 1067 | describe("resolveProjectProviderInstanceEnabled", () => { | ||
| 1068 | const projectId = "project-a" as ProjectId; | ||
| 1069 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 1070 | |||
| 1071 | it("inherits the machine value when the project has no override", () => { | ||
| 1072 | const settings: ServerSettings = { | ||
| 1073 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1074 | providerInstances: { | ||
| 1075 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 1076 | }, | ||
| 1077 | }; | ||
| 1078 | expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false); | ||
| 1079 | }); | ||
| 1080 | |||
| 1081 | it("lets a project enable an instance the machine disabled", () => { | ||
| 1082 | const settings: ServerSettings = { | ||
| 1083 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1084 | providerInstances: { | ||
| 1085 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, | ||
| 1086 | }, | ||
| 1087 | projectSettingsOverrides: { | ||
| 1088 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 1089 | }, | ||
| 1090 | }; | ||
| 1091 | expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(true); | ||
| 1092 | }); | ||
| 1093 | |||
| 1094 | it("lets a project disable an instance the machine enabled", () => { | ||
| 1095 | const settings: ServerSettings = { | ||
| 1096 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1097 | providerInstances: { | ||
| 1098 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 1099 | }, | ||
| 1100 | projectSettingsOverrides: { | ||
| 1101 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 1102 | }, | ||
| 1103 | }; | ||
| 1104 | expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false); | ||
| 1105 | }); | ||
| 1106 | |||
| 1107 | it("always resolves to the machine value when no project is in scope", () => { | ||
| 1108 | const settings: ServerSettings = { | ||
| 1109 | ...DEFAULT_SERVER_SETTINGS, | ||
| 1110 | providerInstances: { | ||
| 1111 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 1112 | }, | ||
| 1113 | projectSettingsOverrides: { | ||
| 1114 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 1115 | }, | ||
| 1116 | }; | ||
| 1117 | expect(resolveProjectProviderInstanceEnabled(settings, null, instanceId)).toBe(true); | ||
| 1118 | }); | ||
| 1119 | |||
| 1120 | it("falls back to the unconfigured default when the instance has no settings entry", () => { | ||
| 1121 | const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, providerInstances: {} }; | ||
| 1122 | // `codex_work` has no entry in `providerInstances`, so with no project | ||
| 1123 | // override either, this is the same unconfigured-default fallback a | ||
| 1124 | // machine-wide (no project) lookup would use. | ||
| 1125 | expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe( | ||
| 1126 | resolveProjectProviderInstanceEnabled(settings, null, instanceId), | ||
| 1127 | ); | ||
| 1128 | |||
| 1129 | const overridden = { | ||
| 1130 | ...settings, | ||
| 1131 | projectSettingsOverrides: { | ||
| 1132 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 1133 | }, | ||
| 1134 | }; | ||
| 1135 | expect(resolveProjectProviderInstanceEnabled(overridden, projectId, instanceId)).toBe(true); | ||
| 1136 | }); | ||
| 1137 | }); | ||
| 1138 | |||
| 1066 | describe("requiredScopesForProjectMutation", () => { | 1139 | describe("requiredScopesForProjectMutation", () => { |
| 1067 | it("adds the settings scope only when a mutation carries scripts", () => { | 1140 | it("adds the settings scope only when a mutation carries scripts", () => { |
| 1068 | const projectId = "project-1" as ProjectId; | 1141 | const projectId = "project-1" as ProjectId; |
packages/contracts/src/settings.ts+52-5| ... | @@ -972,10 +972,24 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ | ... | @@ -972,10 +972,24 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 972 | ] as const; | 972 | ] as const; |
| 973 | export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; | 973 | export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; |
| 974 | 974 | ||
| 975 | export const ProjectProviderInstanceOverride = Schema.Struct({ | ||
| 976 | enabled: Schema.optionalKey(Schema.Boolean), | ||
| 977 | }); | ||
| 978 | export type ProjectProviderInstanceOverride = typeof ProjectProviderInstanceOverride.Type; | ||
| 979 | |||
| 975 | /** | 980 | /** |
| 976 | * One project's overrides. An absent key inherits the environment value; | 981 | * One project's overrides. An absent key inherits the environment value; |
| 977 | * `null` is a real value where the environment type is nullable (no default | 982 | * `null` is a real value where the environment type is nullable (no default |
| 978 | * model, no dedicated writer model, never auto-settle). | 983 | * model, no dedicated writer model, never auto-settle). |
| 984 | * | ||
| 985 | * `providerInstances` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: | ||
| 986 | * every other key replaces its `ServerSettings` field whole, while this one | ||
| 987 | * keeps the settings.json shape but overrides field by field, per | ||
| 988 | * `ProviderInstanceId`. Only `enabled` is overridable today. An instance id | ||
| 989 | * or field absent here inherits the machine value, and a present `enabled` | ||
| 990 | * wins in either direction, so a project can turn on an instance the machine | ||
| 991 | * disabled, or turn off one the machine enabled. See | ||
| 992 | * `resolveProjectProviderInstanceEnabled` for the combined resolution. | ||
| 979 | */ | 993 | */ |
| 980 | export const ProjectSettingsOverrides = Schema.Struct({ | 994 | export const ProjectSettingsOverrides = Schema.Struct({ |
| 981 | worktreeCleanup: Schema.optionalKey(WorktreeCleanup), | 995 | worktreeCleanup: Schema.optionalKey(WorktreeCleanup), |
| ... | @@ -1002,7 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({ | ... | @@ -1002,7 +1016,10 @@ export const ProjectSettingsOverrides = Schema.Struct({ |
| 1002 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), | 1016 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 1003 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), | 1017 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1004 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), | 1018 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), |
| 1005 | } satisfies Record<ProjectScopedServerSettingKey, unknown>); | 1019 | providerInstances: Schema.optionalKey( |
| 1020 | Schema.Record(ProviderInstanceId, ProjectProviderInstanceOverride), | ||
| 1021 | ), | ||
| 1022 | } satisfies Record<ProjectScopedServerSettingKey | "providerInstances", unknown>); | ||
| 1006 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; | 1023 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; |
| 1007 | 1024 | ||
| 1008 | /** | 1025 | /** |
| ... | @@ -1126,10 +1143,11 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -1126,10 +1143,11 @@ export const ServerSettings = Schema.Struct({ |
| 1126 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), | 1143 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), |
| 1127 | ), | 1144 | ), |
| 1128 | /** | 1145 | /** |
| 1129 | * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`. | 1146 | * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`, |
| 1130 | * The source of truth for project settings; `projectAgentBrowserAccessOverrides`, | 1147 | * plus `providerInstances`. The source of truth for project |
| 1131 | * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views | 1148 | * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides` |
| 1132 | * kept for one release so older clients keep reading them. | 1149 | * and `projectScriptOverrides` are derived views kept for one release so |
| 1150 | * older clients keep reading them. | ||
| 1133 | */ | 1151 | */ |
| 1134 | projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( | 1152 | projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( |
| 1135 | Schema.withDecodingDefault(Effect.succeed({})), | 1153 | Schema.withDecodingDefault(Effect.succeed({})), |
| ... | @@ -1371,6 +1389,30 @@ export const resolveProviderInstanceEnabled = ( | ... | @@ -1371,6 +1389,30 @@ export const resolveProviderInstanceEnabled = ( |
| 1371 | return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver); | 1389 | return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver); |
| 1372 | }; | 1390 | }; |
| 1373 | 1391 | ||
| 1392 | /** | ||
| 1393 | * Effective enablement for a provider instance within a project: the | ||
| 1394 | * project's `providerInstances[id].enabled` override when set, otherwise the | ||
| 1395 | * machine-wide `resolveProviderInstanceEnabled` value. `projectId` of `null` | ||
| 1396 | * (no project in scope) always resolves to the machine-wide value. | ||
| 1397 | */ | ||
| 1398 | export const resolveProjectProviderInstanceEnabled = ( | ||
| 1399 | settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">, | ||
| 1400 | projectId: ProjectId | null, | ||
| 1401 | instanceId: ProviderInstanceId, | ||
| 1402 | ): boolean => { | ||
| 1403 | const override = | ||
| 1404 | projectId === null | ||
| 1405 | ? undefined | ||
| 1406 | : settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]?.enabled; | ||
| 1407 | if (override !== undefined) return override; | ||
| 1408 | const instanceConfig = Object.hasOwn(settings.providerInstances, instanceId) | ||
| 1409 | ? settings.providerInstances[instanceId] | ||
| 1410 | : undefined; | ||
| 1411 | return instanceConfig !== undefined | ||
| 1412 | ? resolveProviderInstanceEnabled(instanceConfig) | ||
| 1413 | : isUnconfiguredDefaultInstanceEnabled(instanceId); | ||
| 1414 | }; | ||
| 1415 | |||
| 1374 | export const ServerSettingsOperation = Schema.Literals([ | 1416 | export const ServerSettingsOperation = Schema.Literals([ |
| 1375 | "normalize", | 1417 | "normalize", |
| 1376 | "check-exists", | 1418 | "check-exists", |
| ... | @@ -1498,6 +1540,11 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1498,6 +1540,11 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1498 | * the entry without that key. Per-key null cannot express "clear" for the | 1540 | * the entry without that key. Per-key null cannot express "clear" for the |
| 1499 | * keys whose value type is itself nullable, and clients always hold the | 1541 | * keys whose value type is itself nullable, and clients always hold the |
| 1500 | * current entry from the last settings snapshot. | 1542 | * current entry from the last settings snapshot. |
| 1543 | * | ||
| 1544 | * `providerInstances` is the one exception: an entry that omits the key | ||
| 1545 | * keeps the stored value instead of clearing it, so a client built before | ||
| 1546 | * that field existed cannot wipe it by editing another key. Clearing every | ||
| 1547 | * provider override for a project means sending `providerInstances: {}`. | ||
| 1501 | */ | 1548 | */ |
| 1502 | projectSettingsOverrides: Schema.optionalKey( | 1549 | projectSettingsOverrides: Schema.optionalKey( |
| 1503 | Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), | 1550 | Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), |
packages/contracts/src/t3ProjectFile.ts+4-1| ... | @@ -126,7 +126,10 @@ export const PROJECT_FILE_BACKED_SETTINGS = { | ... | @@ -126,7 +126,10 @@ export const PROJECT_FILE_BACKED_SETTINGS = { |
| 126 | defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, | 126 | defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, |
| 127 | worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, | 127 | worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, |
| 128 | } as const satisfies { | 128 | } as const satisfies { |
| 129 | readonly [K in ProjectScopedServerSettingKey]?: { | 129 | // `providerInstances` is scoped per project but overrides field by field |
| 130 | // rather than replacing a whole `ServerSettings` value (see its doc comment | ||
| 131 | // in settings.ts), so it cannot be file-backed and is excluded here. | ||
| 132 | readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: { | ||
| 130 | readonly field: { | 133 | readonly field: { |
| 131 | readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends | 134 | readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends |
| 132 | | Exclude<ServerSettings[K], null> | 135 | | Exclude<ServerSettings[K], null> |
packages/shared/src/projectSettings.test.ts+38| ... | @@ -147,6 +147,44 @@ describe("resolveProjectSettings", () => { | ... | @@ -147,6 +147,44 @@ describe("resolveProjectSettings", () => { |
| 147 | expect(resolved.settings.defaultModelSelection).toBeNull(); | 147 | expect(resolved.settings.defaultModelSelection).toBeNull(); |
| 148 | expect(resolved.sources.defaultModelSelection).toBe("environment"); | 148 | expect(resolved.sources.defaultModelSelection).toBe("environment"); |
| 149 | }); | 149 | }); |
| 150 | |||
| 151 | it("moves text generation off an instance the project turned off", () => { | ||
| 152 | const codex = ProviderInstanceId.make("codex"); | ||
| 153 | const settings = { | ||
| 154 | ...DEFAULT_SERVER_SETTINGS, | ||
| 155 | textGenerationModelSelection: createModelSelection(codex, "gpt-5"), | ||
| 156 | projectSettingsOverrides: { | ||
| 157 | [projectId]: { providerInstances: { [codex]: { enabled: false } } }, | ||
| 158 | }, | ||
| 159 | }; | ||
| 160 | expect( | ||
| 161 | resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId, | ||
| 162 | ).toBe(ProviderInstanceId.make("claudeAgent")); | ||
| 163 | expect( | ||
| 164 | resolveProjectSettings(settings, otherProjectId).settings.textGenerationModelSelection, | ||
| 165 | ).toEqual(settings.textGenerationModelSelection); | ||
| 166 | }); | ||
| 167 | |||
| 168 | it("falls back to a configured instance when every built-in one is off for the project", () => { | ||
| 169 | const codex = ProviderInstanceId.make("codex"); | ||
| 170 | const codexWork = ProviderInstanceId.make("codex_work"); | ||
| 171 | const settings = { | ||
| 172 | ...DEFAULT_SERVER_SETTINGS, | ||
| 173 | textGenerationModelSelection: createModelSelection(codex, "gpt-5"), | ||
| 174 | providerInstances: { [codexWork]: { driver: ProviderDriverKind.make("codex"), config: {} } }, | ||
| 175 | projectSettingsOverrides: { | ||
| 176 | [projectId]: { | ||
| 177 | providerInstances: { | ||
| 178 | [codex]: { enabled: false }, | ||
| 179 | [ProviderInstanceId.make("claudeAgent")]: { enabled: false }, | ||
| 180 | }, | ||
| 181 | }, | ||
| 182 | }, | ||
| 183 | }; | ||
| 184 | expect( | ||
| 185 | resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId, | ||
| 186 | ).toBe(codexWork); | ||
| 187 | }); | ||
| 150 | }); | 188 | }); |
| 151 | 189 | ||
| 152 | describe("resolveProjectSettings with a t3.json", () => { | 190 | describe("resolveProjectSettings with a t3.json", () => { |
packages/shared/src/projectSettings.ts+20-3| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | type ProjectId, | 6 | type ProjectId, |
| 7 | type ProjectScopedServerSettingKey, | 7 | type ProjectScopedServerSettingKey, |
| 8 | type ProjectSettingsOverrides, | 8 | type ProjectSettingsOverrides, |
| 9 | resolveProjectProviderInstanceEnabled, | ||
| 9 | type ResolvedServerSettings, | 10 | type ResolvedServerSettings, |
| 10 | type ServerSettings, | 11 | type ServerSettings, |
| 11 | type T3ProjectFile, | 12 | type T3ProjectFile, |
| ... | @@ -13,7 +14,7 @@ import { | ... | @@ -13,7 +14,7 @@ import { |
| 13 | type WorktreeCleanupRules, | 14 | type WorktreeCleanupRules, |
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 15 | import { normalizeProjectPathForComparison } from "./path.ts"; | 16 | import { normalizeProjectPathForComparison } from "./path.ts"; |
| 16 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; | 17 | import { fallbackTextGenerationModelSelection } from "./serverSettings.ts"; |
| 17 | 18 | ||
| 18 | /** | 19 | /** |
| 19 | * Where a project-scoped value came from. The order is the priority order: | 20 | * Where a project-scoped value came from. The order is the priority order: |
| ... | @@ -174,18 +175,34 @@ function resolveProjectOverrides( | ... | @@ -174,18 +175,34 @@ function resolveProjectOverrides( |
| 174 | // undefined; that is not an override. | 175 | // undefined; that is not an override. |
| 175 | if (value === undefined) continue; | 176 | if (value === undefined) continue; |
| 176 | // A model on a disabled provider falls back to the environment, like the | 177 | // A model on a disabled provider falls back to the environment, like the |
| 177 | // environment-level guards do for these keys. | 178 | // environment-level guards do for these keys. An instance this project |
| 179 | // enabled via `providerInstances[id].enabled` still counts as enabled here. | ||
| 178 | if ( | 180 | if ( |
| 179 | (key === "textGenerationModelSelection" || key === "defaultModelSelection") && | 181 | (key === "textGenerationModelSelection" || key === "defaultModelSelection") && |
| 180 | value !== undefined && | 182 | value !== undefined && |
| 181 | value !== null && | 183 | value !== null && |
| 182 | !isModelSelectionProviderEnabled(settings, value as ModelSelection) | 184 | !resolveProjectProviderInstanceEnabled( |
| 185 | settings, | ||
| 186 | projectId, | ||
| 187 | (value as ModelSelection).instanceId, | ||
| 188 | ) | ||
| 183 | ) { | 189 | ) { |
| 184 | continue; | 190 | continue; |
| 185 | } | 191 | } |
| 186 | effective[key] = value; | 192 | effective[key] = value; |
| 187 | sources[key] = "project"; | 193 | sources[key] = "project"; |
| 188 | } | 194 | } |
| 195 | // Like the environment guard, text generation never runs on an instance | ||
| 196 | // this project turned off. | ||
| 197 | const textGeneration = effective.textGenerationModelSelection as ModelSelection; | ||
| 198 | if (!resolveProjectProviderInstanceEnabled(settings, projectId, textGeneration.instanceId)) { | ||
| 199 | const fallback = fallbackTextGenerationModelSelection( | ||
| 200 | settings.providerInstances, | ||
| 201 | (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), | ||
| 202 | { includeConfiguredInstances: true }, | ||
| 203 | ); | ||
| 204 | if (fallback !== null) effective.textGenerationModelSelection = fallback; | ||
| 205 | } | ||
| 189 | return { settings: effective as ServerSettings, sources, overrides }; | 206 | return { settings: effective as ServerSettings, sources, overrides }; |
| 190 | } | 207 | } |
| 191 | 208 |
packages/shared/src/serverSettings.test.ts+160-3| ... | @@ -13,6 +13,7 @@ import { createModelSelection } from "./model.ts"; | ... | @@ -13,6 +13,7 @@ import { createModelSelection } from "./model.ts"; |
| 13 | import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; | 13 | import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; |
| 14 | import { | 14 | import { |
| 15 | applyServerSettingsPatch, | 15 | applyServerSettingsPatch, |
| 16 | fallbackTextGenerationModelSelection, | ||
| 16 | modelPreferencesMigrationPatch, | 17 | modelPreferencesMigrationPatch, |
| 17 | resolveModelPreferences, | 18 | resolveModelPreferences, |
| 18 | isModelSelectionProviderEnabled, | 19 | isModelSelectionProviderEnabled, |
| ... | @@ -25,6 +26,32 @@ import { | ... | @@ -25,6 +26,32 @@ import { |
| 25 | /** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ | 26 | /** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ |
| 26 | const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; | 27 | const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; |
| 27 | 28 | ||
| 29 | describe("fallbackTextGenerationModelSelection", () => { | ||
| 30 | it("ignores a configured non-default instance at machine scope, matching no fallback", () => { | ||
| 31 | const configured = ProviderInstanceId.make("codex_work"); | ||
| 32 | const providerInstances = { | ||
| 33 | [configured]: { driver: ProviderDriverKind.make("codex"), config: {} }, | ||
| 34 | }; | ||
| 35 | const fallback = fallbackTextGenerationModelSelection(providerInstances, (instanceId) => | ||
| 36 | instanceId === configured ? true : false, | ||
| 37 | ); | ||
| 38 | expect(fallback).toBeNull(); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("considers a configured non-default instance only when asked to", () => { | ||
| 42 | const configured = ProviderInstanceId.make("codex_work"); | ||
| 43 | const providerInstances = { | ||
| 44 | [configured]: { driver: ProviderDriverKind.make("codex"), config: {} }, | ||
| 45 | }; | ||
| 46 | const fallback = fallbackTextGenerationModelSelection( | ||
| 47 | providerInstances, | ||
| 48 | (instanceId) => instanceId === configured, | ||
| 49 | { includeConfiguredInstances: true }, | ||
| 50 | ); | ||
| 51 | expect(fallback?.instanceId).toBe(configured); | ||
| 52 | }); | ||
| 53 | }); | ||
| 54 | |||
| 28 | describe("serverSettings helpers", () => { | 55 | describe("serverSettings helpers", () => { |
| 29 | it("changes a cleanup rule without replacing the machine's other rules", () => { | 56 | it("changes a cleanup rule without replacing the machine's other rules", () => { |
| 30 | const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | 57 | const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| ... | @@ -442,7 +469,7 @@ describe("serverSettings helpers", () => { | ... | @@ -442,7 +469,7 @@ describe("serverSettings helpers", () => { |
| 442 | expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe( | 469 | expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe( |
| 443 | false, | 470 | false, |
| 444 | ); | 471 | ); |
| 445 | expect(resolveSourceControlWriterModelSelection(settings)).toBe( | 472 | expect(resolveSourceControlWriterModelSelection(settings, undefined, null)).toBe( |
| 446 | settings.textGenerationModelSelection, | 473 | settings.textGenerationModelSelection, |
| 447 | ); | 474 | ); |
| 448 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); | 475 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); |
| ... | @@ -477,7 +504,7 @@ describe("serverSettings helpers", () => { | ... | @@ -477,7 +504,7 @@ describe("serverSettings helpers", () => { |
| 477 | skills: [], | 504 | skills: [], |
| 478 | } satisfies ServerProvider; | 505 | } satisfies ServerProvider; |
| 479 | 506 | ||
| 480 | expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider])).toBe( | 507 | expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider], null)).toBe( |
| 481 | settings.textGenerationModelSelection, | 508 | settings.textGenerationModelSelection, |
| 482 | ); | 509 | ); |
| 483 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); | 510 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); |
| ... | @@ -512,12 +539,86 @@ describe("serverSettings helpers", () => { | ... | @@ -512,12 +539,86 @@ describe("serverSettings helpers", () => { |
| 512 | skills: [], | 539 | skills: [], |
| 513 | } satisfies ServerProvider; | 540 | } satisfies ServerProvider; |
| 514 | 541 | ||
| 515 | expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider])).toBe( | 542 | expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe( |
| 516 | settings.textGenerationModelSelection, | 543 | settings.textGenerationModelSelection, |
| 517 | ); | 544 | ); |
| 518 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); | 545 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); |
| 519 | }); | 546 | }); |
| 520 | 547 | ||
| 548 | it("falls back from a writer provider a project turned off, keeping it for other projects", () => { | ||
| 549 | const instanceId = ProviderInstanceId.make("codex_writer"); | ||
| 550 | const projectId = ProjectId.make("project-a"); | ||
| 551 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); | ||
| 552 | const settings = { | ||
| 553 | ...DEFAULT_SERVER_SETTINGS, | ||
| 554 | providerInstances: { | ||
| 555 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} }, | ||
| 556 | }, | ||
| 557 | projectSettingsOverrides: { | ||
| 558 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, | ||
| 559 | }, | ||
| 560 | sourceControlWriterModelSelection, | ||
| 561 | }; | ||
| 562 | const provider = { | ||
| 563 | instanceId, | ||
| 564 | driver: ProviderDriverKind.make("codex"), | ||
| 565 | enabled: true, | ||
| 566 | installed: true, | ||
| 567 | version: null, | ||
| 568 | status: "ready", | ||
| 569 | auth: { status: "authenticated" }, | ||
| 570 | checkedAt: "2026-07-27T00:00:00.000Z", | ||
| 571 | availability: "available", | ||
| 572 | models: [], | ||
| 573 | slashCommands: [], | ||
| 574 | skills: [], | ||
| 575 | } satisfies ServerProvider; | ||
| 576 | |||
| 577 | expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe( | ||
| 578 | settings.textGenerationModelSelection, | ||
| 579 | ); | ||
| 580 | expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe( | ||
| 581 | sourceControlWriterModelSelection, | ||
| 582 | ); | ||
| 583 | }); | ||
| 584 | |||
| 585 | it("keeps a writer on an instance the project turned on while the machine has it off", () => { | ||
| 586 | const instanceId = ProviderInstanceId.make("codex_writer"); | ||
| 587 | const projectId = ProjectId.make("project-a"); | ||
| 588 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); | ||
| 589 | const settings = { | ||
| 590 | ...DEFAULT_SERVER_SETTINGS, | ||
| 591 | providerInstances: { | ||
| 592 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} }, | ||
| 593 | }, | ||
| 594 | projectSettingsOverrides: { | ||
| 595 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, | ||
| 596 | }, | ||
| 597 | sourceControlWriterModelSelection, | ||
| 598 | }; | ||
| 599 | const provider = { | ||
| 600 | instanceId, | ||
| 601 | driver: ProviderDriverKind.make("codex"), | ||
| 602 | enabled: true, | ||
| 603 | installed: true, | ||
| 604 | version: null, | ||
| 605 | status: "ready", | ||
| 606 | auth: { status: "authenticated" }, | ||
| 607 | checkedAt: "2026-07-27T00:00:00.000Z", | ||
| 608 | availability: "available", | ||
| 609 | models: [], | ||
| 610 | slashCommands: [], | ||
| 611 | skills: [], | ||
| 612 | } satisfies ServerProvider; | ||
| 613 | |||
| 614 | expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe( | ||
| 615 | sourceControlWriterModelSelection, | ||
| 616 | ); | ||
| 617 | expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe( | ||
| 618 | settings.textGenerationModelSelection, | ||
| 619 | ); | ||
| 620 | }); | ||
| 621 | |||
| 521 | it("replaces providerInstances maps so omitted instance fields are cleared", () => { | 622 | it("replaces providerInstances maps so omitted instance fields are cleared", () => { |
| 522 | const codexId = ProviderInstanceId.make("codex"); | 623 | const codexId = ProviderInstanceId.make("codex"); |
| 523 | const current = { | 624 | const current = { |
| ... | @@ -552,6 +653,62 @@ describe("serverSettings helpers", () => { | ... | @@ -552,6 +653,62 @@ describe("serverSettings helpers", () => { |
| 552 | }); | 653 | }); |
| 553 | }); | 654 | }); |
| 554 | 655 | ||
| 656 | it("keeps a project's providerInstances override when a patch entry omits the key", () => { | ||
| 657 | // An older client that predates per-project provider overrides reads and | ||
| 658 | // resends a project's whole entry without knowing about the field. A | ||
| 659 | // naive whole-entry replace would silently wipe it. | ||
| 660 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 661 | const projectId = ProjectId.make("project-a"); | ||
| 662 | const current = { | ||
| 663 | ...DEFAULT_SERVER_SETTINGS, | ||
| 664 | projectSettingsOverrides: { | ||
| 665 | [projectId]: { | ||
| 666 | newWorktreesStartFromOrigin: true, | ||
| 667 | providerInstances: { [instanceId]: { enabled: false } }, | ||
| 668 | }, | ||
| 669 | }, | ||
| 670 | }; | ||
| 671 | |||
| 672 | const patched = applyServerSettingsPatch(current, { | ||
| 673 | projectSettingsOverrides: { | ||
| 674 | [projectId]: { newWorktreesStartFromOrigin: false }, | ||
| 675 | }, | ||
| 676 | }); | ||
| 677 | |||
| 678 | expect(patched.projectSettingsOverrides[projectId]).toEqual({ | ||
| 679 | newWorktreesStartFromOrigin: false, | ||
| 680 | providerInstances: { [instanceId]: { enabled: false } }, | ||
| 681 | }); | ||
| 682 | }); | ||
| 683 | |||
| 684 | it("clears a project's providerInstances override when a patch entry sends an empty map", () => { | ||
| 685 | // A client that knows the field expresses "clear every provider override | ||
| 686 | // for this project" by sending providerInstances explicitly, not by | ||
| 687 | // omitting it. | ||
| 688 | const instanceId = ProviderInstanceId.make("codex_work"); | ||
| 689 | const projectId = ProjectId.make("project-a"); | ||
| 690 | const current = { | ||
| 691 | ...DEFAULT_SERVER_SETTINGS, | ||
| 692 | projectSettingsOverrides: { | ||
| 693 | [projectId]: { | ||
| 694 | newWorktreesStartFromOrigin: true, | ||
| 695 | providerInstances: { [instanceId]: { enabled: false } }, | ||
| 696 | }, | ||
| 697 | }, | ||
| 698 | }; | ||
| 699 | |||
| 700 | const patched = applyServerSettingsPatch(current, { | ||
| 701 | projectSettingsOverrides: { | ||
| 702 | [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} }, | ||
| 703 | }, | ||
| 704 | }); | ||
| 705 | |||
| 706 | expect(patched.projectSettingsOverrides[projectId]).toEqual({ | ||
| 707 | newWorktreesStartFromOrigin: true, | ||
| 708 | providerInstances: {}, | ||
| 709 | }); | ||
| 710 | }); | ||
| 711 | |||
| 555 | it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => { | 712 | it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => { |
| 556 | const hubA = UsageLimitSourceId.make("cliproxy-a"); | 713 | const hubA = UsageLimitSourceId.make("cliproxy-a"); |
| 557 | const hubB = UsageLimitSourceId.make("cliproxy-b"); | 714 | const hubB = UsageLimitSourceId.make("cliproxy-b"); |
packages/shared/src/serverSettings.ts+112-6| ... | @@ -1,13 +1,20 @@ | ... | @@ -1,13 +1,20 @@ |
| 1 | import type { ModelPreferences } from "@t3tools/contracts/settings"; | 1 | import type { ModelPreferences } from "@t3tools/contracts/settings"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 4 | DEFAULT_TEXT_GENERATION_MODEL, | ||
| 5 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, | ||
| 6 | defaultInstanceIdForDriver, | ||
| 3 | isProviderAvailable, | 7 | isProviderAvailable, |
| 4 | isUnconfiguredDefaultInstanceEnabled, | 8 | isUnconfiguredDefaultInstanceEnabled, |
| 9 | resolveProjectProviderInstanceEnabled, | ||
| 5 | resolveProviderInstanceEnabled, | 10 | resolveProviderInstanceEnabled, |
| 6 | isProviderTextGenerationCapable, | 11 | isProviderTextGenerationCapable, |
| 7 | type ModelSelection, | 12 | type ModelSelection, |
| 8 | type ProjectId, | 13 | type ProjectId, |
| 9 | type ProjectScopedServerSettingKey, | 14 | type ProjectScopedServerSettingKey, |
| 10 | type ProjectSettingsOverrides, | 15 | type ProjectSettingsOverrides, |
| 16 | ProviderDriverKind, | ||
| 17 | ProviderInstanceId, | ||
| 11 | type ServerProvider, | 18 | type ServerProvider, |
| 12 | ServerSettings, | 19 | ServerSettings, |
| 13 | type ServerSettingsPatch, | 20 | type ServerSettingsPatch, |
| ... | @@ -71,12 +78,74 @@ export function isModelSelectionProviderEnabled( | ... | @@ -71,12 +78,74 @@ export function isModelSelectionProviderEnabled( |
| 71 | return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); | 78 | return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); |
| 72 | } | 79 | } |
| 73 | 80 | ||
| 81 | const TEXT_GENERATION_FALLBACK_DRIVERS = [ | ||
| 82 | "codex", | ||
| 83 | "claudeAgent", | ||
| 84 | "cursor", | ||
| 85 | "grok", | ||
| 86 | "muse", | ||
| 87 | "pi", | ||
| 88 | "opencode", | ||
| 89 | "antigravity", | ||
| 90 | ].map((driver) => ProviderDriverKind.make(driver)); | ||
| 91 | |||
| 92 | /** | ||
| 93 | * The text generation model on the first instance `isEnabled` accepts: the | ||
| 94 | * built-in default instances in preference order, then, when | ||
| 95 | * `includeConfiguredInstances` is set, configured instances of those | ||
| 96 | * drivers. `null` when none is. | ||
| 97 | * | ||
| 98 | * `includeConfiguredInstances` is for the project-scoped caller only: a | ||
| 99 | * project that disabled every built-in instance should still be able to | ||
| 100 | * fall back to one it configured itself. The machine-level caller leaves it | ||
| 101 | * unset, since widening the candidates there would change behavior for | ||
| 102 | * every user regardless of project scope. | ||
| 103 | */ | ||
| 104 | export function fallbackTextGenerationModelSelection( | ||
| 105 | providerInstances: ServerSettings["providerInstances"], | ||
| 106 | isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, | ||
| 107 | options?: { readonly includeConfiguredInstances?: boolean }, | ||
| 108 | ): ModelSelection | null { | ||
| 109 | const candidates = [ | ||
| 110 | ...TEXT_GENERATION_FALLBACK_DRIVERS.map( | ||
| 111 | (driver) => [defaultInstanceIdForDriver(driver), driver] as const, | ||
| 112 | ), | ||
| 113 | ...(options?.includeConfiguredInstances === true | ||
| 114 | ? Object.entries(providerInstances).flatMap(([instanceId, instance]) => | ||
| 115 | TEXT_GENERATION_FALLBACK_DRIVERS.includes(instance.driver) | ||
| 116 | ? [[ProviderInstanceId.make(instanceId), instance.driver] as const] | ||
| 117 | : [], | ||
| 118 | ) | ||
| 119 | : []), | ||
| 120 | ]; | ||
| 121 | const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); | ||
| 122 | if (match === undefined) return null; | ||
| 123 | const [instanceId, driver] = match; | ||
| 124 | return { | ||
| 125 | instanceId, | ||
| 126 | model: | ||
| 127 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? | ||
| 128 | DEFAULT_MODEL_BY_PROVIDER[driver] ?? | ||
| 129 | DEFAULT_TEXT_GENERATION_MODEL, | ||
| 130 | }; | ||
| 131 | } | ||
| 132 | |||
| 133 | /** | ||
| 134 | * `projectId` has no default: a caller that forgets it would otherwise fall | ||
| 135 | * back to the machine's own writer choice, silently ignoring a project that | ||
| 136 | * turned the writer's instance off. Pass `null` explicitly outside a | ||
| 137 | * project's scope. | ||
| 138 | */ | ||
| 74 | export function resolveSourceControlWriterModelSelection( | 139 | export function resolveSourceControlWriterModelSelection( |
| 75 | settings: ServerSettings, | 140 | settings: ServerSettings, |
| 76 | providers?: ReadonlyArray<ServerProvider>, | 141 | providers: ReadonlyArray<ServerProvider> | undefined, |
| 142 | projectId: ProjectId | null, | ||
| 77 | ): ModelSelection { | 143 | ): ModelSelection { |
| 78 | const selection = settings.sourceControlWriterModelSelection; | 144 | const selection = settings.sourceControlWriterModelSelection; |
| 79 | if (!selection || !isModelSelectionProviderEnabled(settings, selection)) { | 145 | if ( |
| 146 | !selection || | ||
| 147 | !resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) | ||
| 148 | ) { | ||
| 80 | return settings.textGenerationModelSelection; | 149 | return settings.textGenerationModelSelection; |
| 81 | } | 150 | } |
| 82 | if (providers === undefined) { | 151 | if (providers === undefined) { |
| ... | @@ -97,10 +166,14 @@ export function resolveSourceControlWriterModelSelection( | ... | @@ -97,10 +166,14 @@ export function resolveSourceControlWriterModelSelection( |
| 97 | */ | 166 | */ |
| 98 | export function resolveReviewGuideModelSelection( | 167 | export function resolveReviewGuideModelSelection( |
| 99 | settings: ServerSettings, | 168 | settings: ServerSettings, |
| 100 | providers?: ReadonlyArray<ServerProvider>, | 169 | providers: ReadonlyArray<ServerProvider> | undefined, |
| 170 | projectId: ProjectId | null, | ||
| 101 | ): ModelSelection { | 171 | ): ModelSelection { |
| 102 | const selection = settings.reviewGuideModelSelection; | 172 | const selection = settings.reviewGuideModelSelection; |
| 103 | if (selection && isModelSelectionProviderEnabled(settings, selection)) { | 173 | if ( |
| 174 | selection && | ||
| 175 | resolveProjectProviderInstanceEnabled(settings, projectId, selection.instanceId) | ||
| 176 | ) { | ||
| 104 | const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId); | 177 | const provider = providers?.find((candidate) => candidate.instanceId === selection.instanceId); |
| 105 | if ( | 178 | if ( |
| 106 | providers === undefined || | 179 | providers === undefined || |
| ... | @@ -111,7 +184,7 @@ export function resolveReviewGuideModelSelection( | ... | @@ -111,7 +184,7 @@ export function resolveReviewGuideModelSelection( |
| 111 | return selection; | 184 | return selection; |
| 112 | } | 185 | } |
| 113 | } | 186 | } |
| 114 | return resolveSourceControlWriterModelSelection(settings, providers); | 187 | return resolveSourceControlWriterModelSelection(settings, providers, projectId); |
| 115 | } | 188 | } |
| 116 | 189 | ||
| 117 | export interface PersistedServerObservabilitySettings { | 190 | export interface PersistedServerObservabilitySettings { |
| ... | @@ -191,6 +264,36 @@ function mergeSettingsEntries<Value>( | ... | @@ -191,6 +264,36 @@ function mergeSettingsEntries<Value>( |
| 191 | return Object.fromEntries(next); | 264 | return Object.fromEntries(next); |
| 192 | } | 265 | } |
| 193 | 266 | ||
| 267 | /** | ||
| 268 | * `projectSettingsOverrides` entries otherwise replace whole (see the field's | ||
| 269 | * doc comment in contracts), but `providerInstances` was added after clients | ||
| 270 | * already round-trip a project's entry on every edit. A client built before | ||
| 271 | * that addition sends an entry that never mentions the key, and a whole | ||
| 272 | * replace would silently wipe it. When an incoming entry omits the key, keep | ||
| 273 | * what is stored; a client that means to clear every provider override for | ||
| 274 | * the project says so explicitly with `providerInstances: {}`. | ||
| 275 | */ | ||
| 276 | function mergeProjectSettingsOverridesPatch( | ||
| 277 | current: Readonly<Record<string, ProjectSettingsOverrides>>, | ||
| 278 | patch: Readonly<Record<string, ProjectSettingsOverrides | null>>, | ||
| 279 | ): Record<string, ProjectSettingsOverrides> { | ||
| 280 | const next = new Map(Object.entries(current)); | ||
| 281 | for (const [projectId, entry] of Object.entries(patch)) { | ||
| 282 | if (entry === null) { | ||
| 283 | next.delete(projectId); | ||
| 284 | continue; | ||
| 285 | } | ||
| 286 | const storedProviderInstances = current[projectId]?.providerInstances; | ||
| 287 | next.set(projectId, { | ||
| 288 | ...entry, | ||
| 289 | ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined | ||
| 290 | ? {} | ||
| 291 | : { providerInstances: storedProviderInstances }), | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | return Object.fromEntries(next); | ||
| 295 | } | ||
| 296 | |||
| 194 | /** | 297 | /** |
| 195 | * Derived views of `projectSettingsOverrides` for clients that still read | 298 | * Derived views of `projectSettingsOverrides` for clients that still read |
| 196 | * the legacy per-key maps. Recomputed on every patch and load so they | 299 | * the legacy per-key maps. Recomputed on every patch and load so they |
| ... | @@ -463,7 +566,10 @@ export function applyServerSettingsPatch( | ... | @@ -463,7 +566,10 @@ export function applyServerSettingsPatch( |
| 463 | ? { | 566 | ? { |
| 464 | projectSettingsOverrides: Object.fromEntries( | 567 | projectSettingsOverrides: Object.fromEntries( |
| 465 | Object.entries( | 568 | Object.entries( |
| 466 | mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch), | 569 | mergeProjectSettingsOverridesPatch( |
| 570 | current.projectSettingsOverrides, | ||
| 571 | projectSettingsOverridesPatch, | ||
| 572 | ), | ||
| 467 | ).filter(([, entry]) => Object.keys(entry).length > 0), | 573 | ).filter(([, entry]) => Object.keys(entry).length > 0), |
| 468 | ), | 574 | ), |
| 469 | } | 575 | } |