@oar-sh/oar 0.9.0
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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistence + aggregation for plan usage.
|
|
5
|
+
*
|
|
6
|
+
* Storage is deliberately "latest snapshot only" for the provider payloads:
|
|
7
|
+
* plan limits are account-wide and only the newest reading is meaningful, so
|
|
8
|
+
* there is no per-turn history table here (per-turn Copilot deltas already
|
|
9
|
+
* live in `message_usage_snapshots` and serve a different purpose).
|
|
10
|
+
*
|
|
11
|
+
* Cursor is the exception, and only as far as correctness demands: its spend
|
|
12
|
+
* has to be *derived*, so two small bookkeeping tables are kept — a cumulative
|
|
13
|
+
* per-agent checkpoint (to diff against) and per-cycle pool totals (the
|
|
14
|
+
* accumulated result). Neither grows with conversation history.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
PLAN_USAGE_VERSION,
|
|
19
|
+
toTrimmedString,
|
|
20
|
+
} from './plan-usage-contract.mjs';
|
|
21
|
+
import { buildCopilotPlanCard } from './plan-usage-copilot.mjs';
|
|
22
|
+
import { buildClaudePlanCard } from './plan-usage-claude.mjs';
|
|
23
|
+
import {
|
|
24
|
+
CURSOR_POOLS,
|
|
25
|
+
applyCursorUsageDelta,
|
|
26
|
+
buildCursorPlanCard,
|
|
27
|
+
emptyCursorPoolTotals,
|
|
28
|
+
normalizeCursorAllowanceSettings,
|
|
29
|
+
resolveCursorBillingCycle,
|
|
30
|
+
} from './plan-usage-cursor.mjs';
|
|
31
|
+
import {
|
|
32
|
+
buildGrokPlanCard,
|
|
33
|
+
normalizeGrokAllowanceSettings,
|
|
34
|
+
normalizeGrokTurnUsage,
|
|
35
|
+
resolveGrokBillingCycle,
|
|
36
|
+
} from './plan-usage-grok.mjs';
|
|
37
|
+
|
|
38
|
+
export const PROVIDER_USAGE_SNAPSHOT_TABLE = 'provider_usage_snapshots';
|
|
39
|
+
export const CURSOR_USAGE_CHECKPOINT_TABLE = 'cursor_usage_checkpoints';
|
|
40
|
+
export const CURSOR_USAGE_CYCLE_TABLE = 'cursor_usage_cycle_totals';
|
|
41
|
+
export const GROK_USAGE_CYCLE_TABLE = 'grok_usage_cycle_totals';
|
|
42
|
+
|
|
43
|
+
export const PLAN_USAGE_SCHEMA_SQL = `
|
|
44
|
+
CREATE TABLE IF NOT EXISTS ${PROVIDER_USAGE_SNAPSHOT_TABLE} (
|
|
45
|
+
provider TEXT PRIMARY KEY,
|
|
46
|
+
payload_json TEXT,
|
|
47
|
+
source TEXT,
|
|
48
|
+
captured_at TEXT NOT NULL,
|
|
49
|
+
error TEXT
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS ${CURSOR_USAGE_CHECKPOINT_TABLE} (
|
|
53
|
+
agent_id TEXT PRIMARY KEY,
|
|
54
|
+
raw_cost_cents REAL,
|
|
55
|
+
charged_cents REAL,
|
|
56
|
+
input_tokens REAL,
|
|
57
|
+
output_tokens REAL,
|
|
58
|
+
cache_read_tokens REAL,
|
|
59
|
+
cache_write_tokens REAL,
|
|
60
|
+
total_tokens REAL,
|
|
61
|
+
run_count REAL,
|
|
62
|
+
updated_at TEXT NOT NULL
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS ${CURSOR_USAGE_CYCLE_TABLE} (
|
|
66
|
+
cycle_key TEXT NOT NULL,
|
|
67
|
+
pool TEXT NOT NULL,
|
|
68
|
+
raw_cost_cents REAL NOT NULL DEFAULT 0,
|
|
69
|
+
charged_cents REAL NOT NULL DEFAULT 0,
|
|
70
|
+
input_tokens REAL NOT NULL DEFAULT 0,
|
|
71
|
+
output_tokens REAL NOT NULL DEFAULT 0,
|
|
72
|
+
cache_read_tokens REAL NOT NULL DEFAULT 0,
|
|
73
|
+
cache_write_tokens REAL NOT NULL DEFAULT 0,
|
|
74
|
+
total_tokens REAL NOT NULL DEFAULT 0,
|
|
75
|
+
run_count REAL NOT NULL DEFAULT 0,
|
|
76
|
+
updated_at TEXT NOT NULL,
|
|
77
|
+
PRIMARY KEY (cycle_key, pool)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS ${GROK_USAGE_CYCLE_TABLE} (
|
|
81
|
+
cycle_key TEXT PRIMARY KEY,
|
|
82
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
83
|
+
input_tokens REAL NOT NULL DEFAULT 0,
|
|
84
|
+
output_tokens REAL NOT NULL DEFAULT 0,
|
|
85
|
+
total_tokens REAL NOT NULL DEFAULT 0,
|
|
86
|
+
turn_count REAL NOT NULL DEFAULT 0,
|
|
87
|
+
updated_at TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const CHECKPOINT_COLUMNS = [
|
|
92
|
+
['rawCostCents', 'raw_cost_cents'],
|
|
93
|
+
['chargedCents', 'charged_cents'],
|
|
94
|
+
['inputTokens', 'input_tokens'],
|
|
95
|
+
['outputTokens', 'output_tokens'],
|
|
96
|
+
['cacheReadTokens', 'cache_read_tokens'],
|
|
97
|
+
['cacheWriteTokens', 'cache_write_tokens'],
|
|
98
|
+
['totalTokens', 'total_tokens'],
|
|
99
|
+
['runCount', 'run_count'],
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export function ensurePlanUsageSchema(db) {
|
|
103
|
+
if (typeof db?.exec !== 'function') return false;
|
|
104
|
+
db.exec(PLAN_USAGE_SCHEMA_SQL);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function rowToCheckpoint(row) {
|
|
109
|
+
if (!row) return null;
|
|
110
|
+
const checkpoint = { agentId: row.agent_id, capturedAt: row.updated_at };
|
|
111
|
+
for (const [field, column] of CHECKPOINT_COLUMNS) {
|
|
112
|
+
const value = Number(row[column]);
|
|
113
|
+
checkpoint[field] = Number.isFinite(value) ? value : null;
|
|
114
|
+
}
|
|
115
|
+
return checkpoint;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rowsToPoolTotals(rows) {
|
|
119
|
+
const totals = emptyCursorPoolTotals();
|
|
120
|
+
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
121
|
+
const pool = toTrimmedString(row?.pool);
|
|
122
|
+
if (!pool || !totals[pool]) continue;
|
|
123
|
+
for (const [field, column] of CHECKPOINT_COLUMNS) {
|
|
124
|
+
const value = Number(row[column]);
|
|
125
|
+
totals[pool][field] = Number.isFinite(value) ? value : 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return totals;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {object} deps
|
|
133
|
+
* @param {object} deps.db better-sqlite3 handle
|
|
134
|
+
* @param {() => Date} [deps.now] injectable clock for deterministic tests
|
|
135
|
+
*/
|
|
136
|
+
export function createPlanUsageService({ db, now = () => new Date(), dbg = () => {} } = {}) {
|
|
137
|
+
if (!db || typeof db.prepare !== 'function') {
|
|
138
|
+
throw new Error('createPlanUsageService requires a database handle');
|
|
139
|
+
}
|
|
140
|
+
ensurePlanUsageSchema(db);
|
|
141
|
+
|
|
142
|
+
const stmts = {
|
|
143
|
+
upsertSnapshot: db.prepare(`
|
|
144
|
+
INSERT INTO ${PROVIDER_USAGE_SNAPSHOT_TABLE} (provider, payload_json, source, captured_at, error)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?)
|
|
146
|
+
ON CONFLICT(provider) DO UPDATE SET
|
|
147
|
+
payload_json = excluded.payload_json,
|
|
148
|
+
source = excluded.source,
|
|
149
|
+
captured_at = excluded.captured_at,
|
|
150
|
+
error = excluded.error
|
|
151
|
+
`),
|
|
152
|
+
getSnapshot: db.prepare(`SELECT * FROM ${PROVIDER_USAGE_SNAPSHOT_TABLE} WHERE provider = ?`),
|
|
153
|
+
getCheckpoint: db.prepare(`SELECT * FROM ${CURSOR_USAGE_CHECKPOINT_TABLE} WHERE agent_id = ?`),
|
|
154
|
+
upsertCheckpoint: db.prepare(`
|
|
155
|
+
INSERT INTO ${CURSOR_USAGE_CHECKPOINT_TABLE} (
|
|
156
|
+
agent_id, raw_cost_cents, charged_cents, input_tokens, output_tokens,
|
|
157
|
+
cache_read_tokens, cache_write_tokens, total_tokens, run_count, updated_at
|
|
158
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
159
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
160
|
+
raw_cost_cents = excluded.raw_cost_cents,
|
|
161
|
+
charged_cents = excluded.charged_cents,
|
|
162
|
+
input_tokens = excluded.input_tokens,
|
|
163
|
+
output_tokens = excluded.output_tokens,
|
|
164
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
165
|
+
cache_write_tokens = excluded.cache_write_tokens,
|
|
166
|
+
total_tokens = excluded.total_tokens,
|
|
167
|
+
run_count = excluded.run_count,
|
|
168
|
+
updated_at = excluded.updated_at
|
|
169
|
+
`),
|
|
170
|
+
listCycleTotals: db.prepare(`SELECT * FROM ${CURSOR_USAGE_CYCLE_TABLE} WHERE cycle_key = ?`),
|
|
171
|
+
addCycleTotals: db.prepare(`
|
|
172
|
+
INSERT INTO ${CURSOR_USAGE_CYCLE_TABLE} (
|
|
173
|
+
cycle_key, pool, raw_cost_cents, charged_cents, input_tokens, output_tokens,
|
|
174
|
+
cache_read_tokens, cache_write_tokens, total_tokens, run_count, updated_at
|
|
175
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
176
|
+
ON CONFLICT(cycle_key, pool) DO UPDATE SET
|
|
177
|
+
raw_cost_cents = ${CURSOR_USAGE_CYCLE_TABLE}.raw_cost_cents + excluded.raw_cost_cents,
|
|
178
|
+
charged_cents = ${CURSOR_USAGE_CYCLE_TABLE}.charged_cents + excluded.charged_cents,
|
|
179
|
+
input_tokens = ${CURSOR_USAGE_CYCLE_TABLE}.input_tokens + excluded.input_tokens,
|
|
180
|
+
output_tokens = ${CURSOR_USAGE_CYCLE_TABLE}.output_tokens + excluded.output_tokens,
|
|
181
|
+
cache_read_tokens = ${CURSOR_USAGE_CYCLE_TABLE}.cache_read_tokens + excluded.cache_read_tokens,
|
|
182
|
+
cache_write_tokens = ${CURSOR_USAGE_CYCLE_TABLE}.cache_write_tokens + excluded.cache_write_tokens,
|
|
183
|
+
total_tokens = ${CURSOR_USAGE_CYCLE_TABLE}.total_tokens + excluded.total_tokens,
|
|
184
|
+
run_count = ${CURSOR_USAGE_CYCLE_TABLE}.run_count + excluded.run_count,
|
|
185
|
+
updated_at = excluded.updated_at
|
|
186
|
+
`),
|
|
187
|
+
clearCheckpoints: db.prepare(`DELETE FROM ${CURSOR_USAGE_CHECKPOINT_TABLE}`),
|
|
188
|
+
clearCycle: db.prepare(`DELETE FROM ${CURSOR_USAGE_CYCLE_TABLE} WHERE cycle_key = ?`),
|
|
189
|
+
getGrokCycle: db.prepare(`SELECT * FROM ${GROK_USAGE_CYCLE_TABLE} WHERE cycle_key = ?`),
|
|
190
|
+
addGrokCycle: db.prepare(`
|
|
191
|
+
INSERT INTO ${GROK_USAGE_CYCLE_TABLE} (
|
|
192
|
+
cycle_key, cost_usd, input_tokens, output_tokens, total_tokens, turn_count, updated_at
|
|
193
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
194
|
+
ON CONFLICT(cycle_key) DO UPDATE SET
|
|
195
|
+
cost_usd = ${GROK_USAGE_CYCLE_TABLE}.cost_usd + excluded.cost_usd,
|
|
196
|
+
input_tokens = ${GROK_USAGE_CYCLE_TABLE}.input_tokens + excluded.input_tokens,
|
|
197
|
+
output_tokens = ${GROK_USAGE_CYCLE_TABLE}.output_tokens + excluded.output_tokens,
|
|
198
|
+
total_tokens = ${GROK_USAGE_CYCLE_TABLE}.total_tokens + excluded.total_tokens,
|
|
199
|
+
turn_count = ${GROK_USAGE_CYCLE_TABLE}.turn_count + excluded.turn_count,
|
|
200
|
+
updated_at = excluded.updated_at
|
|
201
|
+
`),
|
|
202
|
+
clearGrokCycle: db.prepare(`DELETE FROM ${GROK_USAGE_CYCLE_TABLE} WHERE cycle_key = ?`),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function nowIso() {
|
|
206
|
+
const value = now();
|
|
207
|
+
return value instanceof Date && !Number.isNaN(value.getTime())
|
|
208
|
+
? value.toISOString()
|
|
209
|
+
: new Date().toISOString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function saveSnapshot(provider, payload, { source = 'worker', error = null, capturedAt = null } = {}) {
|
|
213
|
+
const key = toTrimmedString(provider);
|
|
214
|
+
if (!key) return false;
|
|
215
|
+
stmts.upsertSnapshot.run(
|
|
216
|
+
key,
|
|
217
|
+
payload ? JSON.stringify(payload) : null,
|
|
218
|
+
toTrimmedString(source),
|
|
219
|
+
toTrimmedString(capturedAt) || nowIso(),
|
|
220
|
+
toTrimmedString(error),
|
|
221
|
+
);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readSnapshot(provider) {
|
|
226
|
+
const row = stmts.getSnapshot.get(toTrimmedString(provider) || '');
|
|
227
|
+
if (!row) return null;
|
|
228
|
+
let payload = null;
|
|
229
|
+
if (row.payload_json) {
|
|
230
|
+
try {
|
|
231
|
+
payload = JSON.parse(row.payload_json);
|
|
232
|
+
} catch (parseError) {
|
|
233
|
+
dbg('plan usage snapshot parse failed', provider, parseError?.message || String(parseError));
|
|
234
|
+
payload = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
provider: row.provider,
|
|
239
|
+
payload,
|
|
240
|
+
source: row.source || null,
|
|
241
|
+
capturedAt: row.captured_at || null,
|
|
242
|
+
error: row.error || null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fold one cumulative Cursor agent report into the current cycle. Returns the
|
|
248
|
+
* applied delta so callers can log/trace it; a report that adds nothing (a
|
|
249
|
+
* repeated snapshot, or a backend restatement) still refreshes the checkpoint.
|
|
250
|
+
*/
|
|
251
|
+
function recordCursorUsageReport(report, { resetDay } = {}) {
|
|
252
|
+
const previousRow = stmts.getCheckpoint.get(toTrimmedString(report?.agentId) || '');
|
|
253
|
+
const applied = applyCursorUsageDelta({ checkpoint: rowToCheckpoint(previousRow), report });
|
|
254
|
+
if (!applied) return null;
|
|
255
|
+
|
|
256
|
+
const cycle = resolveCursorBillingCycle({ resetDay, now: now() });
|
|
257
|
+
const timestamp = applied.capturedAt || nowIso();
|
|
258
|
+
|
|
259
|
+
const persist = db.transaction(() => {
|
|
260
|
+
stmts.upsertCheckpoint.run(
|
|
261
|
+
applied.agentId,
|
|
262
|
+
applied.checkpoint.rawCostCents,
|
|
263
|
+
applied.checkpoint.chargedCents,
|
|
264
|
+
applied.checkpoint.inputTokens,
|
|
265
|
+
applied.checkpoint.outputTokens,
|
|
266
|
+
applied.checkpoint.cacheReadTokens,
|
|
267
|
+
applied.checkpoint.cacheWriteTokens,
|
|
268
|
+
applied.checkpoint.totalTokens,
|
|
269
|
+
applied.checkpoint.runCount,
|
|
270
|
+
timestamp,
|
|
271
|
+
);
|
|
272
|
+
if (applied.changed) {
|
|
273
|
+
stmts.addCycleTotals.run(
|
|
274
|
+
cycle.key,
|
|
275
|
+
applied.pool,
|
|
276
|
+
applied.delta.rawCostCents,
|
|
277
|
+
applied.delta.chargedCents,
|
|
278
|
+
applied.delta.inputTokens,
|
|
279
|
+
applied.delta.outputTokens,
|
|
280
|
+
applied.delta.cacheReadTokens,
|
|
281
|
+
applied.delta.cacheWriteTokens,
|
|
282
|
+
applied.delta.totalTokens,
|
|
283
|
+
applied.delta.runCount,
|
|
284
|
+
timestamp,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
persist();
|
|
289
|
+
|
|
290
|
+
return { ...applied, cycle };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readCursorCycleTotals({ resetDay } = {}) {
|
|
294
|
+
const cycle = resolveCursorBillingCycle({ resetDay, now: now() });
|
|
295
|
+
const rows = stmts.listCycleTotals.all(cycle.key);
|
|
296
|
+
const totals = rowsToPoolTotals(rows);
|
|
297
|
+
const capturedAt = (rows || [])
|
|
298
|
+
.map((row) => toTrimmedString(row?.updated_at))
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
.sort()
|
|
301
|
+
.pop() || null;
|
|
302
|
+
return { cycle, totals, capturedAt };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Drop the derived Cursor bookkeeping. Used when the user changes plan or
|
|
307
|
+
* account and the accumulated cycle no longer reflects reality — the next
|
|
308
|
+
* report re-baselines from the agent's current cumulative totals, so no
|
|
309
|
+
* historical spend is retroactively counted.
|
|
310
|
+
*/
|
|
311
|
+
function resetCursorAccounting({ resetDay } = {}) {
|
|
312
|
+
const cycle = resolveCursorBillingCycle({ resetDay, now: now() });
|
|
313
|
+
const reset = db.transaction(() => {
|
|
314
|
+
stmts.clearCheckpoints.run();
|
|
315
|
+
stmts.clearCycle.run(cycle.key);
|
|
316
|
+
});
|
|
317
|
+
reset();
|
|
318
|
+
return { cycle };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Book one completed Grok turn into the current billing cycle and refresh the
|
|
323
|
+
* last-turn snapshot. Grok reports per-prompt totals (not cumulative agent
|
|
324
|
+
* counters), so each report is added whole — no checkpoint/diff.
|
|
325
|
+
*/
|
|
326
|
+
function recordGrokUsageReport(rawReport, { resetDay } = {}) {
|
|
327
|
+
const usage = normalizeGrokTurnUsage(rawReport);
|
|
328
|
+
if (!usage) return null;
|
|
329
|
+
const cycle = resolveGrokBillingCycle({ resetDay, now: now() });
|
|
330
|
+
// Server clock for the ledger/snapshot timestamp: the cycle is chosen by
|
|
331
|
+
// server time too, and a client-supplied capturedAt would otherwise drive
|
|
332
|
+
// the card's "Updated …" freshness label. The worker's capturedAt stays
|
|
333
|
+
// inside the snapshot payload itself.
|
|
334
|
+
const timestamp = nowIso();
|
|
335
|
+
const persist = db.transaction(() => {
|
|
336
|
+
stmts.addGrokCycle.run(
|
|
337
|
+
cycle.key,
|
|
338
|
+
usage.costUsd || 0,
|
|
339
|
+
usage.inputTokens || 0,
|
|
340
|
+
usage.outputTokens || 0,
|
|
341
|
+
usage.totalTokens || 0,
|
|
342
|
+
1,
|
|
343
|
+
timestamp,
|
|
344
|
+
);
|
|
345
|
+
stmts.upsertSnapshot.run(
|
|
346
|
+
'grok',
|
|
347
|
+
JSON.stringify(usage),
|
|
348
|
+
'worker',
|
|
349
|
+
timestamp,
|
|
350
|
+
null,
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
persist();
|
|
354
|
+
return { usage, cycle };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readGrokCycleTotals({ resetDay } = {}) {
|
|
358
|
+
const cycle = resolveGrokBillingCycle({ resetDay, now: now() });
|
|
359
|
+
const row = stmts.getGrokCycle.get(cycle.key);
|
|
360
|
+
if (!row) {
|
|
361
|
+
return {
|
|
362
|
+
cycle,
|
|
363
|
+
totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, turnCount: 0 },
|
|
364
|
+
capturedAt: null,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
cycle,
|
|
369
|
+
totals: {
|
|
370
|
+
costUsd: Number(row.cost_usd) || 0,
|
|
371
|
+
inputTokens: Number(row.input_tokens) || 0,
|
|
372
|
+
outputTokens: Number(row.output_tokens) || 0,
|
|
373
|
+
totalTokens: Number(row.total_tokens) || 0,
|
|
374
|
+
turnCount: Number(row.turn_count) || 0,
|
|
375
|
+
},
|
|
376
|
+
capturedAt: row.updated_at || null,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function resetGrokAccounting({ resetDay } = {}) {
|
|
381
|
+
const cycle = resolveGrokBillingCycle({ resetDay, now: now() });
|
|
382
|
+
stmts.clearGrokCycle.run(cycle.key);
|
|
383
|
+
return { cycle };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Assemble the full multi-provider report. Every provider is independent:
|
|
388
|
+
* one failing source produces an unavailable card instead of failing the
|
|
389
|
+
* whole response.
|
|
390
|
+
*/
|
|
391
|
+
function buildReport({
|
|
392
|
+
copilotSummary = null,
|
|
393
|
+
copilotError = null,
|
|
394
|
+
copilotStale = false,
|
|
395
|
+
copilotBilling = null,
|
|
396
|
+
claudeConfigured = true,
|
|
397
|
+
cursorConfigured = true,
|
|
398
|
+
cursorAllowances = null,
|
|
399
|
+
cursorBilling = null,
|
|
400
|
+
cursorDashboardAuth = null,
|
|
401
|
+
grokConfigured = false,
|
|
402
|
+
grokAllowances = null,
|
|
403
|
+
grokBilling = null,
|
|
404
|
+
} = {}) {
|
|
405
|
+
const generatedAt = nowIso();
|
|
406
|
+
const claudeSnapshot = readSnapshot('claude');
|
|
407
|
+
const allowances = normalizeCursorAllowanceSettings(cursorAllowances || {});
|
|
408
|
+
const cursorCycle = readCursorCycleTotals({ resetDay: allowances.resetDay });
|
|
409
|
+
const grokSettings = normalizeGrokAllowanceSettings(grokAllowances || {});
|
|
410
|
+
const grokCycle = readGrokCycleTotals({ resetDay: grokSettings.resetDay });
|
|
411
|
+
const grokSnapshot = readSnapshot('grok');
|
|
412
|
+
// The SDK engine's per-turn detail. Stored under its own key rather than
|
|
413
|
+
// 'github' so it stays clearly additive: the Copilot card's meters come
|
|
414
|
+
// from the account-level quota API and are correct for both engines, and
|
|
415
|
+
// an install still on the extension engine simply has no row here.
|
|
416
|
+
const copilotWorkerSnapshot = readSnapshot('copilot-sdk');
|
|
417
|
+
|
|
418
|
+
const providers = [
|
|
419
|
+
buildCopilotPlanCard({
|
|
420
|
+
summary: copilotSummary,
|
|
421
|
+
billing: copilotBilling,
|
|
422
|
+
workerUsage: copilotWorkerSnapshot?.payload || null,
|
|
423
|
+
error: copilotError,
|
|
424
|
+
capturedAt: generatedAt,
|
|
425
|
+
stale: copilotStale === true,
|
|
426
|
+
}),
|
|
427
|
+
buildClaudePlanCard({
|
|
428
|
+
usage: claudeSnapshot?.payload || null,
|
|
429
|
+
capturedAt: claudeSnapshot?.capturedAt || null,
|
|
430
|
+
configured: claudeConfigured !== false,
|
|
431
|
+
message: claudeSnapshot?.error || null,
|
|
432
|
+
// A stored snapshot is by definition from an earlier turn; the SDK's
|
|
433
|
+
// usage control call only works while a session transport is open, and
|
|
434
|
+
// the relay never opens a billable turn just to refresh this.
|
|
435
|
+
stale: !!claudeSnapshot?.payload,
|
|
436
|
+
}),
|
|
437
|
+
buildCursorPlanCard({
|
|
438
|
+
totals: cursorCycle.totals,
|
|
439
|
+
allowances,
|
|
440
|
+
cycle: cursorCycle.cycle,
|
|
441
|
+
capturedAt: cursorCycle.capturedAt,
|
|
442
|
+
configured: cursorConfigured !== false,
|
|
443
|
+
dashboard: cursorBilling,
|
|
444
|
+
dashboardAuth: cursorDashboardAuth,
|
|
445
|
+
}),
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
// Grok is opt-in: hide the card entirely when the provider is disabled
|
|
449
|
+
// (unlike Claude/Cursor which show a not-configured placeholder).
|
|
450
|
+
if (grokConfigured === true) {
|
|
451
|
+
providers.push(buildGrokPlanCard({
|
|
452
|
+
usage: grokSnapshot?.payload || null,
|
|
453
|
+
cycleTotals: grokCycle.totals,
|
|
454
|
+
allowances: grokSettings,
|
|
455
|
+
cycle: grokCycle.cycle,
|
|
456
|
+
billing: grokBilling,
|
|
457
|
+
capturedAt: grokSnapshot?.capturedAt || grokCycle.capturedAt || null,
|
|
458
|
+
configured: true,
|
|
459
|
+
message: grokSnapshot?.error || null,
|
|
460
|
+
// Same semantics as the Claude card: a stored snapshot is by
|
|
461
|
+
// definition from an earlier turn (the relay never opens a billable
|
|
462
|
+
// turn just to refresh it), so the two identical data shapes carry
|
|
463
|
+
// the same staleness flag.
|
|
464
|
+
stale: !!grokSnapshot?.payload,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
version: PLAN_USAGE_VERSION,
|
|
470
|
+
generatedAt,
|
|
471
|
+
providers: providers.filter(Boolean),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
saveSnapshot,
|
|
477
|
+
readSnapshot,
|
|
478
|
+
recordCursorUsageReport,
|
|
479
|
+
readCursorCycleTotals,
|
|
480
|
+
resetCursorAccounting,
|
|
481
|
+
recordGrokUsageReport,
|
|
482
|
+
readGrokCycleTotals,
|
|
483
|
+
resetGrokAccounting,
|
|
484
|
+
buildReport,
|
|
485
|
+
pools: CURSOR_POOLS,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import net from 'net';
|
|
4
|
+
|
|
5
|
+
// Liveness for preview cards. Previews never auto-close, so the card has to say
|
|
6
|
+
// whether the dev server behind it is still up — a TCP connect is enough and,
|
|
7
|
+
// unlike an HTTP request, leaves nothing in the app's request log.
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTERVAL_MS = 15_000;
|
|
10
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 250;
|
|
11
|
+
|
|
12
|
+
export function probeTcpPort({ host, port, timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, connect = net.connect } = {}) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const finish = (online) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
try { socket.destroy(); } catch {}
|
|
19
|
+
resolve(online);
|
|
20
|
+
};
|
|
21
|
+
const socket = connect({ host, port });
|
|
22
|
+
socket.setTimeout?.(timeoutMs);
|
|
23
|
+
socket.once('connect', () => finish(true));
|
|
24
|
+
socket.once('timeout', () => finish(false));
|
|
25
|
+
socket.once('error', () => finish(false));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createPreviewHealthProbe({
|
|
30
|
+
registry,
|
|
31
|
+
intervalMs = DEFAULT_INTERVAL_MS,
|
|
32
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
33
|
+
probe = probeTcpPort,
|
|
34
|
+
setIntervalImpl = setInterval,
|
|
35
|
+
clearIntervalImpl = clearInterval,
|
|
36
|
+
} = {}) {
|
|
37
|
+
let timer = null;
|
|
38
|
+
let sweeping = false;
|
|
39
|
+
|
|
40
|
+
async function probeNow(token) {
|
|
41
|
+
const entry = registry.get(token);
|
|
42
|
+
if (!entry) return null;
|
|
43
|
+
// Static previews have no upstream to die; they are online by definition.
|
|
44
|
+
if (entry.mode === 'static') return true;
|
|
45
|
+
const online = await probe({
|
|
46
|
+
host: entry.targetHost,
|
|
47
|
+
port: entry.targetPort,
|
|
48
|
+
timeoutMs: connectTimeoutMs,
|
|
49
|
+
});
|
|
50
|
+
registry.markHealth(entry.token, online);
|
|
51
|
+
return online;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function sweep() {
|
|
55
|
+
// A slow sweep must not overlap itself; with the registry capped at 8
|
|
56
|
+
// previews and a 250ms connect budget, one pass is bounded at ~2s anyway.
|
|
57
|
+
if (sweeping) return;
|
|
58
|
+
const previews = registry.list().filter((entry) => entry.mode !== 'static');
|
|
59
|
+
if (previews.length === 0) return;
|
|
60
|
+
sweeping = true;
|
|
61
|
+
try {
|
|
62
|
+
await Promise.all(previews.map((entry) => probeNow(entry.token)));
|
|
63
|
+
} finally {
|
|
64
|
+
sweeping = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function start() {
|
|
69
|
+
if (timer) return;
|
|
70
|
+
timer = setIntervalImpl(() => { sweep().catch(() => {}); }, intervalMs);
|
|
71
|
+
// An idle relay should not be held awake by a probe that has nothing to do.
|
|
72
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stop() {
|
|
76
|
+
if (!timer) return;
|
|
77
|
+
clearIntervalImpl(timer);
|
|
78
|
+
timer = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { start, stop, sweep, probeNow, get running() { return Boolean(timer); } };
|
|
82
|
+
}
|