| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | ProviderDriverKind, | 4 | ProviderDriverKind, |
| 5 | ProviderInstanceId, | 5 | ProviderInstanceId, |
| | 6 | resolveProviderInstanceEnabled, |
| 6 | ServerSettings, | 7 | ServerSettings, |
| 7 | ServerSettingsPatch, | 8 | ServerSettingsPatch, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| ... | @@ -16,8 +17,10 @@ import * as Option from "effect/Option"; | ... | @@ -16,8 +17,10 @@ import * as Option from "effect/Option"; |
| 16 | import * as PlatformError from "effect/PlatformError"; | 17 | import * as PlatformError from "effect/PlatformError"; |
| 17 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 18 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| | 20 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 19 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 21 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 20 | import * as ServerConfig from "./config.ts"; | 22 | import * as ServerConfig from "./config.ts"; |
| | 23 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 21 | import * as ServerSettingsModule from "./serverSettings.ts"; | 24 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 22 | | 25 | |
| 23 | const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); | 26 | const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); |
| ... | @@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); | ... | @@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); |
| 26 | const makeServerSettingsLayer = () => | 29 | const makeServerSettingsLayer = () => |
| 27 | ServerSettingsModule.layer.pipe( | 30 | ServerSettingsModule.layer.pipe( |
| 28 | Layer.provide(ServerSecretStore.layer), | 31 | Layer.provide(ServerSecretStore.layer), |
| | 32 | Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), |
| 29 | Layer.provideMerge( | 33 | Layer.provideMerge( |
| 30 | Layer.fresh( | 34 | Layer.fresh( |
| 31 | ServerConfig.layerTest(process.cwd(), { | 35 | ServerConfig.layerTest(process.cwd(), { |
| ... | @@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) | ... | @@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) |
| 47 | }), | 51 | }), |
| 48 | ); | 52 | ); |
| 49 | | 53 | |
| | 54 | const recordProviderUsage = (provider: string, instanceId: string | null = provider) => |
| | 55 | Effect.gen(function* () { |
| | 56 | const sql = yield* SqlClient.SqlClient; |
| | 57 | yield* sql` |
| | 58 | INSERT INTO projection_thread_sessions ( |
| | 59 | thread_id, |
| | 60 | status, |
| | 61 | provider_name, |
| | 62 | provider_instance_id, |
| | 63 | updated_at |
| | 64 | ) |
| | 65 | VALUES ( |
| | 66 | ${`thread-${instanceId ?? provider}`}, |
| | 67 | ${"ready"}, |
| | 68 | ${provider}, |
| | 69 | ${instanceId}, |
| | 70 | ${"2026-08-25T00:00:00.000Z"} |
| | 71 | ) |
| | 72 | `; |
| | 73 | }); |
| | 74 | |
| 50 | it.layer(NodeServices.layer)("server settings", (it) => { | 75 | it.layer(NodeServices.layer)("server settings", (it) => { |
| 51 | it.effect("preserves context when reading a provider environment secret fails", () => { | 76 | it.effect("preserves context when reading a provider environment secret fails", () => { |
| 52 | const platformCause = PlatformError.systemError({ | 77 | const platformCause = PlatformError.systemError({ |
| ... | @@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 67 | ); | 92 | ); |
| 68 | const settingsLayer = ServerSettingsModule.layer.pipe( | 93 | const settingsLayer = ServerSettingsModule.layer.pipe( |
| 69 | Layer.provide(makeFailingSecretStoreLayer(cause)), | 94 | Layer.provide(makeFailingSecretStoreLayer(cause)), |
| | 95 | Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), |
| 70 | Layer.provideMerge(configLayer), | 96 | Layer.provideMerge(configLayer), |
| 71 | ); | 97 | ); |
| 72 | | 98 | |
| ... | @@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 92 | }).pipe(Effect.provide(settingsLayer)); | 118 | }).pipe(Effect.provide(settingsLayer)); |
| 93 | }); | 119 | }); |
| 94 | | 120 | |
| | 121 | it.effect("identifies provider history query failures", () => |
| | 122 | Effect.gen(function* () { |
| | 123 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 124 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 125 | const sql = yield* SqlClient.SqlClient; |
| | 126 | yield* sql`DROP TABLE projection_thread_sessions`; |
| | 127 | |
| | 128 | const error = yield* Effect.flip(serverSettings.getSettings); |
| | 129 | |
| | 130 | assert.deepInclude(error, { |
| | 131 | _tag: "ServerSettingsError", |
| | 132 | operation: "read-provider-history", |
| | 133 | settingsPath: serverConfig.settingsPath, |
| | 134 | }); |
| | 135 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 136 | ); |
| | 137 | |
| 95 | it.effect("decodes nested settings patches", () => | 138 | it.effect("decodes nested settings patches", () => |
| 96 | Effect.gen(function* () { | 139 | Effect.gen(function* () { |
| 97 | assert.deepEqual( | 140 | assert.deepEqual( |
| ... | @@ -488,6 +531,251 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -488,6 +531,251 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 488 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 531 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 489 | ); | 532 | ); |
| 490 | | 533 | |
| | 534 | it.effect("enables previously used providers from sparse settings files", () => |
| | 535 | Effect.gen(function* () { |
| | 536 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 537 | const fileSystem = yield* FileSystem.FileSystem; |
| | 538 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 539 | yield* fileSystem.writeFileString( |
| | 540 | serverConfig.settingsPath, |
| | 541 | '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', |
| | 542 | ); |
| | 543 | yield* recordProviderUsage("opencode"); |
| | 544 | |
| | 545 | const settings = yield* serverSettings.getSettings; |
| | 546 | |
| | 547 | assert.isFalse(settings.providers.grok.enabled); |
| | 548 | assert.isTrue(settings.providers.opencode.enabled); |
| | 549 | assert.isFalse(settings.providers.cursor.enabled); |
| | 550 | assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); |
| | 551 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 552 | ); |
| | 553 | |
| | 554 | it.effect("preserves existing provider instances without explicit enabled flags", () => |
| | 555 | Effect.gen(function* () { |
| | 556 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 557 | const fileSystem = yield* FileSystem.FileSystem; |
| | 558 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 559 | yield* fileSystem.writeFileString( |
| | 560 | serverConfig.settingsPath, |
| | 561 | '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}', |
| | 562 | ); |
| | 563 | yield* recordProviderUsage("cursor", "cursor_work"); |
| | 564 | yield* recordProviderUsage("grok", null); |
| | 565 | yield* recordProviderUsage("opencode", "opencode_work"); |
| | 566 | |
| | 567 | const settings = yield* serverSettings.getSettings; |
| | 568 | |
| | 569 | assert.isTrue(settings.providers.cursor.enabled); |
| | 570 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); |
| | 571 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); |
| | 572 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); |
| | 573 | const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; |
| | 574 | assert.isDefined(unused); |
| | 575 | assert.isFalse(resolveProviderInstanceEnabled(unused)); |
| | 576 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 577 | ); |
| | 578 | |
| | 579 | it.effect("preserves explicit provider disables in existing settings files", () => |
| | 580 | Effect.gen(function* () { |
| | 581 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 582 | const fileSystem = yield* FileSystem.FileSystem; |
| | 583 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 584 | yield* fileSystem.writeFileString( |
| | 585 | serverConfig.settingsPath, |
| | 586 | '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}', |
| | 587 | ); |
| | 588 | yield* recordProviderUsage("grok"); |
| | 589 | yield* recordProviderUsage("opencode"); |
| | 590 | yield* recordProviderUsage("cursor"); |
| | 591 | |
| | 592 | const settings = yield* serverSettings.getSettings; |
| | 593 | |
| | 594 | assert.isFalse(settings.providers.grok.enabled); |
| | 595 | assert.isFalse(settings.providers.opencode.enabled); |
| | 596 | assert.isFalse(settings.providers.cursor.enabled); |
| | 597 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); |
| | 598 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); |
| | 599 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); |
| | 600 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 601 | ); |
| | 602 | |
| | 603 | it.effect("keeps unused providers disabled in existing sparse settings files", () => |
| | 604 | Effect.gen(function* () { |
| | 605 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 606 | const fileSystem = yield* FileSystem.FileSystem; |
| | 607 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 608 | yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); |
| | 609 | |
| | 610 | const settings = yield* serverSettings.getSettings; |
| | 611 | |
| | 612 | assert.isFalse(settings.providers.grok.enabled); |
| | 613 | assert.isFalse(settings.providers.opencode.enabled); |
| | 614 | assert.isFalse(settings.providers.cursor.enabled); |
| | 615 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 616 | ); |
| | 617 | |
| | 618 | it.effect("preserves provider history when no settings file exists", () => |
| | 619 | Effect.gen(function* () { |
| | 620 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 621 | yield* recordProviderUsage("grok"); |
| | 622 | |
| | 623 | const settings = yield* serverSettings.getSettings; |
| | 624 | |
| | 625 | assert.isTrue(settings.providers.grok.enabled); |
| | 626 | assert.isFalse(settings.providers.opencode.enabled); |
| | 627 | assert.isFalse(settings.providers.cursor.enabled); |
| | 628 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 629 | ); |
| | 630 | |
| | 631 | it.effect("preserves provider history when the settings file is invalid", () => |
| | 632 | Effect.gen(function* () { |
| | 633 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 634 | const fileSystem = yield* FileSystem.FileSystem; |
| | 635 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 636 | yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); |
| | 637 | yield* recordProviderUsage("cursor"); |
| | 638 | |
| | 639 | const settings = yield* serverSettings.getSettings; |
| | 640 | |
| | 641 | assert.isTrue(settings.providers.cursor.enabled); |
| | 642 | assert.isFalse(settings.providers.grok.enabled); |
| | 643 | assert.isFalse(settings.providers.opencode.enabled); |
| | 644 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 645 | ); |
| | 646 | |
| | 647 | it.effect("preserves valid provider flags when another settings field is invalid", () => |
| | 648 | Effect.gen(function* () { |
| | 649 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 650 | const fileSystem = yield* FileSystem.FileSystem; |
| | 651 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 652 | yield* fileSystem.writeFileString( |
| | 653 | serverConfig.settingsPath, |
| | 654 | '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', |
| | 655 | ); |
| | 656 | yield* recordProviderUsage("cursor"); |
| | 657 | |
| | 658 | const settings = yield* serverSettings.getSettings; |
| | 659 | |
| | 660 | assert.isFalse(settings.providers.cursor.enabled); |
| | 661 | assert.isTrue(settings.providers.grok.enabled); |
| | 662 | assert.isFalse(settings.providers.opencode.enabled); |
| | 663 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 664 | ); |
| | 665 | |
| | 666 | it.effect("restores providers from persisted runtime sessions", () => |
| | 667 | Effect.gen(function* () { |
| | 668 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 669 | const sql = yield* SqlClient.SqlClient; |
| | 670 | yield* sql` |
| | 671 | INSERT INTO provider_session_runtime ( |
| | 672 | thread_id, |
| | 673 | provider_name, |
| | 674 | provider_instance_id, |
| | 675 | adapter_key, |
| | 676 | status, |
| | 677 | last_seen_at |
| | 678 | ) |
| | 679 | VALUES ( |
| | 680 | ${"thread-opencode-runtime"}, |
| | 681 | ${"opencode"}, |
| | 682 | ${"opencode"}, |
| | 683 | ${"opencode"}, |
| | 684 | ${"ready"}, |
| | 685 | ${"2026-08-25T00:00:00.000Z"} |
| | 686 | ) |
| | 687 | `; |
| | 688 | |
| | 689 | const settings = yield* serverSettings.getSettings; |
| | 690 | |
| | 691 | assert.isFalse(settings.providers.grok.enabled); |
| | 692 | assert.isTrue(settings.providers.opencode.enabled); |
| | 693 | assert.isFalse(settings.providers.cursor.enabled); |
| | 694 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 695 | ); |
| | 696 | |
| | 697 | it.effect("persists explicit disables after a provider has been used", () => |
| | 698 | Effect.gen(function* () { |
| | 699 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 700 | const fileSystem = yield* FileSystem.FileSystem; |
| | 701 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 702 | yield* recordProviderUsage("grok"); |
| | 703 | |
| | 704 | assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); |
| | 705 | |
| | 706 | const settings = yield* serverSettings.updateSettings({ |
| | 707 | providers: { grok: { enabled: false } }, |
| | 708 | }); |
| | 709 | assert.isFalse(settings.providers.grok.enabled); |
| | 710 | |
| | 711 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); |
| | 712 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| | 713 | assert.isFalse(JSON.parse(raw).providers.grok.enabled); |
| | 714 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 715 | ); |
| | 716 | |
| | 717 | it.effect("persists explicit provider enables before their first use", () => |
| | 718 | Effect.gen(function* () { |
| | 719 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 720 | const fileSystem = yield* FileSystem.FileSystem; |
| | 721 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 722 | |
| | 723 | yield* serverSettings.updateSettings({ |
| | 724 | providers: { |
| | 725 | cursor: { enabled: true }, |
| | 726 | grok: { enabled: true }, |
| | 727 | opencode: { enabled: true }, |
| | 728 | }, |
| | 729 | }); |
| | 730 | yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); |
| | 731 | |
| | 732 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); |
| | 733 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| | 734 | const persisted = JSON.parse(raw); |
| | 735 | assert.isTrue(persisted.providers.cursor.enabled); |
| | 736 | assert.isTrue(persisted.providers.grok.enabled); |
| | 737 | assert.isTrue(persisted.providers.opencode.enabled); |
| | 738 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 739 | ); |
| | 740 | |
| | 741 | it.effect("keeps optional providers disabled after a new installation writes settings", () => |
| | 742 | Effect.gen(function* () { |
| | 743 | const serverConfig = yield* ServerConfig.ServerConfig; |
| | 744 | const fileSystem = yield* FileSystem.FileSystem; |
| | 745 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| | 746 | |
| | 747 | const initial = yield* serverSettings.getSettings; |
| | 748 | assert.isFalse(initial.providers.grok.enabled); |
| | 749 | assert.isFalse(initial.providers.opencode.enabled); |
| | 750 | assert.isFalse(initial.providers.cursor.enabled); |
| | 751 | |
| | 752 | const next = yield* serverSettings.updateSettings({ |
| | 753 | addProjectBaseDirectory: "~/Development", |
| | 754 | providerInstances: { |
| | 755 | [ProviderInstanceId.make("grok")]: { |
| | 756 | driver: ProviderDriverKind.make("grok"), |
| | 757 | config: {}, |
| | 758 | }, |
| | 759 | }, |
| | 760 | }); |
| | 761 | |
| | 762 | assert.isFalse(next.providers.grok.enabled); |
| | 763 | assert.isFalse(next.providers.opencode.enabled); |
| | 764 | assert.isFalse(next.providers.cursor.enabled); |
| | 765 | const grok = next.providerInstances[ProviderInstanceId.make("grok")]; |
| | 766 | assert.isDefined(grok); |
| | 767 | assert.isFalse(resolveProviderInstanceEnabled(grok)); |
| | 768 | |
| | 769 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); |
| | 770 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| | 771 | const persisted = JSON.parse(raw); |
| | 772 | assert.isFalse(persisted.providers.cursor.enabled); |
| | 773 | assert.isFalse(persisted.providers.grok.enabled); |
| | 774 | assert.isFalse(persisted.providers.opencode.enabled); |
| | 775 | assert.isUndefined(persisted.providerInstances.grok.enabled); |
| | 776 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| | 777 | ); |
| | 778 | |
| 491 | it.effect("folds a legacy in-config enabled flag into the envelope on load", () => | 779 | it.effect("folds a legacy in-config enabled flag into the envelope on load", () => |
| 492 | Effect.gen(function* () { | 780 | Effect.gen(function* () { |
| 493 | const serverConfig = yield* ServerConfig.ServerConfig; | 781 | const serverConfig = yield* ServerConfig.ServerConfig; |
| ... | @@ -635,7 +923,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -635,7 +923,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 635 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 923 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 636 | ); | 924 | ); |
| 637 | | 925 | |
| 638 | it.effect("writes only non-default server settings to disk", () => | 926 | it.effect("writes non-default settings and explicit optional provider defaults to disk", () => |
| 639 | Effect.gen(function* () { | 927 | Effect.gen(function* () { |
| 640 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 928 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 641 | const serverConfig = yield* ServerConfig.ServerConfig; | 929 | const serverConfig = yield* ServerConfig.ServerConfig; |
| ... | @@ -672,7 +960,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -672,7 +960,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 672 | codex: { | 960 | codex: { |
| 673 | binaryPath: "/opt/homebrew/bin/codex", | 961 | binaryPath: "/opt/homebrew/bin/codex", |
| 674 | }, | 962 | }, |
| | 963 | cursor: { |
| | 964 | enabled: false, |
| | 965 | }, |
| | 966 | grok: { |
| | 967 | enabled: false, |
| | 968 | }, |
| 675 | opencode: { | 969 | opencode: { |
| | 970 | enabled: false, |
| 676 | serverUrl: "http://127.0.0.1:4096", | 971 | serverUrl: "http://127.0.0.1:4096", |
| 677 | serverPassword: "secret-password", | 972 | serverPassword: "secret-password", |
| 678 | }, | 973 | }, |