@north-light/crouter 0.3.311 → 0.3.313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +27 -30
- package/dist/api/client.js +80 -67
- package/dist/api/dto/broker-ops.d.ts +10 -20
- package/dist/api/dto/canvas.d.ts +1 -10
- package/dist/api/dto/health.d.ts +113 -0
- package/dist/api/dto/human-requests.d.ts +14 -3
- package/dist/api/dto/human.d.ts +2 -48
- package/dist/api/dto/human.js +3 -2
- package/dist/api/dto/inbox.d.ts +14 -0
- package/dist/api/dto/mail.d.ts +43 -0
- package/dist/api/dto/nodes.d.ts +11 -8
- package/dist/api/dto/recovery.d.ts +96 -0
- package/dist/api/dto/reviews.d.ts +2 -2
- package/dist/api/routes.d.ts +9 -6
- package/dist/api/routes.js +11 -11
- package/dist/build-root.d.ts +4 -0
- package/dist/build-root.js +11 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +35 -47
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +12 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +12 -5
- package/dist/cli.js +10 -1
- package/dist/clients/__tests__/helpers/human-daemon.d.ts +11 -0
- package/dist/clients/__tests__/helpers/human-daemon.js +60 -0
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/chrome/inbox-strip.d.ts +3 -3
- package/dist/clients/attach/chrome/inbox-strip.js +17 -9
- package/dist/clients/attach/chrome/ticket-panel.d.ts +2 -2
- package/dist/clients/attach/chrome/ticket-panel.js +77 -46
- package/dist/clients/attach/overlays/graph.js +6 -9
- package/dist/clients/attach/render/chat-view.d.ts +5 -4
- package/dist/clients/attach/render/chat-view.js +22 -13
- package/dist/clients/attach/render/page-block.d.ts +11 -7
- package/dist/clients/attach/render/page-block.js +64 -55
- package/dist/clients/attach/viewer.js +746 -735
- package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +56 -56
- package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +45 -32
- package/dist/clients/inbox/controller.d.ts +42 -14
- package/dist/clients/inbox/controller.js +240 -124
- package/dist/clients/inbox/dto-adapt.d.ts +28 -0
- package/dist/clients/inbox/dto-adapt.js +93 -0
- package/dist/clients/inbox/page-adapter.d.ts +5 -4
- package/dist/clients/inbox/page-adapter.js +12 -12
- package/dist/clients/inbox/resolve.d.ts +10 -5
- package/dist/clients/inbox/resolve.js +13 -12
- package/dist/clients/inbox/review/review-client.d.ts +0 -1
- package/dist/clients/inbox/review/review-client.js +0 -4
- package/dist/clients/inbox/review-adapter.d.ts +2 -1
- package/dist/clients/inbox/review-adapter.js +3 -5
- package/dist/clients/inbox/surface.d.ts +2 -2
- package/dist/clients/inbox/surface.js +19 -8
- package/dist/clients/inbox/tui.d.ts +3 -3
- package/dist/commands/__tests__/human.test.js +31 -15
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +36 -11
- package/dist/commands/{sys/__tests__ → __tests__/integration}/migrate.test.js +142 -36
- package/dist/commands/__tests__/revive-now-gate.test.js +2 -0
- package/dist/commands/api-client.d.ts +5 -4
- package/dist/commands/api-client.js +13 -7
- package/dist/commands/canvas-history/shared.js +1 -1
- package/dist/commands/canvas-history.js +1 -1
- package/dist/commands/canvas-roster.js +1 -1
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/human/feedback.js +20 -17
- package/dist/commands/human/prompts.js +45 -36
- package/dist/commands/human/queue.js +73 -123
- package/dist/commands/human/review.js +6 -16
- package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +2 -0
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +2 -0
- package/dist/commands/memory/__tests__/repository-root-lint.test.js +2 -0
- package/dist/commands/node-inspect-artifacts.js +1 -1
- package/dist/commands/node-lifecycle-revive.d.ts +2 -2
- package/dist/commands/node-lifecycle-revive.js +9 -19
- package/dist/commands/pkg/plugin-manage.js +0 -1
- package/dist/commands/profile/show.js +4 -12
- package/dist/commands/surface-tmux-spread.js +1 -5
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +23 -30
- package/dist/commands/sys/__tests__/sync-project-guidance.test.js +37 -11
- package/dist/commands/sys/branch.d.ts +2 -0
- package/dist/commands/sys/branch.js +19 -0
- package/dist/commands/sys/config.js +106 -33
- package/dist/commands/sys/daemon.d.ts +2 -0
- package/dist/commands/sys/daemon.js +130 -7
- package/dist/commands/sys/doctor.js +5 -15
- package/dist/commands/sys/migrate.js +21 -92
- package/dist/commands/sys/setup-settings-runner.js +4 -2
- package/dist/commands/sys.js +2 -15
- package/dist/core/__tests__/boot-fault-attempt.test.js +23 -1
- package/dist/core/__tests__/boot.test.js +32 -25
- package/dist/core/__tests__/broker-launch-candidates.test.js +49 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +149 -176
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +59 -10
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +76 -20
- package/dist/core/__tests__/canvas.test.js +18 -47
- package/dist/core/__tests__/child-death-wake.test.js +29 -7
- package/dist/core/__tests__/cli-help-contract.test.js +2 -0
- package/dist/core/__tests__/close.test.js +29 -10
- package/dist/core/__tests__/collectible.test.js +2 -1
- package/dist/core/__tests__/config-change-delta.test.js +2 -0
- package/dist/core/__tests__/context-intro.test.js +17 -5
- package/dist/core/__tests__/cron-armed-context-failure.test.js +17 -12
- package/dist/core/__tests__/cron-held-settlement.test.js +21 -3
- package/dist/core/__tests__/cron-node-sink-parked-root.test.js +29 -10
- package/dist/core/__tests__/cron-self-cancel-delivery.test.js +14 -2
- package/dist/core/__tests__/cron-timeout-descendants.test.js +13 -1
- package/dist/core/__tests__/daemon-boot.test.js +51 -14
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +8 -3
- package/dist/core/__tests__/focuses.test.js +10 -5
- package/dist/core/__tests__/helpers/harness.js +50 -18
- package/dist/core/__tests__/history-inbox.test.js +12 -16
- package/dist/core/__tests__/human-action-delivery.test.js +48 -28
- package/dist/core/__tests__/human-actions.test.js +7 -3
- package/dist/core/__tests__/inline-memory-refs.test.js +7 -2
- package/dist/core/__tests__/integration/broker-fork-seam.test.js +37 -10
- package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +42 -0
- package/dist/core/__tests__/integration/broker-snapshot-history.test.js +42 -29
- package/dist/core/__tests__/integration/broker-wedge-supervision.test.js +36 -35
- package/dist/core/__tests__/integration/command-plugins.test.js +15 -6
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +113 -92
- package/dist/core/__tests__/integration/host-teardown-process-group.test.js +9 -5
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +312 -1
- package/dist/core/__tests__/integration/plugin-revalidate.test.js +4 -0
- package/dist/core/__tests__/integration/profile-pause.test.js +4 -4
- package/dist/core/__tests__/integration/refresh-stall-recycle.test.js +9 -12
- package/dist/core/__tests__/integration/revive.test.js +67 -21
- package/dist/core/__tests__/integration/spawn-root.test.js +62 -19
- package/dist/core/__tests__/integration/subscription-delivery.test.js +33 -21
- package/dist/core/__tests__/integration/worktree-land.test.js +5 -2
- package/dist/core/__tests__/integration/worktree-reap.test.js +15 -1
- package/dist/core/__tests__/kickoff.test.js +36 -39
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +2 -1
- package/dist/core/__tests__/lifecycle.test.js +2 -1
- package/dist/core/__tests__/live-lifecycle-prompt.test.js +13 -1
- package/dist/core/__tests__/memory-resolver-precedence.test.js +2 -0
- package/dist/core/__tests__/migration.test.js +145 -31
- package/dist/core/__tests__/model-pin-durability.test.js +4 -1
- package/dist/core/__tests__/model-routes-config.test.js +13 -5
- package/dist/core/__tests__/node-outcome.test.js +93 -26
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +4 -3
- package/dist/core/__tests__/on-read-dedup-resume.test.js +8 -7
- package/dist/core/__tests__/on-read-nested-store.test.js +8 -7
- package/dist/core/__tests__/passive-subscription.test.js +50 -157
- package/dist/core/__tests__/plugin-kinds.test.js +6 -4
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +6 -1
- package/dist/core/__tests__/plugin-page-components.test.js +6 -4
- package/dist/core/__tests__/preview-registry-sync.test.js +2 -0
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +35 -32
- package/dist/core/__tests__/prune.test.js +11 -5
- package/dist/core/__tests__/push-final-guard.test.js +14 -7
- package/dist/core/__tests__/relaunch-root.test.js +30 -2
- package/dist/core/__tests__/remote-canvas-target.test.js +7 -1
- package/dist/core/__tests__/repository-root-identity.test.js +4 -0
- package/dist/core/__tests__/review-model-floor.test.js +6 -3
- package/dist/core/__tests__/revive-all.test.js +13 -3
- package/dist/core/__tests__/revive-capacity.test.js +26 -4
- package/dist/core/__tests__/revive-parked-fresh.test.js +31 -8
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +1 -1
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +4 -3
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +11 -14
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +85 -71
- package/dist/core/__tests__/seam/dormancy-release.test.js +33 -19
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +7 -5
- package/dist/core/__tests__/seam/inbox-reference-guidance.test.js +20 -6
- package/dist/core/__tests__/seam/node-outcome-delivery.test.js +27 -3
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +5 -4
- package/dist/core/__tests__/stop-guard.test.js +2 -1
- package/dist/core/__tests__/subscription-pool-credential-guard.test.js +4 -3
- package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +19 -5
- package/dist/core/__tests__/yield-ensures-daemon.test.js +2 -1
- package/dist/core/bootstrap.js +2 -0
- package/dist/core/broker-client/__tests__/transport-socket.test.js +51 -6
- package/dist/core/broker-client/transport-socket.d.ts +1 -0
- package/dist/core/broker-client/transport-socket.js +13 -5
- package/dist/core/canvas/__tests__/attention.test.js +29 -35
- package/dist/core/canvas/__tests__/history-transcript.test.js +6 -6
- package/dist/core/canvas/__tests__/render-remote.test.js +25 -9
- package/dist/core/canvas/attention.d.ts +2 -5
- package/dist/core/canvas/attention.js +15 -64
- package/dist/core/canvas/boot.d.ts +6 -7
- package/dist/core/canvas/boot.js +5 -9
- package/dist/core/canvas/canvas.d.ts +13 -109
- package/dist/core/canvas/canvas.js +140 -401
- package/dist/core/canvas/crons.d.ts +2 -0
- package/dist/core/canvas/crons.js +16 -15
- package/dist/core/canvas/daemon-owner.js +10 -0
- package/dist/core/canvas/db.d.ts +2 -2
- package/dist/core/canvas/db.js +2 -18
- package/dist/core/canvas/deliveries.d.ts +104 -0
- package/dist/core/canvas/deliveries.js +354 -0
- package/dist/core/canvas/executions.d.ts +29 -0
- package/dist/core/canvas/executions.js +68 -0
- package/dist/core/canvas/history.js +35 -35
- package/dist/core/canvas/index.d.ts +3 -1
- package/dist/core/canvas/index.js +3 -1
- package/dist/core/canvas/migrations.js +20 -12
- package/dist/core/canvas/nav-model.js +1 -3
- package/dist/core/canvas/nav-render.d.ts +4 -4
- package/dist/core/canvas/nav-render.js +11 -21
- package/dist/core/canvas/node-sql-migration.d.ts +3 -0
- package/dist/core/canvas/node-sql-migration.js +261 -0
- package/dist/core/canvas/paths.d.ts +2 -16
- package/dist/core/canvas/paths.js +2 -31
- package/dist/core/canvas/recovery.d.ts +76 -0
- package/dist/core/canvas/recovery.js +588 -0
- package/dist/core/canvas/remote-canvas-source.js +4 -0
- package/dist/core/canvas/render-source.d.ts +16 -27
- package/dist/core/canvas/render-source.js +16 -45
- package/dist/core/canvas/sessions.d.ts +33 -0
- package/dist/core/canvas/sessions.js +78 -0
- package/dist/core/canvas/types.d.ts +33 -33
- package/dist/core/canvas/types.js +2 -5
- package/dist/core/canvas/waits.d.ts +18 -0
- package/dist/core/canvas/waits.js +70 -0
- package/dist/core/canvas/worktrees.d.ts +40 -0
- package/dist/core/canvas/worktrees.js +93 -0
- package/dist/core/command-hooks/artifact.d.ts +6 -0
- package/dist/core/command-hooks/artifact.js +22 -6
- package/dist/core/command-hooks/discovery.d.ts +2 -0
- package/dist/core/command-hooks/discovery.js +2 -0
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
- package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
- package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
- package/dist/core/command-hooks/lifecycle-create.js +51 -0
- package/dist/core/command-hooks/schema.d.ts +2 -2
- package/dist/core/command-hooks/schema.js +5 -4
- package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
- package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
- package/dist/core/config.d.ts +17 -12
- package/dist/core/config.js +106 -155
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/feed/__tests__/inbox-migrate.test.js +27 -31
- package/dist/core/feed/feed.d.ts +0 -6
- package/dist/core/feed/feed.js +68 -56
- package/dist/core/feed/inbox.d.ts +7 -81
- package/dist/core/feed/inbox.js +46 -378
- package/dist/core/feed/messages.d.ts +58 -0
- package/dist/core/feed/messages.js +217 -0
- package/dist/core/fs-utils.d.ts +4 -0
- package/dist/core/fs-utils.js +21 -1
- package/dist/core/human/answer.d.ts +1 -1
- package/dist/core/human/answer.js +1 -1
- package/dist/core/human/claim.d.ts +1 -1
- package/dist/core/human/claim.js +2 -4
- package/dist/core/human/component-docs.js +1 -1
- package/dist/core/human/feedback-companion.js +4 -3
- package/dist/core/human/feedback.d.ts +5 -8
- package/dist/core/human/feedback.js +57 -85
- package/dist/core/human/page-authoring.d.ts +30 -0
- package/dist/core/human/page-authoring.js +19 -0
- package/dist/core/human/requests.d.ts +136 -0
- package/dist/core/human/requests.js +515 -0
- package/dist/core/human/root.d.ts +0 -7
- package/dist/core/human/root.js +0 -17
- package/dist/core/human/scan.d.ts +2 -7
- package/dist/core/human/scan.js +48 -132
- package/dist/core/human/summary.d.ts +1 -1
- package/dist/core/human/summary.js +1 -1
- package/dist/core/human/tickets.d.ts +23 -83
- package/dist/core/human/tickets.js +92 -243
- package/dist/core/human/types.d.ts +5 -1
- package/dist/core/installed-plugins.js +8 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +19 -17
- package/dist/core/locked-file.d.ts +6 -3
- package/dist/core/locked-file.js +36 -9
- package/dist/core/memory/extensions.js +2 -0
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/profiles/__tests__/env-store.test.js +2 -0
- package/dist/core/profiles/default-binding.d.ts +0 -4
- package/dist/core/profiles/default-binding.js +12 -58
- package/dist/core/profiles/env-store.js +8 -3
- package/dist/core/profiles/manifest.d.ts +2 -37
- package/dist/core/profiles/manifest.js +35 -161
- package/dist/core/profiles/select.js +3 -0
- package/dist/core/review/__tests__/capture-origin.test.js +13 -2
- package/dist/core/review/__tests__/comments.test.js +28 -10
- package/dist/core/review/__tests__/remap.test.js +28 -10
- package/dist/core/review/__tests__/stage-identity.test.js +17 -7
- package/dist/core/review/birth.d.ts +1 -1
- package/dist/core/review/birth.js +34 -5
- package/dist/core/review/comments.js +1 -1
- package/dist/core/review/companion.d.ts +1 -1
- package/dist/core/review/companion.js +2 -2
- package/dist/core/review/document.js +1 -1
- package/dist/core/review/project.d.ts +5 -1
- package/dist/core/review/project.js +14 -36
- package/dist/core/review/realize.d.ts +3 -6
- package/dist/core/review/realize.js +51 -168
- package/dist/core/review/remap.js +1 -1
- package/dist/core/review/stage.d.ts +3 -2
- package/dist/core/review/stage.js +2 -3
- package/dist/core/review/store.d.ts +3 -21
- package/dist/core/review/store.js +140 -186
- package/dist/core/review/types.d.ts +0 -1
- package/dist/core/runtime/__tests__/install-id-cache-scoping.test.js +9 -5
- package/dist/core/runtime/__tests__/spawn-env.test.js +8 -9
- package/dist/core/runtime/broker/auth-reload.js +9 -10
- package/dist/core/runtime/broker/daemon-ops.d.ts +25 -8
- package/dist/core/runtime/broker/daemon-ops.js +42 -9
- package/dist/core/runtime/broker/extension-abort.d.ts +3 -0
- package/dist/core/runtime/broker/extension-abort.js +11 -0
- package/dist/core/runtime/broker/fault-retry.d.ts +15 -6
- package/dist/core/runtime/broker/fault-retry.js +142 -95
- package/dist/core/runtime/broker/inbox.d.ts +4 -32
- package/dist/core/runtime/broker/inbox.js +4 -92
- package/dist/core/runtime/broker/rebind.d.ts +1 -0
- package/dist/core/runtime/broker/rebind.js +21 -12
- package/dist/core/runtime/broker-persona-guidance.d.ts +1 -1
- package/dist/core/runtime/broker-persona-guidance.js +2 -13
- package/dist/core/runtime/broker.js +28 -22
- package/dist/core/runtime/close.d.ts +3 -9
- package/dist/core/runtime/close.js +16 -27
- package/dist/core/runtime/fault.d.ts +3 -93
- package/dist/core/runtime/fault.js +9 -419
- package/dist/core/runtime/kickoff.d.ts +3 -6
- package/dist/core/runtime/kickoff.js +12 -18
- package/dist/core/runtime/launch.js +1 -1
- package/dist/core/runtime/lifecycle.js +40 -7
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +26 -11
- package/dist/core/runtime/placement.js +2 -2
- package/dist/core/runtime/recycle.js +24 -14
- package/dist/core/runtime/reset.js +26 -7
- package/dist/core/runtime/revive.js +226 -217
- package/dist/core/runtime/spawn.d.ts +4 -1
- package/dist/core/runtime/spawn.js +43 -5
- package/dist/core/runtime/warm-pool.d.ts +2 -2
- package/dist/core/runtime/warm-pool.js +23 -3
- package/dist/core/scoped-state/db.d.ts +7 -0
- package/dist/core/scoped-state/db.js +92 -0
- package/dist/core/scoped-state/migrate.d.ts +11 -0
- package/dist/core/scoped-state/migrate.js +198 -0
- package/dist/core/scoped-state/paths.d.ts +9 -0
- package/dist/core/scoped-state/paths.js +27 -0
- package/dist/core/scoped-state/profiles.d.ts +12 -0
- package/dist/core/scoped-state/profiles.js +62 -0
- package/dist/core/scoped-state/providers.d.ts +16 -0
- package/dist/core/scoped-state/providers.js +8 -0
- package/dist/core/scoped-state/schema.d.ts +3 -0
- package/dist/core/scoped-state/schema.js +26 -0
- package/dist/core/scoped-state/settings.d.ts +28 -0
- package/dist/core/scoped-state/settings.js +82 -0
- package/dist/core/subscription-state.d.ts +29 -48
- package/dist/core/subscription-state.js +176 -712
- package/dist/core/worktree-close.js +4 -4
- package/dist/core/worktree-mutation-async.js +2 -2
- package/dist/core/worktree-sweep.js +11 -16
- package/dist/core/worktree.js +6 -12
- package/dist/daemon/__tests__/autostart-storm.test.js +1 -39
- package/dist/daemon/__tests__/helpers/source-daemon.d.ts +2 -2
- package/dist/daemon/__tests__/helpers/source-daemon.js +7 -6
- package/dist/daemon/__tests__/integration/migration-startup.test.js +59 -26
- package/dist/daemon/__tests__/node-deadline-lane.test.js +2 -1
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +41 -17
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +39 -10
- package/dist/daemon/__tests__/reconciler-signature.test.js +2 -2
- package/dist/daemon/__tests__/subtree-filter-freshness.test.js +2 -2
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +17 -12
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +27 -5
- package/dist/daemon/api/__tests__/model-config.test.js +8 -8
- package/dist/daemon/api/__tests__/node-create-description.test.js +22 -5
- package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -1
- package/dist/daemon/api/__tests__/nodes-activity-query.test.js +10 -12
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +6 -3
- package/dist/daemon/api/__tests__/profile-metadata-route.test.js +4 -1
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +26 -8
- package/dist/daemon/api/__tests__/seam/api-server.test.js +24 -32
- package/dist/daemon/api/__tests__/seam/b10-attach-modelauth.test.js +4 -1
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +5 -4
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +29 -43
- package/dist/daemon/api/__tests__/submit-result.test.js +3 -2
- package/dist/daemon/api/bridge.js +1 -1
- package/dist/daemon/api/handlers/bash-jobs.js +6 -7
- package/dist/daemon/api/handlers/broker-ops.js +47 -84
- package/dist/daemon/api/handlers/broker-recovery.d.ts +4 -0
- package/dist/daemon/api/handlers/broker-recovery.js +251 -0
- package/dist/daemon/api/handlers/canvas.js +2 -44
- package/dist/daemon/api/handlers/daemon.js +41 -0
- package/dist/daemon/api/handlers/feedback-comments.js +2 -2
- package/dist/daemon/api/handlers/health.js +10 -5
- package/dist/daemon/api/handlers/human-requests.js +168 -188
- package/dist/daemon/api/handlers/human.js +3 -148
- package/dist/daemon/api/handlers/inbox-page.d.ts +4 -0
- package/dist/daemon/api/handlers/inbox-page.js +89 -0
- package/dist/daemon/api/handlers/inbox.js +21 -120
- package/dist/daemon/api/handlers/messages.d.ts +1 -0
- package/dist/daemon/api/handlers/messages.js +73 -15
- package/dist/daemon/api/handlers/node-outcomes.js +58 -13
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +41 -42
- package/dist/daemon/api/handlers/reviews.js +4 -29
- package/dist/daemon/api/map.d.ts +25 -3
- package/dist/daemon/api/map.js +84 -14
- package/dist/daemon/api/server.js +68 -9
- package/dist/daemon/control.d.ts +18 -0
- package/dist/daemon/cron/disposition.d.ts +2 -0
- package/dist/daemon/cron/disposition.js +8 -5
- package/dist/daemon/cron/settle.js +37 -13
- package/dist/daemon/cron/sinks.d.ts +18 -1
- package/dist/daemon/cron/sinks.js +47 -93
- package/dist/daemon/crtrd-cli.js +4 -19
- package/dist/daemon/crtrd.d.ts +2 -0
- package/dist/daemon/crtrd.js +206 -81
- package/dist/daemon/fleet.js +5 -6
- package/dist/daemon/human/finish.d.ts +12 -24
- package/dist/daemon/human/finish.js +88 -285
- package/dist/daemon/human/settle.d.ts +40 -0
- package/dist/daemon/human/settle.js +89 -0
- package/dist/daemon/human/sweep.d.ts +20 -5
- package/dist/daemon/human/sweep.js +56 -60
- package/dist/daemon/manage.d.ts +21 -16
- package/dist/daemon/manage.js +85 -51
- package/dist/daemon/messaging/node-message.d.ts +1 -1
- package/dist/daemon/messaging/node-message.js +9 -12
- package/dist/daemon/park-activity.js +3 -3
- package/dist/daemon/reconcilers/bash-deadline.js +6 -7
- package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -1
- package/dist/daemon/reconcilers/broker-supervision.js +41 -19
- package/dist/daemon/reconcilers/controller-death.js +6 -18
- package/dist/daemon/reconcilers/delivery-lane.d.ts +9 -0
- package/dist/daemon/reconcilers/delivery-lane.js +130 -0
- package/dist/daemon/reconcilers/live-obligation.js +3 -3
- package/dist/daemon/reconcilers/node-deadline.js +1 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +1 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +10 -12
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +5 -5
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +1 -1
- package/dist/daemon/reconcilers/pending-review-submit.js +3 -3
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -8
- package/dist/daemon/reconcilers/storage-maintenance.js +13 -43
- package/dist/daemon/review/companion.js +5 -11
- package/dist/daemon/review/deliver.d.ts +1 -5
- package/dist/daemon/review/deliver.js +3 -52
- package/dist/daemon/review/finish.d.ts +2 -27
- package/dist/daemon/review/finish.js +79 -263
- package/dist/daemon/review/sweep.d.ts +3 -10
- package/dist/daemon/review/sweep.js +13 -131
- package/dist/daemon/startup-policy.d.ts +0 -5
- package/dist/daemon/startup-policy.js +0 -5
- package/dist/hook-authoring.d.ts +28 -2
- package/dist/hook-authoring.js +53 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/migrations/__tests__/activation-concurrency.test.js +3 -2
- package/dist/migrations/__tests__/activation.test.js +9 -6
- package/dist/migrations/activation.d.ts +3 -2
- package/dist/migrations/activation.js +2 -4
- package/dist/migrations/corpus.d.ts +2 -5
- package/dist/migrations/corpus.js +9 -20
- package/dist/migrations/runner.d.ts +3 -1
- package/dist/migrations/runner.js +6 -2
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +21 -89
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +4 -2
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +51 -33
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +16 -2
- package/dist/pi-extensions/__tests__/pre-command-gate.test.js +13 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +98 -244
- package/dist/pi-extensions/canvas-passive-context.d.ts +4 -8
- package/dist/pi-extensions/canvas-passive-context.js +85 -37
- package/dist/pi-extensions/canvas-stophook.js +47 -31
- package/dist/shared/env.d.ts +1 -0
- package/dist/shared/env.js +3 -0
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
- package/dist/clients/attach/__tests__/page-block.test.js +0 -54
- package/dist/clients/inbox/review-visibility.d.ts +0 -5
- package/dist/clients/inbox/review-visibility.js +0 -19
- package/dist/commands/canvas-rebuild-index.d.ts +0 -2
- package/dist/commands/canvas-rebuild-index.js +0 -46
- package/dist/core/__tests__/human-cancel-guard.test.js +0 -110
- package/dist/core/__tests__/human-deliver.test.d.ts +0 -1
- package/dist/core/__tests__/human-deliver.test.js +0 -256
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +0 -1
- package/dist/core/__tests__/human-node-not-supervised.test.js +0 -89
- package/dist/core/__tests__/integration/human-deliver-e2e.test.d.ts +0 -1
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +0 -131
- package/dist/core/__tests__/phase4-review-store.test.d.ts +0 -1
- package/dist/core/__tests__/phase4-review-store.test.js +0 -144
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.d.ts +0 -1
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +0 -110
- package/dist/core/canvas/human-deliveries.d.ts +0 -39
- package/dist/core/canvas/human-deliveries.js +0 -75
- package/dist/core/canvas/node-outcome-deliveries.d.ts +0 -64
- package/dist/core/canvas/node-outcome-deliveries.js +0 -159
- package/dist/core/feed/passive.d.ts +0 -3
- package/dist/core/feed/passive.js +0 -53
- package/dist/core/human/__tests__/inbox-cancel-worker.d.ts +0 -1
- package/dist/core/human/__tests__/inbox-cancel-worker.js +0 -7
- package/dist/core/human/__tests__/inbox-claim-worker.d.ts +0 -1
- package/dist/core/human/__tests__/inbox-claim-worker.js +0 -17
- package/dist/core/human/__tests__/inbox-stale-claim-worker.d.ts +0 -1
- package/dist/core/human/__tests__/inbox-stale-claim-worker.js +0 -8
- package/dist/core/human/__tests__/integration/inbox-core.test.d.ts +0 -1
- package/dist/core/human/__tests__/integration/inbox-core.test.js +0 -145
- package/dist/core/human/__tests__/page-tickets.test.d.ts +0 -1
- package/dist/core/human/__tests__/page-tickets.test.js +0 -161
- package/dist/core/review/ticket-filter.d.ts +0 -17
- package/dist/core/review/ticket-filter.js +0 -21
- package/dist/core/runtime/broker/passive.d.ts +0 -17
- package/dist/core/runtime/broker/passive.js +0 -44
- package/dist/core/runtime/busy.d.ts +0 -46
- package/dist/core/runtime/busy.js +0 -150
- package/dist/daemon/__tests__/startup-block-marker.test.d.ts +0 -1
- package/dist/daemon/__tests__/startup-block-marker.test.js +0 -86
- package/dist/daemon/api/__tests__/review-terminal-conflict.test.d.ts +0 -1
- package/dist/daemon/api/__tests__/review-terminal-conflict.test.js +0 -98
- package/dist/daemon/human/deliver-action.d.ts +0 -3
- package/dist/daemon/human/deliver-action.js +0 -9
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +0 -3
- package/dist/daemon/node-outcome/deliver-outcome.js +0 -9
- package/dist/daemon/reconcilers/human-delivery-lane.d.ts +0 -10
- package/dist/daemon/reconcilers/human-delivery-lane.js +0 -41
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +0 -10
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +0 -41
- package/dist/daemon/startup-block-marker.d.ts +0 -25
- package/dist/daemon/startup-block-marker.js +0 -99
- /package/dist/{clients/attach/__tests__/page-block.test.d.ts → api/dto/mail.js} +0 -0
- /package/dist/{commands/sys/__tests__/migrate.test.d.ts → api/dto/recovery.js} +0 -0
- /package/dist/{core/__tests__/human-cancel-guard.test.d.ts → commands/__tests__/integration/migrate.test.d.ts} +0 -0
|
@@ -1,729 +1,193 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
2
|
import { join } from 'node:path';
|
|
5
|
-
import { atomicWriteJson } from './fs-utils.js';
|
|
6
3
|
import { LockedJsonFile } from './locked-file.js';
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
import { piAgentDir } from './runtime/package-health.js';
|
|
5
|
+
import { openScopedStore } from './scoped-state/db.js';
|
|
6
|
+
import { providerStore } from './scoped-state/paths.js';
|
|
7
|
+
import { providerRowByReference, providerRows, providerWrite } from './scoped-state/providers.js';
|
|
8
|
+
export const ANTHROPIC_PROVIDER_ID = 'anthropic';
|
|
9
|
+
export const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
|
|
9
10
|
export const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
11
|
+
const DEFAULT_FALLBACK_STRENGTH = 'strong';
|
|
12
|
+
const PROVIDERS = { anthropic: { label: 'Claude', credentials: 'anthropic-credentials.json' }, 'openai-codex': { label: 'OpenAI Codex', credentials: 'openai-codex-credentials.json' } };
|
|
13
|
+
function agentDir() { return piAgentDir(); }
|
|
14
|
+
function credentialFile(provider) { return join(agentDir(), PROVIDERS[provider].credentials); }
|
|
15
|
+
function normalizeStrength(value) { return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light' ? value : undefined; }
|
|
16
|
+
function key(label) { return label.toLowerCase(); }
|
|
17
|
+
function credential(input) { const label = typeof input.label === 'string' ? input.label.trim() : ''; const refresh = typeof input.refresh === 'string' ? input.refresh.trim() : ''; const access = typeof input.access === 'string' ? input.access.trim() : ''; const expires = Number(input.expires); if (!label || !refresh || !access || !Number.isFinite(expires))
|
|
18
|
+
return undefined; const accountId = typeof input.accountId === 'string' && input.accountId.trim() ? input.accountId.trim() : undefined; return { label, refresh, access, expires, rateLimitedUntil: Number.isFinite(Number(input.rateLimitedUntil)) ? Number(input.rateLimitedUntil) : 0, lastAttemptAt: Number.isFinite(Number(input.lastAttemptAt)) ? Number(input.lastAttemptAt) : 0, lastRateLimitedAt: Number.isFinite(Number(input.lastRateLimitedAt)) ? Number(input.lastRateLimitedAt) : 0, ...(input.authFailure === 'invalid_grant' ? { authFailure: 'invalid_grant' } : {}), ...(accountId ? { accountId } : {}) }; }
|
|
19
|
+
function assertCredential(provider, entry) { if (!entry.label?.trim())
|
|
20
|
+
throw new Error(`${getProviderLabel(provider)} subscription label cannot be empty`); const result = credential(entry); if (!result)
|
|
21
|
+
throw new Error(`Refusing to persist incomplete ${getProviderLabel(provider)} subscription "${entry.label.trim()}"; pool rows require both OAuth tokens.`); return result; }
|
|
22
|
+
function parseCredentials(path, current) { if (!current || current.trim() === '')
|
|
23
|
+
return { version: 1, accounts: {} }; let parsed; try {
|
|
24
|
+
parsed = JSON.parse(current);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new Error(`managed credential file ${path} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
|
|
28
|
+
} if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || parsed.version !== 1 || typeof parsed.accounts !== 'object' || parsed.accounts === null || Array.isArray(parsed.accounts))
|
|
29
|
+
throw new Error(`managed credential file ${path} is corrupt`); return parsed; }
|
|
30
|
+
function toPublic(row, file) { const stored = file.accounts[row.id]; if (!stored || typeof stored.refresh !== 'string' || typeof stored.access !== 'string' || !Number.isFinite(stored.expires))
|
|
31
|
+
throw new Error(`managed ${row.providerId} account ${row.id} has no credential record`); return { label: row.label, refresh: stored.refresh, access: stored.access, expires: stored.expires, rateLimitedUntil: row.rateLimitedUntil, lastAttemptAt: row.lastAttemptAt, lastRateLimitedAt: row.lastRateLimitedAt, ...(row.authFailure ? { authFailure: row.authFailure } : {}), ...(stored.accountId ? { accountId: stored.accountId } : {}) }; }
|
|
32
|
+
function rowsWithCredentials(provider, file) { return providerRows(agentDir(), provider).map((row) => ({ row, entry: toPublic(row, file) })); }
|
|
33
|
+
function managedCredentialFile(provider) { return new LockedJsonFile(credentialFile(provider), true, '{"version":1,"accounts":{}}\n'); }
|
|
34
|
+
function readFile(provider) { return parseCredentials(credentialFile(provider), managedCredentialFile(provider).readUnlocked()); }
|
|
35
|
+
function identity(entry) { return entry.accountId?.trim() || (entry.refresh.trim() ? `refresh:${createHash('sha256').update(entry.refresh.trim()).digest('hex')}` : undefined); }
|
|
36
|
+
function assertTokens(provider, label, value) { const refresh = value.refresh?.trim() ?? ''; const access = value.access?.trim() ?? ''; const expires = Number(value.expires); if (!refresh || !access || !Number.isFinite(expires))
|
|
37
|
+
throw new Error(`${getProviderLabel(provider)} subscription "${label}" returned an incomplete credential; the stored credential was left unchanged.`); return { refresh, access, expires }; }
|
|
38
|
+
export function isManagedProvider(providerId) { return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID; }
|
|
39
|
+
export function getProviderLabel(providerId) { return PROVIDERS[providerId].label; }
|
|
40
|
+
export function resolveProviderCandidates(candidates, now = Date.now(), options = {}) { const viable = []; const rejected = []; for (let i = 0; i < candidates.length; i++) {
|
|
41
|
+
const candidate = candidates[i];
|
|
42
|
+
const reason = !candidate.registered ? 'unregistered' : candidate.automaticFallback === false ? 'non-managed-fallback' : !candidate.authenticated ? 'unauthenticated' : (candidate.coolingUntil ?? 0) > now ? 'cooling' : undefined;
|
|
43
|
+
if (!reason)
|
|
44
|
+
viable.push(candidate);
|
|
45
|
+
else {
|
|
40
46
|
rejected.push({ candidate, reason });
|
|
41
|
-
|
|
42
|
-
// — unless the pin is merely cooling, a transient managed cooldown that still
|
|
43
|
-
// prefers a viable fallback over stalling.
|
|
44
|
-
if (pinnedToFirst && i === 0 && reason !== "cooling")
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
return { candidates: viable, rejected };
|
|
48
|
-
}
|
|
49
|
-
// pi's agent dir, resolved PER ACCESS. Deliberately mirrors pi's own `getAgentDir()`
|
|
50
|
-
// (PI_CODING_AGENT_DIR, tilde-expanded, else ~/.pi/agent) rather than importing it: a
|
|
51
|
-
// value import from @earendil-works/pi-coding-agent would drag the whole pi SDK into
|
|
52
|
-
// every CLI leaf that touches subscriptions. Resolving per access (never once at module
|
|
53
|
-
// load) is what makes PI_CODING_AGENT_DIR and a per-process HOME actually take effect.
|
|
54
|
-
function agentDir() {
|
|
55
|
-
const configured = process.env["PI_CODING_AGENT_DIR"];
|
|
56
|
-
if (configured) {
|
|
57
|
-
return configured === "~" || configured.startsWith("~/") ? join(homedir(), configured.slice(1)) : configured;
|
|
58
|
-
}
|
|
59
|
-
return join(homedir(), ".pi", "agent");
|
|
60
|
-
}
|
|
61
|
-
function rotationConfigFile() {
|
|
62
|
-
return join(agentDir(), "provider-rotation.json");
|
|
63
|
-
}
|
|
64
|
-
const DEFAULT_FALLBACK_STRENGTH = "strong";
|
|
65
|
-
const PROVIDERS = {
|
|
66
|
-
[ANTHROPIC_PROVIDER_ID]: {
|
|
67
|
-
label: "Claude",
|
|
68
|
-
poolFileName: "anthropic-subscriptions.json",
|
|
69
|
-
},
|
|
70
|
-
[OPENAI_CODEX_PROVIDER_ID]: {
|
|
71
|
-
label: "OpenAI Codex",
|
|
72
|
-
poolFileName: "openai-codex-subscriptions.json",
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
/** The pool file backing one managed provider's accounts, in the CURRENT agent dir. */
|
|
76
|
-
function poolFileFor(providerId) {
|
|
77
|
-
return join(agentDir(), PROVIDERS[providerId].poolFileName);
|
|
78
|
-
}
|
|
79
|
-
function readJsonFile(path) {
|
|
80
|
-
if (!existsSync(path))
|
|
81
|
-
return undefined;
|
|
82
|
-
try {
|
|
83
|
-
const raw = readFileSync(path, "utf8").trim();
|
|
84
|
-
if (!raw)
|
|
85
|
-
return undefined;
|
|
86
|
-
return JSON.parse(raw);
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
return undefined;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* The pool file is read strictly: an absent or empty file represents no accounts, while a
|
|
94
|
-
* present file that cannot be read or parsed is an operational error. Treating the latter as
|
|
95
|
-
* an empty pool would incorrectly report an authenticated provider as unconfigured.
|
|
96
|
-
*/
|
|
97
|
-
function readPoolFile(path) {
|
|
98
|
-
if (!existsSync(path))
|
|
99
|
-
return undefined;
|
|
100
|
-
try {
|
|
101
|
-
const raw = readFileSync(path, "utf8").trim();
|
|
102
|
-
if (!raw)
|
|
103
|
-
return undefined;
|
|
104
|
-
return JSON.parse(raw);
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
throw new Error(`Subscription pool ${path} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* The pool contents a locked transaction is about to act on. An empty file is genuinely no
|
|
112
|
-
* accounts; a non-empty file that will not parse is CORRUPT, and treating it as an empty pool
|
|
113
|
-
* is what let `modifyElectedSubscription` answer pi's refresh with `undefined` -- the exact
|
|
114
|
-
* shape pi reports as `Provider is not configured: <id>`, a fatal fault no agent can clear
|
|
115
|
-
* from inside its own turn. Raise instead, so the failure names itself.
|
|
116
|
-
*/
|
|
117
|
-
function parsePoolUnderLock(path, current) {
|
|
118
|
-
if (!current || current.trim() === "")
|
|
119
|
-
return undefined;
|
|
120
|
-
try {
|
|
121
|
-
return JSON.parse(current);
|
|
122
|
-
}
|
|
123
|
-
catch (error) {
|
|
124
|
-
throw new Error(`Subscription pool ${path} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
function normalizeStrength(value) {
|
|
128
|
-
return value === "ultra" || value === "strong" || value === "medium" || value === "light" ? value : undefined;
|
|
129
|
-
}
|
|
130
|
-
function normalizeTiming(value) {
|
|
131
|
-
const numeric = Number(value);
|
|
132
|
-
return Number.isFinite(numeric) ? numeric : 0;
|
|
133
|
-
}
|
|
134
|
-
// A pool row IS a credential: an account whose OAuth token pair is missing cannot service a
|
|
135
|
-
// turn and cannot be refreshed, so it is not a lesser row, it is not a row at all. Normalization
|
|
136
|
-
// drops it here, which is what lets every consumer treat pool occupancy as authentication.
|
|
137
|
-
function normalizeCredential(value) {
|
|
138
|
-
const label = typeof value.label === "string" ? value.label.trim() : "";
|
|
139
|
-
if (!label)
|
|
140
|
-
return undefined;
|
|
141
|
-
const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
|
|
142
|
-
const access = typeof value.access === "string" ? value.access.trim() : "";
|
|
143
|
-
if (!refresh || !access)
|
|
144
|
-
return undefined;
|
|
145
|
-
const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
|
|
146
|
-
const expires = Number(value.expires);
|
|
147
|
-
const rateLimitedUntil = Number(value.rateLimitedUntil);
|
|
148
|
-
return {
|
|
149
|
-
label,
|
|
150
|
-
refresh,
|
|
151
|
-
access,
|
|
152
|
-
expires: Number.isFinite(expires) ? expires : 0,
|
|
153
|
-
...(accountId ? { accountId } : {}),
|
|
154
|
-
rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
|
|
155
|
-
...(value.authFailure === "invalid_grant" ? { authFailure: "invalid_grant" } : {}),
|
|
156
|
-
lastAttemptAt: normalizeTiming(value.lastAttemptAt),
|
|
157
|
-
lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
function normalizeCredentials(values) {
|
|
161
|
-
if (!Array.isArray(values))
|
|
162
|
-
return [];
|
|
163
|
-
return values.map((entry) => normalizeCredential(entry)).filter((entry) => Boolean(entry));
|
|
164
|
-
}
|
|
165
|
-
// Announced only where the drop is about to be PERSISTED. Normalization also runs on the
|
|
166
|
-
// lock-free read path, which discards nothing from storage, so tracing inside it reports a
|
|
167
|
-
// loss that has not happened and repeats on every read. The account-set log compares
|
|
168
|
-
// normalized state on both sides and so can never see this row; say it here instead.
|
|
169
|
-
function traceDroppedRows(providerId, stored, normalized) {
|
|
170
|
-
if (!Array.isArray(stored) || stored.length === normalized.length)
|
|
171
|
-
return;
|
|
172
|
-
process.stderr.write(`[subscription-pool] ${getProviderLabel(providerId)}: dropping ${stored.length - normalized.length} stored row(s) with no label or no token pair\n`);
|
|
173
|
-
}
|
|
174
|
-
// A refreshed/modified token set is only storable when it carries BOTH halves and a usable
|
|
175
|
-
// expiry. Persisting a partial one is silently destructive: `JSON.stringify` drops the missing
|
|
176
|
-
// key, and `normalizeCredential` (which requires refresh AND access together) then discards the
|
|
177
|
-
// whole row on the very next read — silently logging that account out until the user logs in
|
|
178
|
-
// again, at which point the next refresh repeats it. Providers do not all validate their own refresh response
|
|
179
|
-
// (pi's Anthropic flow forwards `data.refresh_token` verbatim, absent or not), so the storage
|
|
180
|
-
// layer refuses the write instead: the previous credential stays intact and the caller sees a
|
|
181
|
-
// real error it can classify (cooldown -> reauth -> fallback).
|
|
182
|
-
function assertStorableTokens(providerId, label, value) {
|
|
183
|
-
const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
|
|
184
|
-
const access = typeof value.access === "string" ? value.access.trim() : "";
|
|
185
|
-
const expires = Number(value.expires);
|
|
186
|
-
if (!refresh || !access || !Number.isFinite(expires)) {
|
|
187
|
-
const missing = [!refresh ? "refresh token" : undefined, !access ? "access token" : undefined, Number.isFinite(expires) ? undefined : "expiry"]
|
|
188
|
-
.filter((part) => part !== undefined)
|
|
189
|
-
.join(", ");
|
|
190
|
-
throw new Error(`${getProviderLabel(providerId)} subscription "${label}" returned an incomplete credential (missing ${missing}); the stored credential was left unchanged.`);
|
|
191
|
-
}
|
|
192
|
-
return { refresh, access, expires };
|
|
193
|
-
}
|
|
194
|
-
// Every candidate row must survive normalization before it can reach disk. Existing on-disk
|
|
195
|
-
// rows remain tolerant of malformed historical data during reads, but a new transform cannot
|
|
196
|
-
// acknowledge a row that persistence would silently discard.
|
|
197
|
-
function assertCandidateRowsStorable(providerId, candidate) {
|
|
198
|
-
for (const entry of candidate) {
|
|
199
|
-
if (normalizeCredential(entry) !== undefined)
|
|
200
|
-
continue;
|
|
201
|
-
const label = typeof entry.label === "string" && entry.label.trim() ? ` "${entry.label.trim()}"` : "";
|
|
202
|
-
throw new Error(`Refusing to persist incomplete ${getProviderLabel(providerId)} subscription${label}; pool rows require a label and both OAuth tokens.`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// The ONE door every write to a pool file goes through. Both pool invariants -- one account
|
|
206
|
-
// identity per pool, and every candidate row carrying a storable credential -- are properties
|
|
207
|
-
// of the FILE, not of whichever code path happens to be writing, so they are asserted here
|
|
208
|
-
// against the state this write replaces, under the caller's already-held lock. `atomicWriteJson`
|
|
209
|
-
// is reached for the pool NOWHERE ELSE in this file: that makes the invariants unbypassable.
|
|
210
|
-
function persistPool(providerId, poolFile, base, candidate) {
|
|
211
|
-
assertCandidateRowsStorable(providerId, candidate);
|
|
212
|
-
const next = normalizeCredentials(candidate);
|
|
213
|
-
logCredentialSetChange(providerId, base, next);
|
|
214
|
-
atomicWriteJson(poolFile, next, { mode: 0o600 });
|
|
215
|
-
return next;
|
|
216
|
-
}
|
|
217
|
-
// Accounts vanishing from a pool is the failure users actually feel ("I keep having to log in
|
|
218
|
-
// again"), and a shrinking file says nothing about which process shrank it. Every write that
|
|
219
|
-
// CHANGES the set of accounts announces itself on stderr -- which lands in the broker log next to the rotation lines -- with the pid
|
|
220
|
-
// and argv of whoever did it. Unchanged sets (the constant cooldown/timestamp bookkeeping) stay
|
|
221
|
-
// silent, so this is a rare, high-signal line, not a log firehose.
|
|
222
|
-
function logCredentialSetChange(providerId, base, next) {
|
|
223
|
-
const labels = (pool) => pool.map((entry) => entry.label).sort();
|
|
224
|
-
const before = labels(base);
|
|
225
|
-
const after = labels(next);
|
|
226
|
-
if (before.length === after.length && before.every((label, index) => label === after[index]))
|
|
227
|
-
return;
|
|
228
|
-
const who = `pid ${process.pid} ${process.argv.slice(1).join(" ")}`;
|
|
229
|
-
process.stderr.write(`[subscription-pool] ${getProviderLabel(providerId)} accounts changed: [${before.join(", ")}] -> [${after.join(", ")}] (${who})\n`);
|
|
230
|
-
}
|
|
231
|
-
export function isManagedProvider(providerId) {
|
|
232
|
-
return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
|
|
233
|
-
}
|
|
234
|
-
export function getProviderLabel(providerId) {
|
|
235
|
-
return PROVIDERS[providerId].label;
|
|
236
|
-
}
|
|
237
|
-
// The ONE locked read/modify/write primitive for a provider pool file. It acquires a
|
|
238
|
-
// cross-process lock on the pool file, re-reads + normalizes the pool under that lock, awaits
|
|
239
|
-
// the caller's transform, and persists atomically through `persistPool` before releasing.
|
|
240
|
-
// Every pool mutation goes through this door, so causal cooldown guards compare against
|
|
241
|
-
// fresh-under-lock state and all pool invariants share one write path.
|
|
242
|
-
//
|
|
243
|
-
// `fn` returning `undefined` means "no change, skip the write" (used by timestamp guards and
|
|
244
|
-
// refreshes that find a fresh credential). `withLockAsync` seeds a missing file with `{}`;
|
|
245
|
-
// `normalizeCredentials` coerces that first-lock seed to an empty pool. Pool writes happen via
|
|
246
|
-
// the callback's atomic tmp+rename and return no backend `next`, so lock-free readers always see
|
|
247
|
-
// a whole snapshot. Throws (rather than proceeding unlocked) if lock acquisition is exhausted.
|
|
248
|
-
export async function mutateSubscriptionPool(providerId, fn) {
|
|
249
|
-
const poolFile = poolFileFor(providerId);
|
|
250
|
-
return new LockedJsonFile(poolFile).withLockAsync(async (current, assertLockHeld) => {
|
|
251
|
-
const parsed = parsePoolUnderLock(poolFile, current);
|
|
252
|
-
const base = normalizeCredentials(parsed);
|
|
253
|
-
const transformed = await fn(base, assertLockHeld);
|
|
254
|
-
if (transformed !== undefined)
|
|
255
|
-
traceDroppedRows(providerId, parsed, base);
|
|
256
|
-
if (transformed === undefined)
|
|
257
|
-
return { result: base };
|
|
258
|
-
return { result: persistPool(providerId, poolFile, base, transformed) };
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
// The token exchange for ONE account, serialized across processes on the pool file's own lock.
|
|
262
|
-
// A refresh token is single-use: without this, two brokers holding the same expired credential
|
|
263
|
-
// both call the provider, and the loser gets a genuine `invalid_grant` that bricks its turn. The
|
|
264
|
-
// whole read -> check-expiry -> exchange -> write transaction therefore happens under the lock,
|
|
265
|
-
// so a second refresher that arrives after a winner observes the rotated value and never makes
|
|
266
|
-
// its own (now doomed) call. Every account stores its value in this shared pool.
|
|
267
|
-
//
|
|
268
|
-
// The exchange is awaited while the shared pool transaction holds the lock.
|
|
269
|
-
export async function refreshSubscriptionCredential(providerId, label, exchange) {
|
|
270
|
-
const committed = await mutateSubscriptionPool(providerId, async (pool, assertLockHeld) => {
|
|
271
|
-
const index = pool.findIndex((entry) => entry.label === label);
|
|
272
|
-
if (index === -1)
|
|
273
|
-
throw new Error(`${getProviderLabel(providerId)} subscription "${label}" no longer exists`);
|
|
274
|
-
const entry = pool[index];
|
|
275
|
-
if (entry.expires > Date.now())
|
|
276
|
-
return undefined;
|
|
277
|
-
const refreshed = assertStorableTokens(providerId, label, await exchange(entry.refresh));
|
|
278
|
-
const next = [...pool];
|
|
279
|
-
next[index] = { ...entry, refresh: refreshed.refresh, access: refreshed.access, expires: refreshed.expires };
|
|
280
|
-
assertLockHeld();
|
|
281
|
-
return next;
|
|
282
|
-
});
|
|
283
|
-
const result = committed.find((entry) => entry.label === label);
|
|
284
|
-
if (result === undefined)
|
|
285
|
-
throw new Error(`${getProviderLabel(providerId)} subscription "${label}" did not survive the write`);
|
|
286
|
-
return result;
|
|
287
|
-
}
|
|
288
|
-
// The serialized read-modify-write pi's `CredentialStore.modify` contract demands. Pi's OAuth
|
|
289
|
-
// refresh runs inside `apply`, and refresh tokens are single-use, so election, exchange, and
|
|
290
|
-
// persistence remain under one pool lock. A credential whose provider rejects its refresh token
|
|
291
|
-
// is marked auth-failed before the next eligible pool account is tried; this lets pi's eager
|
|
292
|
-
// credential resolution recover through the same account pool as turn-time rotation. If every
|
|
293
|
-
// candidate fails, those auth-failure marks are persisted before the final error is rethrown.
|
|
294
|
-
//
|
|
295
|
-
// `apply` returning `undefined` means "no token change". Only the token triple is replaced on a
|
|
296
|
-
// successful refresh; account identity and rotation bookkeeping remain attached to their row.
|
|
297
|
-
export function isInvalidRefreshTokenError(error) {
|
|
298
|
-
const value = error;
|
|
299
|
-
const typedStatus = typeof value?.status === "number" ? value.status : undefined;
|
|
300
|
-
const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ""]
|
|
301
|
-
.filter((part) => typeof part === "string" && part.length > 0)
|
|
302
|
-
.join(" ");
|
|
303
|
-
const messageStatusMatch = message.match(/\bstatus[=:]\s*(\d+)/i);
|
|
304
|
-
const status = typedStatus ?? (messageStatusMatch ? Number(messageStatusMatch[1]) : Number.NaN);
|
|
305
|
-
return status === 400 && /invalid_grant|refresh token .*invalid|refresh token .*not found/i.test(message);
|
|
306
|
-
}
|
|
307
|
-
export async function modifyElectedSubscription(providerId, elect, apply) {
|
|
308
|
-
let label;
|
|
309
|
-
let pendingError;
|
|
310
|
-
const committed = await mutateSubscriptionPool(providerId, async (pool, assertLockHeld) => {
|
|
311
|
-
const next = [...pool];
|
|
312
|
-
const attempted = new Set();
|
|
313
|
-
let changed = false;
|
|
314
|
-
while (attempted.size < next.length) {
|
|
315
|
-
const elected = elect(next.filter((entry) => !attempted.has(entry.label)));
|
|
316
|
-
if (elected === undefined)
|
|
317
|
-
break;
|
|
318
|
-
const index = next.findIndex((entry) => entry.label === elected.label);
|
|
319
|
-
if (index === -1)
|
|
320
|
-
break;
|
|
321
|
-
attempted.add(next[index].label);
|
|
322
|
-
let applied;
|
|
323
|
-
try {
|
|
324
|
-
applied = await apply(next[index]);
|
|
325
|
-
}
|
|
326
|
-
catch (error) {
|
|
327
|
-
if (!isInvalidRefreshTokenError(error)) {
|
|
328
|
-
pendingError = error;
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
const attemptAt = Date.now();
|
|
332
|
-
next[index] = {
|
|
333
|
-
...next[index],
|
|
334
|
-
authFailure: "invalid_grant",
|
|
335
|
-
lastAttemptAt: Math.max(next[index].lastAttemptAt, attemptAt),
|
|
336
|
-
rateLimitedUntil: Math.max(next[index].rateLimitedUntil, attemptAt + INVALID_REFRESH_TOKEN_BACKOFF_MS),
|
|
337
|
-
};
|
|
338
|
-
changed = true;
|
|
339
|
-
pendingError = error;
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
label = next[index].label;
|
|
343
|
-
pendingError = undefined;
|
|
344
|
-
if (applied !== undefined) {
|
|
345
|
-
const stored = assertStorableTokens(providerId, next[index].label, applied);
|
|
346
|
-
next[index] = { ...next[index], refresh: stored.refresh, access: stored.access, expires: stored.expires };
|
|
347
|
-
changed = true;
|
|
348
|
-
}
|
|
47
|
+
if (options.pinnedToFirst && i === 0 && reason !== 'cooling')
|
|
349
48
|
break;
|
|
350
|
-
}
|
|
351
|
-
if (!changed)
|
|
352
|
-
return undefined;
|
|
353
|
-
assertLockHeld();
|
|
354
|
-
return next;
|
|
355
|
-
});
|
|
356
|
-
if (pendingError !== undefined)
|
|
357
|
-
throw pendingError;
|
|
358
|
-
return label === undefined ? undefined : committed.find((entry) => entry.label === label);
|
|
359
|
-
}
|
|
360
|
-
export function readSubscriptionPool(providerId) {
|
|
361
|
-
// Reads are lock-free: whole-file tmp+rename persistence means a reader always sees a complete
|
|
362
|
-
// snapshot.
|
|
363
|
-
return normalizeCredentials(readPoolFile(poolFileFor(providerId)));
|
|
364
|
-
}
|
|
365
|
-
// Seed/replace the entire pool under the lock. Production mutation goes through the specific
|
|
366
|
-
// mutators below (each a thin transform over `mutateSubscriptionPool`); this whole-array replace
|
|
367
|
-
// exists for test seeding and is still locked, never an unlocked write surface.
|
|
368
|
-
export async function writeSubscriptionPool(providerId, next) {
|
|
369
|
-
return mutateSubscriptionPool(providerId, () => next);
|
|
370
|
-
}
|
|
371
|
-
function resolveCredentialIndex(pool, ref) {
|
|
372
|
-
const needle = ref.trim().toLowerCase();
|
|
373
|
-
const numeric = Number.parseInt(needle, 10);
|
|
374
|
-
if (Number.isFinite(numeric) && String(numeric) === needle && numeric >= 1 && numeric <= pool.length) {
|
|
375
|
-
return numeric - 1;
|
|
376
49
|
}
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
// Merge an incoming upsert onto an existing same-label entry (design §1). Credential VALUE
|
|
380
|
-
// fields always come from the incoming entry (that is the point of the upsert); timing/cooldown
|
|
381
|
-
// state is reconciled causally so a refresh/reauth upsert built from a snapshot taken before a
|
|
382
|
-
// concurrent 429 can never erase that newer cooldown. The `>=` on `lastRateLimitedAt` is
|
|
383
|
-
// deliberate: reauth clears a cooldown after a successful re-login while preserving
|
|
384
|
-
// `lastRateLimitedAt`, so its equal-timestamp write wins and clears -- but a 429 that landed
|
|
385
|
-
// AFTER the reauth snapshot has a strictly greater `lastRateLimitedAt` and survives.
|
|
386
|
-
function mergeUpsert(existing, incoming) {
|
|
387
|
-
const cooldownFromIncoming = incoming.lastRateLimitedAt >= existing.lastRateLimitedAt;
|
|
388
|
-
const accountId = incoming.accountId ?? existing.accountId;
|
|
389
|
-
return {
|
|
390
|
-
...incoming,
|
|
391
|
-
...(accountId ? { accountId } : {}),
|
|
392
|
-
lastAttemptAt: Math.max(existing.lastAttemptAt, incoming.lastAttemptAt),
|
|
393
|
-
lastRateLimitedAt: cooldownFromIncoming ? incoming.lastRateLimitedAt : existing.lastRateLimitedAt,
|
|
394
|
-
rateLimitedUntil: cooldownFromIncoming ? incoming.rateLimitedUntil : existing.rateLimitedUntil,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
export async function upsertSubscription(providerId, next) {
|
|
398
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
399
|
-
const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
|
|
400
|
-
if (index === -1)
|
|
401
|
-
return [...pool, next];
|
|
402
|
-
const merged = [...pool];
|
|
403
|
-
merged[index] = mergeUpsert(pool[index], next);
|
|
404
|
-
return merged;
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
// Thrown from inside the locked add transform when a label or Codex account already exists. A
|
|
408
|
-
// typed error so the /provider-sub handler surfaces a precise, user-facing message while the
|
|
409
|
-
// uniqueness decision itself stays atomic under the pool lock.
|
|
50
|
+
} return { candidates: viable, rejected }; }
|
|
410
51
|
export class DuplicateSubscriptionError extends Error {
|
|
411
|
-
constructor(message) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
// Atomically append a new subscription, enforcing label and account uniqueness inside the pool
|
|
447
|
-
// lock. The long OAuth flow stays outside the lock; only this final validate-then-append is
|
|
448
|
-
// serialized, so two brokers that both finish OAuth for the same account cannot add two labels.
|
|
449
|
-
export async function addSubscription(providerId, entry) {
|
|
450
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
451
|
-
// Normalize the candidate label BEFORE the locked uniqueness check: `normalizeCredentials`
|
|
452
|
-
// trims on persist, so a raw " work " that passed an untrimmed check would land as a
|
|
453
|
-
// duplicate "work". Reject an empty/whitespace label here too -- the storage primitive
|
|
454
|
-
// stays defensive regardless of what the handler validated.
|
|
455
|
-
const label = entry.label.trim();
|
|
456
|
-
if (!label)
|
|
457
|
-
throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
|
|
458
|
-
if (pool.some((existing) => existing.label.toLowerCase() === label.toLowerCase())) {
|
|
459
|
-
throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
|
|
460
|
-
}
|
|
461
|
-
assertAccountAvailable(pool, providerId, accountIdentity(entry), label);
|
|
462
|
-
return [...pool, { ...entry, label }];
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
// Identity-checked upsert for reauthentication. Same merge semantics as `upsertSubscription`
|
|
466
|
-
// (append-or-`mergeUpsert`, preserving cooldown causality), but gated by `assertAccountAvailable`
|
|
467
|
-
// first: reauthenticating a row into an account held by another row throws instead of creating a
|
|
468
|
-
// duplicate quota entry. `next.label` is excluded so reauth into its own row passes.
|
|
469
|
-
export async function upsertSubscriptionWithUniqueAccount(providerId, next) {
|
|
470
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
471
|
-
assertAccountAvailable(pool, providerId, accountIdentity(next), next.label);
|
|
472
|
-
const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
|
|
473
|
-
if (index === -1)
|
|
474
|
-
return [...pool, next];
|
|
475
|
-
const merged = [...pool];
|
|
476
|
-
const value = mergeUpsert(pool[index], next);
|
|
477
|
-
// A reauthentication without a stable identity must not attach the old account's identity to
|
|
478
|
-
// newly issued tokens. Refreshes use `modifyElectedSubscription`, which preserves identity.
|
|
479
|
-
if (next.accountId === undefined) {
|
|
480
|
-
const { accountId: _accountId, ...withoutAccountId } = value;
|
|
481
|
-
merged[index] = withoutAccountId;
|
|
482
|
-
}
|
|
483
|
-
else {
|
|
484
|
-
merged[index] = value;
|
|
485
|
-
}
|
|
486
|
-
return merged;
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
export async function renameSubscription(providerId, ref, nextLabel) {
|
|
490
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
491
|
-
const index = resolveCredentialIndex(pool, ref);
|
|
492
|
-
if (index === -1)
|
|
493
|
-
throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
494
|
-
const label = nextLabel.trim();
|
|
495
|
-
if (!label)
|
|
496
|
-
throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
|
|
497
|
-
if (pool.some((entry, entryIndex) => entryIndex !== index && entry.label.toLowerCase() === label.toLowerCase())) {
|
|
498
|
-
throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
|
|
52
|
+
constructor(message) { super(message); this.name = 'DuplicateSubscriptionError'; }
|
|
53
|
+
}
|
|
54
|
+
export function readSubscriptionPool(provider) { return rowsWithCredentials(provider, readFile(provider)).map(({ entry }) => entry); }
|
|
55
|
+
async function locked(provider, run) { const path = credentialFile(provider); return managedCredentialFile(provider).withLockAsync(async (current, _held, publish) => { const file = parseCredentials(path, current); return { result: await run(file, rowsWithCredentials(provider, file), (next) => publish(`${JSON.stringify(next)}\n`)) }; }); }
|
|
56
|
+
function insert(db, provider, row) { db.prepare('INSERT INTO managed_accounts(provider_id,id,label,label_key,position,last_attempt_at,last_rate_limited_at,rate_limited_until,auth_failure) VALUES(?,?,?,?,?,?,?,?,?)').run(provider, row.id, row.label, row.labelKey, row.position, row.lastAttemptAt, row.lastRateLimitedAt, row.rateLimitedUntil, row.authFailure ?? null); }
|
|
57
|
+
function update(db, row) { db.prepare('UPDATE managed_accounts SET label=?,label_key=?,position=?,last_attempt_at=?,last_rate_limited_at=?,rate_limited_until=?,auth_failure=? WHERE provider_id=? AND id=?').run(row.label, row.labelKey, row.position, row.lastAttemptAt, row.lastRateLimitedAt, row.rateLimitedUntil, row.authFailure ?? null, row.providerId, row.id); }
|
|
58
|
+
function publicPool(provider, file) { return rowsWithCredentials(provider, file).map(({ entry }) => entry); }
|
|
59
|
+
function writeMembershipAfterCredentialPublish(provider, write) { try {
|
|
60
|
+
write();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`managed ${getProviderLabel(provider)} credential file was published, but its SQL membership update failed; SQL membership remains authoritative: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
64
|
+
} }
|
|
65
|
+
export async function writeSubscriptionPool(provider, next) { return locked(provider, async (file, rows, publish) => { const normalized = next.map((entry) => assertCredential(provider, entry)); const ids = normalized.map((entry, index) => ({ entry, id: rows[index]?.row.id ?? randomUUID() })); const accounts = {}; for (const { entry, id } of ids)
|
|
66
|
+
accounts[id] = { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) }; publish({ version: 1, accounts }); writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => { db.prepare('DELETE FROM managed_accounts WHERE provider_id=?').run(provider); ids.forEach(({ entry, id }, position) => insert(db, provider, { providerId: provider, id, label: entry.label, labelKey: key(entry.label), position, lastAttemptAt: entry.lastAttemptAt, lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil, ...(entry.authFailure ? { authFailure: entry.authFailure } : {}) })); })); return normalized; }); }
|
|
67
|
+
function resolve(rows, ref) { const needle = ref.trim().toLowerCase(); const number = Number.parseInt(needle, 10); if (Number.isFinite(number) && String(number) === needle && number >= 1 && number <= rows.length)
|
|
68
|
+
return number - 1; return rows.findIndex(({ entry }) => key(entry.label) === needle); }
|
|
69
|
+
export async function addSubscription(provider, entry) { const checked = assertCredential(provider, entry); return locked(provider, async (file, rows, publish) => { if (rows.some(({ entry: existing }) => key(existing.label) === key(checked.label)))
|
|
70
|
+
throw new DuplicateSubscriptionError(`${getProviderLabel(provider)} subscription already exists: ${checked.label}`); const same = identity(checked); const collision = rows.find(({ entry: existing }) => identity(existing) === same); if (same && collision)
|
|
71
|
+
throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${collision.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`); const id = randomUUID(); file.accounts[id] = { refresh: checked.refresh, access: checked.access, expires: checked.expires, ...(checked.accountId ? { accountId: checked.accountId } : {}) }; publish(file); writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => insert(db, provider, { providerId: provider, id, label: checked.label, labelKey: key(checked.label), position: rows.length, lastAttemptAt: checked.lastAttemptAt, lastRateLimitedAt: checked.lastRateLimitedAt, rateLimitedUntil: checked.rateLimitedUntil, ...(checked.authFailure ? { authFailure: checked.authFailure } : {}) }))); return publicPool(provider, file); }); }
|
|
72
|
+
export async function upsertSubscription(provider, entry) { const checked = assertCredential(provider, entry); const existing = readSubscriptionPool(provider).find((x) => key(x.label) === key(checked.label)); return existing ? upsertSubscriptionWithUniqueAccount(provider, checked) : addSubscription(provider, checked); }
|
|
73
|
+
export async function upsertSubscriptionWithUniqueAccount(provider, entry) {
|
|
74
|
+
const checked = assertCredential(provider, entry);
|
|
75
|
+
return locked(provider, async (file, rows, publish) => {
|
|
76
|
+
const index = rows.findIndex(({ entry: existing }) => key(existing.label) === key(checked.label));
|
|
77
|
+
const existing = rows[index];
|
|
78
|
+
const duplicate = rows.find(({ entry: other }, i) => i !== index && identity(other) === identity(checked));
|
|
79
|
+
if (identity(checked) && duplicate)
|
|
80
|
+
throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${duplicate.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`);
|
|
81
|
+
if (!existing) {
|
|
82
|
+
const id = randomUUID();
|
|
83
|
+
file.accounts[id] = { refresh: checked.refresh, access: checked.access, expires: checked.expires, ...(checked.accountId ? { accountId: checked.accountId } : {}) };
|
|
84
|
+
publish(file);
|
|
85
|
+
writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => insert(db, provider, { providerId: provider, id, label: checked.label, labelKey: key(checked.label), position: rows.length, lastAttemptAt: checked.lastAttemptAt, lastRateLimitedAt: checked.lastRateLimitedAt, rateLimitedUntil: checked.rateLimitedUntil })));
|
|
86
|
+
return publicPool(provider, file);
|
|
499
87
|
}
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
88
|
+
const row = existing.row;
|
|
89
|
+
const cooldownIncoming = checked.lastRateLimitedAt >= row.lastRateLimitedAt;
|
|
90
|
+
file.accounts[row.id] = { refresh: checked.refresh, access: checked.access, expires: checked.expires, ...(checked.accountId ? { accountId: checked.accountId } : {}) };
|
|
91
|
+
publish(file);
|
|
92
|
+
writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => update(db, { ...row, label: checked.label, labelKey: key(checked.label), lastAttemptAt: Math.max(row.lastAttemptAt, checked.lastAttemptAt), lastRateLimitedAt: cooldownIncoming ? checked.lastRateLimitedAt : row.lastRateLimitedAt, rateLimitedUntil: cooldownIncoming ? checked.rateLimitedUntil : row.rateLimitedUntil })));
|
|
93
|
+
return publicPool(provider, file);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export async function renameSubscription(provider, ref, nextLabel) { return locked(provider, async (file, rows) => { const index = resolve(rows, ref); if (index < 0)
|
|
97
|
+
throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); const label = nextLabel.trim(); if (!label)
|
|
98
|
+
throw new Error(`${getProviderLabel(provider)} subscription label cannot be empty`); if (rows.some((value, i) => i !== index && key(value.entry.label) === key(label)))
|
|
99
|
+
throw new DuplicateSubscriptionError(`${getProviderLabel(provider)} subscription already exists: ${label}`); providerWrite(agentDir(), (db) => update(db, { ...rows[index].row, label, labelKey: key(label) })); return publicPool(provider, file); }); }
|
|
100
|
+
async function remove(provider, ref) { return locked(provider, async (file, rows, publish) => { const index = resolve(rows, ref); if (index < 0)
|
|
101
|
+
throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); const removed = rows[index].row; providerWrite(agentDir(), (db) => db.prepare('DELETE FROM managed_accounts WHERE provider_id=? AND id=?').run(provider, removed.id)); delete file.accounts[removed.id]; publish(file); return publicPool(provider, file); }); }
|
|
102
|
+
export async function removeManagedAccount(provider, ref) { return remove(provider, ref); }
|
|
103
|
+
export async function removeSubscription(provider, ref) { return remove(provider, ref); }
|
|
104
|
+
export async function removeAllManagedAccounts(provider) { return locked(provider, async (file, rows, publish) => { providerWrite(agentDir(), (db) => db.prepare('DELETE FROM managed_accounts WHERE provider_id=?').run(provider)); for (const { row } of rows)
|
|
105
|
+
delete file.accounts[row.id]; publish(file); return rows.length; }); }
|
|
106
|
+
export async function removeManagedProvider(provider, label) { if (label === undefined) {
|
|
107
|
+
await removeAllManagedAccounts(provider);
|
|
108
|
+
return [];
|
|
109
|
+
} return remove(provider, label); }
|
|
110
|
+
export async function promoteSubscription(provider, ref) { return locked(provider, async (file, rows) => { const index = resolve(rows, ref); if (index < 0)
|
|
111
|
+
throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); providerWrite(agentDir(), (db) => { const reordered = [rows[index], ...rows.filter((_, i) => i !== index)]; reordered.forEach((item, position) => update(db, { ...item.row, position })); }); return publicPool(provider, file); }); }
|
|
112
|
+
async function mutateHealth(provider, ref, mutate) { providerWrite(agentDir(), (db) => { const row = providerRowByReference(db, provider, ref); if (!row)
|
|
113
|
+
throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); const next = mutate(row); if (next)
|
|
114
|
+
update(db, next); }); return readSubscriptionPool(provider); }
|
|
115
|
+
export async function markSubscriptionAttempt(provider, ref, attemptAt = Date.now()) { return mutateHealth(provider, ref, (row) => ({ ...row, lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt) })); }
|
|
116
|
+
export async function markSubscriptionAuthenticationFailed(provider, ref, retryAfterMs, attemptAt = Date.now()) { return mutateHealth(provider, ref, (row) => ({ ...row, authFailure: 'invalid_grant', lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt), rateLimitedUntil: Math.max(row.rateLimitedUntil, attemptAt + Math.max(0, retryAfterMs)) })); }
|
|
117
|
+
export async function markSubscriptionRateLimited(provider, ref, retryAfterMs, attemptAt = Date.now(), rateLimitedAt = Date.now()) { return mutateHealth(provider, ref, (row) => rateLimitedAt < row.lastRateLimitedAt ? null : { ...row, lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt), lastRateLimitedAt: rateLimitedAt, rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs) }); }
|
|
118
|
+
export async function markSubscriptionSuccess(provider, ref, attemptAt = Date.now()) { return mutateHealth(provider, ref, (row) => attemptAt < row.lastRateLimitedAt ? null : { ...row, authFailure: undefined, lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt), rateLimitedUntil: 0 }); }
|
|
119
|
+
export async function refreshSubscriptionCredential(provider, label, exchange) { return locked(provider, async (file, rows, publish) => { const index = resolve(rows, label); if (index < 0)
|
|
120
|
+
throw new Error(`${getProviderLabel(provider)} subscription "${label}" no longer exists`); const current = rows[index]; if (current.entry.expires > Date.now())
|
|
121
|
+
return current.entry; const tokens = assertTokens(provider, current.entry.label, await exchange(current.entry.refresh)); file.accounts[current.row.id] = { ...file.accounts[current.row.id], ...tokens }; publish(file); return { ...current.entry, ...tokens }; }); }
|
|
122
|
+
export function isInvalidRefreshTokenError(error) { const value = error; const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ''].filter((part) => typeof part === 'string').join(' '); const status = typeof value?.status === 'number' ? value.status : Number(message.match(/\bstatus[=:]\s*(\d+)/i)?.[1]); return status === 400 && /invalid_grant|refresh token .*invalid|refresh token .*not found/i.test(message); }
|
|
123
|
+
export async function modifyElectedSubscription(provider, elect, apply) { return locked(provider, async (file, rows, publish) => { const attempted = new Set(); let lastError; while (attempted.size < rows.length) {
|
|
124
|
+
const chosen = elect(rows.filter(({ entry }) => !attempted.has(entry.label)).map(({ entry }) => entry));
|
|
125
|
+
if (!chosen)
|
|
126
|
+
break;
|
|
127
|
+
const current = rows.find(({ entry }) => entry.label === chosen.label);
|
|
128
|
+
attempted.add(current.entry.label);
|
|
521
129
|
try {
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
return
|
|
528
|
-
const body = await response.json();
|
|
529
|
-
const accountId = typeof body.account?.uuid === "string" && body.account.uuid.trim() ? body.account.uuid.trim() : undefined;
|
|
530
|
-
const email = typeof body.account?.email === "string" && body.account.email.trim() ? body.account.email.trim() : undefined;
|
|
531
|
-
return { ...(accountId ? { accountId } : {}), ...(email ? { email } : {}) };
|
|
532
|
-
}
|
|
533
|
-
catch {
|
|
534
|
-
return supplied ? { accountId: supplied } : {};
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
function nextAutomaticLabel(pool, email, fallback) {
|
|
538
|
-
const base = email || fallback?.trim() || `Account ${pool.length + 1}`;
|
|
539
|
-
const used = new Set(pool.map((entry) => entry.label.toLowerCase()));
|
|
540
|
-
if (!used.has(base.toLowerCase()))
|
|
541
|
-
return base;
|
|
542
|
-
for (let index = 2;; index++) {
|
|
543
|
-
const candidate = `${base} (${index})`;
|
|
544
|
-
if (!used.has(candidate.toLowerCase()))
|
|
545
|
-
return candidate;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
// Coordinated persistence for a freshly acquired managed OAuth credential. An explicit label
|
|
549
|
-
// selects that row for reauthentication; otherwise a matching identity updates its row, and a new
|
|
550
|
-
// identity receives an automatic email-derived label. UI must never persist managed OAuth into
|
|
551
|
-
// auth.json because the pool owns refresh and account uniqueness.
|
|
552
|
-
export async function commitManagedLogin(providerId, label, credential) {
|
|
553
|
-
const identity = await enrichManagedLoginIdentity(providerId, credential);
|
|
554
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
555
|
-
const accountId = identity.accountId;
|
|
556
|
-
const selectedLabel = credential.autoLabel ? undefined : label?.trim();
|
|
557
|
-
const existing = (selectedLabel ? pool.find((entry) => entry.label.toLowerCase() === selectedLabel.toLowerCase()) : undefined) ??
|
|
558
|
-
pool.find((entry) => accountIdentity(entry) === accountIdentity({ accountId, refresh: credential.refresh }));
|
|
559
|
-
const resolvedLabel = existing?.label ?? (credential.autoLabel ? nextAutomaticLabel(pool, identity.email, label) : selectedLabel);
|
|
560
|
-
if (!resolvedLabel)
|
|
561
|
-
throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
|
|
562
|
-
if (!existing && pool.some((entry) => entry.label.toLowerCase() === resolvedLabel.toLowerCase())) {
|
|
563
|
-
throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${resolvedLabel}`);
|
|
130
|
+
const next = await apply(current.entry);
|
|
131
|
+
if (next) {
|
|
132
|
+
const tokens = assertTokens(provider, current.entry.label, next);
|
|
133
|
+
file.accounts[current.row.id] = { ...file.accounts[current.row.id], ...tokens };
|
|
134
|
+
publish(file);
|
|
135
|
+
return { ...current.entry, ...tokens };
|
|
564
136
|
}
|
|
565
|
-
|
|
566
|
-
const next = {
|
|
567
|
-
label: resolvedLabel,
|
|
568
|
-
refresh: credential.refresh,
|
|
569
|
-
access: credential.access,
|
|
570
|
-
expires: credential.expires,
|
|
571
|
-
...(accountId ? { accountId } : {}),
|
|
572
|
-
rateLimitedUntil: 0,
|
|
573
|
-
lastAttemptAt: Date.now(),
|
|
574
|
-
lastRateLimitedAt: credential.lastRateLimitedAt,
|
|
575
|
-
};
|
|
576
|
-
if (!existing)
|
|
577
|
-
return [...pool, next];
|
|
578
|
-
const index = pool.indexOf(existing);
|
|
579
|
-
const merged = [...pool];
|
|
580
|
-
merged[index] = mergeUpsert(existing, next);
|
|
581
|
-
return merged;
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
// Remove every account for one managed provider through a single locked transform. The
|
|
585
|
-
// returned count belongs to that locked snapshot, so callers do not race a concurrent login.
|
|
586
|
-
export async function removeAllManagedAccounts(providerId) {
|
|
587
|
-
let removed = 0;
|
|
588
|
-
await mutateSubscriptionPool(providerId, (pool) => {
|
|
589
|
-
removed = pool.length;
|
|
590
|
-
return [];
|
|
591
|
-
});
|
|
592
|
-
return removed;
|
|
593
|
-
}
|
|
594
|
-
// Coordinated removal for the human UI. Removes a single account when `label` is given; the ENTIRE
|
|
595
|
-
// provider (every pool account) when `label` is omitted — always through a pool transform. UI
|
|
596
|
-
// must NEVER reach for a raw auth.json delete on a managed provider: that strips a file pi no
|
|
597
|
-
// longer reads for managed providers and orphans the whole pool.
|
|
598
|
-
export async function removeManagedProvider(providerId, label) {
|
|
599
|
-
if (label === undefined) {
|
|
600
|
-
await removeAllManagedAccounts(providerId);
|
|
601
|
-
return [];
|
|
137
|
+
return current.entry;
|
|
602
138
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
export async function
|
|
613
|
-
return
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
return
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
})
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
export
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
if (index === -1)
|
|
648
|
-
throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
649
|
-
const next = [...pool];
|
|
650
|
-
next[index] = {
|
|
651
|
-
...pool[index],
|
|
652
|
-
authFailure: "invalid_grant",
|
|
653
|
-
lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
|
|
654
|
-
// This is only an exclusion backoff, never a rate-limit observation.
|
|
655
|
-
rateLimitedUntil: Math.max(pool[index].rateLimitedUntil, attemptAt + Math.max(0, retryAfterMs)),
|
|
656
|
-
};
|
|
657
|
-
return next;
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
export async function markSubscriptionRateLimited(providerId, ref, retryAfterMs, attemptAt = Date.now(), rateLimitedAt = Date.now()) {
|
|
661
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
662
|
-
const index = resolveCredentialIndex(pool, ref);
|
|
663
|
-
if (index === -1)
|
|
664
|
-
throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
665
|
-
// A later rate-limit is authoritative until it expires: an out-of-order (stale) 429 whose
|
|
666
|
-
// basis predates the slot's newest cooldown must not reopen or shorten it.
|
|
667
|
-
if (rateLimitedAt < pool[index].lastRateLimitedAt)
|
|
668
|
-
return undefined;
|
|
669
|
-
const next = [...pool];
|
|
670
|
-
next[index] = {
|
|
671
|
-
...pool[index],
|
|
672
|
-
lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
|
|
673
|
-
lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
|
|
674
|
-
rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
|
|
675
|
-
};
|
|
676
|
-
return next;
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
export async function markSubscriptionSuccess(providerId, ref, attemptAt = Date.now()) {
|
|
680
|
-
return mutateSubscriptionPool(providerId, (pool) => {
|
|
681
|
-
const index = resolveCredentialIndex(pool, ref);
|
|
682
|
-
if (index === -1)
|
|
683
|
-
throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
684
|
-
// A success whose attempt started before the slot's newest 429 must not clear that cooldown
|
|
685
|
-
// (the memory rule): treat the later rate-limit as authoritative until it expires.
|
|
686
|
-
if (attemptAt < pool[index].lastRateLimitedAt)
|
|
687
|
-
return undefined;
|
|
688
|
-
const next = [...pool];
|
|
689
|
-
const { authFailure: _authFailure, ...healthy } = pool[index];
|
|
690
|
-
next[index] = { ...healthy, lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt), rateLimitedUntil: 0 };
|
|
691
|
-
return next;
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
export function parseRetryAfterHeader(value, now = Date.now()) {
|
|
695
|
-
if (!value)
|
|
696
|
-
return undefined;
|
|
697
|
-
const trimmed = value.trim();
|
|
698
|
-
if (!trimmed)
|
|
699
|
-
return undefined;
|
|
700
|
-
const seconds = Number(trimmed);
|
|
701
|
-
if (Number.isFinite(seconds))
|
|
702
|
-
return Math.max(0, Math.round(seconds * 1000));
|
|
703
|
-
const parsed = Date.parse(trimmed);
|
|
704
|
-
return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
|
|
705
|
-
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!isInvalidRefreshTokenError(error))
|
|
141
|
+
throw error;
|
|
142
|
+
lastError = error;
|
|
143
|
+
const at = Date.now();
|
|
144
|
+
providerWrite(agentDir(), (db) => update(db, { ...current.row, authFailure: 'invalid_grant', lastAttemptAt: Math.max(current.row.lastAttemptAt, at), rateLimitedUntil: Math.max(current.row.rateLimitedUntil, at + INVALID_REFRESH_TOKEN_BACKOFF_MS) }));
|
|
145
|
+
}
|
|
146
|
+
} if (lastError)
|
|
147
|
+
throw lastError; return undefined; }); }
|
|
148
|
+
export async function enrichManagedLoginIdentity(provider, value) { const supplied = value.accountId?.trim(); if (provider !== ANTHROPIC_PROVIDER_ID)
|
|
149
|
+
return supplied ? { accountId: supplied } : {}; try {
|
|
150
|
+
const response = await fetch('https://api.anthropic.com/api/oauth/profile', { headers: { Authorization: `Bearer ${value.access}`, Accept: 'application/json' }, signal: AbortSignal.timeout(3_000) });
|
|
151
|
+
if (!response.ok)
|
|
152
|
+
return supplied ? { accountId: supplied } : {};
|
|
153
|
+
const body = await response.json();
|
|
154
|
+
const accountId = typeof body.account?.uuid === 'string' && body.account.uuid.trim() ? body.account.uuid.trim() : undefined;
|
|
155
|
+
const email = typeof body.account?.email === 'string' && body.account.email.trim() ? body.account.email.trim() : undefined;
|
|
156
|
+
return { ...(accountId ? { accountId } : {}), ...(email ? { email } : {}) };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return supplied ? { accountId: supplied } : {};
|
|
160
|
+
} }
|
|
161
|
+
export async function commitManagedLogin(provider, label, value) { const identityInfo = await enrichManagedLoginIdentity(provider, value); return locked(provider, async (file, rows, publish) => { const selected = value.autoLabel ? undefined : label?.trim(); const incomingIdentity = identity({ refresh: value.refresh, accountId: identityInfo.accountId }); const matching = (selected ? rows.find(({ entry }) => key(entry.label) === key(selected)) : undefined) ?? rows.find(({ entry }) => identity(entry) === incomingIdentity); let resolved = matching?.entry.label ?? selected; if (!resolved && value.autoLabel) {
|
|
162
|
+
const base = identityInfo.email || label?.trim() || `Account ${rows.length + 1}`;
|
|
163
|
+
resolved = base;
|
|
164
|
+
for (let n = 2; rows.some(({ entry }) => key(entry.label) === key(resolved)); n++)
|
|
165
|
+
resolved = `${base} (${n})`;
|
|
166
|
+
} if (!resolved)
|
|
167
|
+
throw new Error(`${getProviderLabel(provider)} subscription label cannot be empty`); const entry = assertCredential(provider, { label: resolved, refresh: value.refresh, access: value.access, expires: value.expires, ...(identityInfo.accountId ? { accountId: identityInfo.accountId } : {}), rateLimitedUntil: 0, lastAttemptAt: Date.now(), lastRateLimitedAt: value.lastRateLimitedAt }); if (!matching) {
|
|
168
|
+
if (rows.some(({ entry: existing }) => key(existing.label) === key(entry.label)))
|
|
169
|
+
throw new DuplicateSubscriptionError(`${getProviderLabel(provider)} subscription already exists: ${entry.label}`);
|
|
170
|
+
const duplicate = rows.find(({ entry: existing }) => identity(existing) === identity(entry));
|
|
171
|
+
if (identity(entry) && duplicate)
|
|
172
|
+
throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${duplicate.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`);
|
|
173
|
+
const id = randomUUID();
|
|
174
|
+
file.accounts[id] = { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) };
|
|
175
|
+
publish(file);
|
|
176
|
+
writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => insert(db, provider, { providerId: provider, id, label: entry.label, labelKey: key(entry.label), position: rows.length, lastAttemptAt: entry.lastAttemptAt, lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil })));
|
|
177
|
+
return publicPool(provider, file);
|
|
178
|
+
} const row = matching.row; const duplicate = rows.find(({ entry: other }) => other.label !== matching.entry.label && identity(other) === identity(entry)); if (identity(entry) && duplicate)
|
|
179
|
+
throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${duplicate.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`); file.accounts[row.id] = { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) }; publish(file); const clear = entry.lastRateLimitedAt >= row.lastRateLimitedAt; writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => update(db, { ...row, lastAttemptAt: Math.max(row.lastAttemptAt, entry.lastAttemptAt), lastRateLimitedAt: clear ? entry.lastRateLimitedAt : row.lastRateLimitedAt, rateLimitedUntil: clear ? 0 : row.rateLimitedUntil }))); return publicPool(provider, file); }); }
|
|
180
|
+
export function parseRetryAfterHeader(value, now = Date.now()) { if (!value?.trim())
|
|
181
|
+
return undefined; const seconds = Number(value.trim()); if (Number.isFinite(seconds))
|
|
182
|
+
return Math.max(0, Math.round(seconds * 1000)); const parsed = Date.parse(value); return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined; }
|
|
706
183
|
export function readRotationConfig() {
|
|
707
|
-
const
|
|
708
|
-
return {
|
|
709
|
-
defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
|
|
710
|
-
revertWhenAvailable: raw.revertWhenAvailable ?? true,
|
|
711
|
-
};
|
|
184
|
+
const row = openScopedStore(providerStore(agentDir())).prepare('SELECT default_fallback_strength,revert_when_available FROM rotation_settings WHERE singleton=1').get();
|
|
185
|
+
return { defaultFallbackStrength: normalizeStrength(row?.default_fallback_strength) ?? DEFAULT_FALLBACK_STRENGTH, revertWhenAvailable: row?.revert_when_available === undefined || row.revert_when_available === null ? true : Boolean(row.revert_when_available) };
|
|
712
186
|
}
|
|
713
187
|
export function writeRotationConfig(next) {
|
|
714
188
|
const current = readRotationConfig();
|
|
715
|
-
const merged = {
|
|
716
|
-
|
|
717
|
-
revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
|
|
718
|
-
};
|
|
719
|
-
atomicWriteJson(rotationConfigFile(), merged);
|
|
189
|
+
const merged = { defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH, revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true };
|
|
190
|
+
providerWrite(agentDir(), (db) => db.prepare(`INSERT INTO rotation_settings(singleton,default_fallback_strength,revert_when_available) VALUES(1,?,?) ON CONFLICT(singleton) DO UPDATE SET default_fallback_strength=excluded.default_fallback_strength,revert_when_available=excluded.revert_when_available`).run(merged.defaultFallbackStrength, merged.revertWhenAvailable ? 1 : 0));
|
|
720
191
|
return merged;
|
|
721
192
|
}
|
|
722
|
-
export function formatStatusLine(
|
|
723
|
-
const pool = readSubscriptionPool(providerId);
|
|
724
|
-
const ready = pool.filter((entry) => entry.authFailure === undefined && (!entry.rateLimitedUntil || entry.rateLimitedUntil <= now));
|
|
725
|
-
const label = getProviderLabel(providerId);
|
|
726
|
-
return ready.length === 0
|
|
727
|
-
? `${label} pool exhausted`
|
|
728
|
-
: `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
|
|
729
|
-
}
|
|
193
|
+
export function formatStatusLine(provider, now = Date.now()) { const pool = readSubscriptionPool(provider); const ready = pool.filter((entry) => entry.authFailure === undefined && entry.rateLimitedUntil <= now); const label = getProviderLabel(provider); return ready.length === 0 ? `${label} pool exhausted` : `${label} ${ready[0]?.label ?? provider} (${ready.length}/${pool.length} ready)`; }
|