@pellux/goodvibes-agent 1.9.1 → 1.10.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +12 -1
  3. package/dist/package/main.js +36712 -28803
  4. package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
  5. package/docs/README.md +1 -1
  6. package/docs/connected-host.md +1 -1
  7. package/docs/getting-started.md +1 -1
  8. package/docs/tools-and-commands.md +1 -0
  9. package/package.json +3 -3
  10. package/release/live-verification/live-verification.json +11 -11
  11. package/release/live-verification/live-verification.md +12 -12
  12. package/src/agent/email/email-service.ts +1 -1
  13. package/src/agent/email/imap-client.ts +4 -4
  14. package/src/agent/email/smtp-client.ts +5 -5
  15. package/src/cli/config-overrides.ts +29 -14
  16. package/src/cli/entrypoint.ts +12 -4
  17. package/src/cli/launch-auto-update.ts +218 -0
  18. package/src/cli/relay-command.ts +4 -4
  19. package/src/cli/service-posture.ts +2 -2
  20. package/src/cli/tui-startup.ts +31 -0
  21. package/src/cli/workspaces-command.ts +5 -1
  22. package/src/cli-flags.ts +1 -1
  23. package/src/config/index.ts +1 -1
  24. package/src/config/update-settings.ts +45 -0
  25. package/src/config/workspace-registration.ts +214 -15
  26. package/src/input/commands/runtime-services.ts +0 -5
  27. package/src/input/commands/update-runtime.ts +313 -0
  28. package/src/input/commands.ts +2 -0
  29. package/src/input/feed-context-factory.ts +2 -2
  30. package/src/input/handler-feed.ts +8 -8
  31. package/src/input/handler.ts +1 -1
  32. package/src/input/mcp-workspace.ts +5 -1
  33. package/src/input/panel-paste-flood-guard.ts +1 -1
  34. package/src/input/settings-modal-types.ts +11 -5
  35. package/src/input/settings-modal.ts +56 -61
  36. package/src/main.ts +19 -20
  37. package/src/renderer/activity-sidebar.ts +53 -4
  38. package/src/renderer/settings-modal-helpers.ts +2 -0
  39. package/src/renderer/settings-modal.ts +37 -25
  40. package/src/runtime/bootstrap-core.ts +16 -5
  41. package/src/runtime/bootstrap-external-services.ts +107 -11
  42. package/src/runtime/bootstrap-hook-bridge.ts +2 -2
  43. package/src/runtime/bootstrap-shell.ts +4 -4
  44. package/src/runtime/bootstrap.ts +34 -12
  45. package/src/runtime/connected-host-autostart.ts +269 -0
  46. package/src/runtime/daemon-receipts.ts +66 -0
  47. package/src/runtime/diagnostics/panels/index.ts +0 -2
  48. package/src/runtime/feature-enablement.ts +176 -0
  49. package/src/runtime/index.ts +12 -29
  50. package/src/runtime/memory-spine-adoption.ts +18 -0
  51. package/src/runtime/onboarding/apply.ts +50 -37
  52. package/src/runtime/onboarding/types.ts +2 -2
  53. package/src/runtime/onboarding/verify.ts +22 -4
  54. package/src/runtime/release-artifacts.ts +113 -0
  55. package/src/runtime/services.ts +228 -19
  56. package/src/runtime/session-spine-rest-transport.ts +84 -4
  57. package/src/runtime/ui-services.ts +1 -1
  58. package/src/runtime/update-check.ts +64 -0
  59. package/src/shell/ui-openers.ts +8 -0
  60. package/src/tools/agent-harness-metadata.ts +8 -0
  61. package/src/tools/agent-harness-setup-connected-host.ts +1 -1
  62. package/src/tools/agent-harness-setup-posture.ts +1 -1
  63. package/src/version.ts +1 -1
  64. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  65. package/src/runtime/surface-feature-flags.ts +0 -100
@@ -34,6 +34,7 @@ import {
34
34
  type SessionRegistrationInput,
35
35
  } from '../agent/session-registration.ts';
36
36
  import { readConnectedHostOperatorToken } from './connected-host-auth.ts';
37
+ import { extractDaemonReceipts, type DaemonReceipt } from './daemon-receipts.ts';
37
38
 
38
39
  const DEFAULT_REGISTER_TIMEOUT_MS = 1_500;
39
40
  const DEFAULT_CLOSE_TIMEOUT_MS = 500;
@@ -122,12 +123,22 @@ export function createSpineRestTransport(options: SpineRestTransportOptions): Sp
122
123
  };
123
124
  }
124
125
 
125
- async function defaultProbe(connection: SessionRegistrationConnection, timeoutMs: number): Promise<boolean> {
126
+ async function defaultProbe(
127
+ connection: SessionRegistrationConnection,
128
+ timeoutMs: number,
129
+ ): Promise<boolean> {
126
130
  const controller = new AbortController();
127
131
  const timer = setTimeout(() => controller.abort(), timeoutMs);
128
132
  try {
129
133
  // Any HTTP response (even 401) means the host answered -> reachable. Auth is
130
134
  // a separate concern surfaced by register/close results, not the probe.
135
+ //
136
+ // This is a LIVENESS read only. It fetches PLAIN /status, which a current
137
+ // daemon treats as receipt-NEUTRAL: it never delivers (or marks delivered)
138
+ // the daemon's one-shot honesty receipts. Consuming those is a separate,
139
+ // explicit `?receipts=consume` read done exactly once per attach (see
140
+ // createSpineReceiptConsumer), so this frequent keepalive poll can never
141
+ // silently drain them.
131
142
  await fetch(`${connection.baseUrl}/status`, {
132
143
  headers: connection.token ? { authorization: `Bearer ${connection.token}` } : undefined,
133
144
  signal: controller.signal,
@@ -143,18 +154,87 @@ async function defaultProbe(connection: SessionRegistrationConnection, timeoutMs
143
154
  export interface SpineRestProbeOptions {
144
155
  readonly resolveConnection: () => SessionRegistrationConnection;
145
156
  readonly probeTimeoutMs?: number;
146
- /** Override for tests; default does a short GET {baseUrl}/status. */
147
- readonly probeImpl?: (connection: SessionRegistrationConnection, timeoutMs: number) => Promise<boolean>;
157
+ /** Override for tests; default does a short PLAIN GET {baseUrl}/status. */
158
+ readonly probeImpl?: (
159
+ connection: SessionRegistrationConnection,
160
+ timeoutMs: number,
161
+ ) => Promise<boolean>;
148
162
  }
149
163
 
150
164
  /**
151
165
  * Builds the zero-argument `probe` the SDK core calls directly from
152
166
  * `probeReachability()` (its injected-probe shape takes no parameters — the
153
167
  * core has no connection to hand it). This closure captures the resolver and
154
- * timeout so the core stays connection-agnostic.
168
+ * timeout so the core stays connection-agnostic. Liveness only — receipt
169
+ * consumption is {@link createSpineReceiptConsumer}, not this.
155
170
  */
156
171
  export function createSpineRestProbe(options: SpineRestProbeOptions): () => Promise<boolean> {
157
172
  const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
158
173
  const probeImpl = options.probeImpl ?? defaultProbe;
159
174
  return () => probeImpl(options.resolveConnection(), probeTimeoutMs);
160
175
  }
176
+
177
+ /**
178
+ * Performs ONE receipt-consuming /status read: it appends `?receipts=consume`,
179
+ * the only read a current daemon delivers (and then marks delivered) its
180
+ * undelivered honesty receipts to — "updated from X to Y", "restarted after a
181
+ * crash at HH:MM", settings-migration notes. A plain /status read (the liveness
182
+ * probe above) is receipt-neutral, so without this explicit consuming read the
183
+ * agent would never surface those lines against a current daemon.
184
+ *
185
+ * Delivery is destructive at the daemon (served exactly once), so this is issued
186
+ * exactly once per attach — never on the frequent liveness cadence. Returns []
187
+ * on any transport failure, a non-2xx status, or a non-JSON body; reachability
188
+ * is the probe's concern, not this read's.
189
+ */
190
+ async function defaultConsumeReceipts(
191
+ connection: SessionRegistrationConnection,
192
+ timeoutMs: number,
193
+ ): Promise<readonly DaemonReceipt[]> {
194
+ const controller = new AbortController();
195
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
196
+ try {
197
+ const url = new URL(`${connection.baseUrl}/status`);
198
+ url.searchParams.set('receipts', 'consume');
199
+ const response = await fetch(url, {
200
+ headers: connection.token ? { authorization: `Bearer ${connection.token}` } : undefined,
201
+ signal: controller.signal,
202
+ });
203
+ if (!response.ok) return [];
204
+ try {
205
+ return extractDaemonReceipts(await response.json());
206
+ } catch {
207
+ return [];
208
+ }
209
+ } catch {
210
+ return [];
211
+ } finally {
212
+ clearTimeout(timer);
213
+ }
214
+ }
215
+
216
+ export interface SpineReceiptConsumerOptions {
217
+ readonly resolveConnection: () => SessionRegistrationConnection;
218
+ readonly consumeTimeoutMs?: number;
219
+ /** Override for tests; default does a short GET {baseUrl}/status?receipts=consume. */
220
+ readonly consumeImpl?: (
221
+ connection: SessionRegistrationConnection,
222
+ timeoutMs: number,
223
+ ) => Promise<readonly DaemonReceipt[]>;
224
+ }
225
+
226
+ /**
227
+ * Builds the zero-argument receipt consumer the agent invokes once per attach
228
+ * (see the memory-spine adoption reconciler's `onAttach`). Captures the
229
+ * connection resolver and timeout; the returned function performs a single
230
+ * `?receipts=consume` read and yields whatever honesty receipts the daemon
231
+ * delivered (empty when there are none, the daemon is old/absent, or the read
232
+ * fails).
233
+ */
234
+ export function createSpineReceiptConsumer(
235
+ options: SpineReceiptConsumerOptions,
236
+ ): () => Promise<readonly DaemonReceipt[]> {
237
+ const consumeTimeoutMs = options.consumeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
238
+ const consumeImpl = options.consumeImpl ?? defaultConsumeReceipts;
239
+ return () => consumeImpl(options.resolveConnection(), consumeTimeoutMs);
240
+ }
@@ -54,7 +54,7 @@ export interface UiPlatformServices {
54
54
  readonly tokenAuditor: RuntimeServices['tokenAuditor'];
55
55
  readonly replayEngine: RuntimeServices['replayEngine'];
56
56
  readonly webhookNotifier: RuntimeServices['webhookNotifier'];
57
- /** OS-level terminal focus tracker, ported from goodvibes-tui's W2.3. */
57
+ /** OS-level terminal focus tracker, ported from goodvibes-tui's core/focus-tracker.ts. */
58
58
  readonly focusTracker: RuntimeServices['focusTracker'];
59
59
  readonly policyRuntimeState: RuntimeServices['policyRuntimeState'];
60
60
  readonly externalServices?: {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure logic for `/update` and the launch-time self-update: version
3
+ * comparison, the latest-release-tag redirect lookup, and honest install-kind
4
+ * detection.
5
+ *
6
+ * Version comparison and the release-tag lookup are re-exported from the
7
+ * SDK's canonical update policy module (platform/runtime/self-update) — one
8
+ * mechanism everywhere, the same module the TUI and the daemon's hourly loop
9
+ * consume. Install-kind detection stays local: it encodes how THIS package is
10
+ * installed (compiled binary vs bun/npm package vs source run) and what
11
+ * command replaces a swap for each kind.
12
+ *
13
+ * The self-update download/verify/swap orchestration that USES these lives in
14
+ * src/input/commands/update-runtime.ts; this module only decides "is there a
15
+ * newer version" and "can this install be swapped in place".
16
+ */
17
+ export {
18
+ compareVersions,
19
+ normalizeVersion,
20
+ parseReleaseTagFromLocation,
21
+ resolveLatestReleaseTag,
22
+ type UpdateFetchLike,
23
+ } from '@pellux/goodvibes-sdk/platform/runtime/self-update';
24
+
25
+ /**
26
+ * How this running process was installed, detected honestly from
27
+ * process.execPath rather than assumed:
28
+ * - "binary": a standalone `bun build --compile` executable with no
29
+ * package-manager ancestry — the directly-downloaded release binary
30
+ * (goodvibes-agent-<os>-<arch>). Swappable in place.
31
+ * - "bun-global-package": running the vendored binary shipped inside an
32
+ * npm/bun-managed package install (execPath contains a "node_modules"
33
+ * path segment — true for both `bun add -g` and a local project
34
+ * dependency). Managed by the package manager; swapping the vendored file
35
+ * in place would fight the next `bun add -g` upgrade, so this is never
36
+ * swapped — the user re-runs their package manager instead.
37
+ * - "source": running directly via the `bun` interpreter (`bun run
38
+ * src/main.ts`), not a compiled binary at all — a dev checkout, never
39
+ * swapped.
40
+ */
41
+ export type InstallKind = 'binary' | 'bun-global-package' | 'source';
42
+
43
+ export function detectInstallKind(execPath: string): InstallKind {
44
+ const segments = execPath.split(/[\\/]/);
45
+ const execName = (segments[segments.length - 1] ?? '').toLowerCase();
46
+ if (execName === 'bun' || execName === 'bun.exe') {
47
+ return 'source';
48
+ }
49
+ if (segments.includes('node_modules')) {
50
+ return 'bun-global-package';
51
+ }
52
+ return 'binary';
53
+ }
54
+
55
+ /**
56
+ * The exact command to tell the user to run instead of a swap, for each
57
+ * non-binary install kind. The agent's supported managed install path is the
58
+ * Bun global package (see README) — there is no curl installer for this repo,
59
+ * so both non-binary kinds point at the package manager.
60
+ */
61
+ export function fallbackUpdateCommand(kind: Exclude<InstallKind, 'binary'>): string {
62
+ void kind;
63
+ return 'bun add -g @pellux/goodvibes-agent';
64
+ }
@@ -187,6 +187,14 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
187
187
  };
188
188
 
189
189
  commandContext.openModelPicker = () => {
190
+ // Picker-open re-check: re-verify each provider's live model list (TTL-
191
+ // respecting) so a freshly-opened picker reflects models the provider
192
+ // started or stopped serving. Fire-and-forget; a completed refresh re-renders
193
+ // so the list updates in place without blocking the open. (Same wiring as
194
+ // the TUI's picker — the fix-everywhere convention for this defect class.)
195
+ void providerRegistry.refreshLiveModelDiscovery?.().then((reports) => {
196
+ if (reports.some((report) => report.added.length > 0 || report.removed.length > 0)) render();
197
+ }).catch(() => {});
190
198
  void (async () => {
191
199
  const catalogModels = providerRegistry.getSelectableModels();
192
200
  const configuredIds = new Set(getConfiguredProviderIds());
@@ -144,6 +144,14 @@ export function describeCommandPolicy(commandName: string): CommandExecutionPoli
144
144
  boundary: 'Diagnostics and review commands inspect Agent, provider, MCP, security, and connected-host readiness without taking lifecycle ownership.',
145
145
  };
146
146
  }
147
+ if (root === 'update' || root === 'upgrade') {
148
+ return {
149
+ effect: 'mixed',
150
+ confirmation,
151
+ preferredModelTool: agentHarnessModes('run_command'),
152
+ boundary: 'Check is a read-only release lookup; apply downloads, checksum-verifies, and atomically swaps the installed binary (and vector addon) keeping the previous version beside it, and rollback exchanges the kept previous version back in. Apply and rollback change the installed program and require explicit user intent.',
153
+ };
154
+ }
147
155
  if (root === 'trust' || root === 'auth' || root === 'bundle') {
148
156
  return {
149
157
  effect: 'mixed',
@@ -496,6 +496,6 @@ export function connectedHostBootstrapPlan(
496
496
  serviceDiagnostics: 'host action:"services" includeParameters:true',
497
497
  setupItem: 'setup action:"item" setupItemId:"connected-host-readiness"',
498
498
  },
499
- policy: 'Bootstrap commands are user-run setup guidance. Agent does not run host install/start commands implicitly; once the host is reachable, exact service mutations stay on confirmed operator methods.',
499
+ policy: 'Bootstrap commands are user-run setup guidance. At boot the Agent runtime starts an already-installed host whose service is stopped, through the platform service manager, and reports it; beyond that one bounded boot behavior the Agent does not run host install/start commands implicitly, and once the host is reachable exact service mutations stay on confirmed operator methods.',
500
500
  };
501
501
  }
@@ -92,7 +92,7 @@ export async function setupRepairSummary(context: CommandContext, args: AgentHar
92
92
  effect: 'read-only-repair-decision',
93
93
  boundary: 'This route chooses the safest next setup repair route only. It never starts, installs, restarts, writes tokens, imports settings, or opens UI by itself.',
94
94
  confirmation: 'Any returned confirmed-effect route still requires confirm:true and explicitUserRequest tied to the user request.',
95
- hostOwnership: 'GoodVibes Agent does not take ambient ownership of the GoodVibes host lifecycle; disconnected hosts use user-run bootstrap guidance, and reachable hosts use daemon receipts before lifecycle mutation.',
95
+ hostOwnership: 'GoodVibes Agent does not take ambient ownership of the GoodVibes host lifecycle. At boot it starts an installed-but-stopped host once through the platform service manager and reports it; beyond that, disconnected hosts use user-run bootstrap guidance, and reachable hosts use daemon receipts before lifecycle mutation.',
96
96
  },
97
97
  };
98
98
  }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.9.1';
9
+ let _version = '1.10.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8')) as {
12
12
  readonly version?: unknown;
@@ -1,156 +0,0 @@
1
- /**
2
- * OpsPanel — diagnostic data provider for operator interventions.
3
- *
4
- * Subscribes to OPS_AUDIT events from the UI-facing ops event feed and maintains a
5
- * bounded buffer of intervention records for display
6
- * in the Ops panel.
7
- *
8
- * Controls are only shown when the OpsControlPlane reports the action is legal
9
- * (state machine allows it), satisfying requirement: "No illegal action appears in UI".
10
- */
11
- import type { RuntimeEventEnvelope } from '@/runtime/index.ts';
12
- import type { PanelConfig } from '@/runtime/index.ts';
13
- import { DEFAULT_PANEL_CONFIG, appendBounded, applyFilter } from '@/runtime/index.ts';
14
- import type { DiagnosticFilter } from '@/runtime/index.ts';
15
- import type { OpsInterventionReason, OpsEvent } from '@/runtime/index.ts';
16
- import type { UiEventFeed } from '../../ui-events.ts';
17
-
18
- // ---------------------------------------------------------------------------
19
- // Audit entry
20
- // ---------------------------------------------------------------------------
21
-
22
- /**
23
- * Immutable audit record for a single operator intervention.
24
- */
25
- export interface OpsAuditEntry {
26
- /** Monotonic sequence number. */
27
- readonly seq: number;
28
- /** Epoch ms when the intervention was recorded. */
29
- readonly ts: number;
30
- /** The action taken (e.g. 'task.cancel', 'agent.cancel'). */
31
- readonly action: string;
32
- /** The target task or agent ID. */
33
- readonly targetId: string;
34
- /** Whether this intervention targeted a task or an agent. */
35
- readonly targetKind: 'task' | 'agent';
36
- /** The reason code for the intervention. */
37
- readonly reason: OpsInterventionReason;
38
- /** Optional operator note. */
39
- readonly note?: string;
40
- /** Outcome of the intervention. */
41
- readonly outcome: 'success' | 'rejected' | 'error';
42
- /** Error message if outcome is not success. */
43
- readonly errorMessage?: string;
44
- /** Trace identifier from the event envelope. */
45
- readonly traceId: string;
46
- /** Session identifier from the event envelope. */
47
- readonly sessionId: string;
48
- }
49
-
50
- // ---------------------------------------------------------------------------
51
- // OpsPanel
52
- // ---------------------------------------------------------------------------
53
-
54
- /** Internal mutable record (same shape as immutable OpsAuditEntry). */
55
- type MutableAuditRecord = {
56
- seq: number;
57
- ts: number;
58
- action: string;
59
- targetId: string;
60
- targetKind: 'task' | 'agent';
61
- reason: OpsInterventionReason;
62
- note?: string;
63
- outcome: 'success' | 'rejected' | 'error';
64
- errorMessage?: string;
65
- traceId: string;
66
- sessionId: string;
67
- };
68
-
69
- /** Type alias for the OPS_AUDIT event shape. */
70
- type OpsAuditEvent = Extract<OpsEvent, { type: 'OPS_AUDIT' }>;
71
-
72
- export class OpsPanel {
73
- private readonly _config: PanelConfig;
74
- private readonly _events: UiEventFeed<OpsEvent>;
75
-
76
- private readonly _audit: MutableAuditRecord[] = [];
77
- private _seq = 0;
78
-
79
- private readonly _subscribers = new Set<() => void>();
80
- private _unsub: (() => void) | null = null;
81
-
82
- public constructor(
83
- events: UiEventFeed<OpsEvent>,
84
- config: PanelConfig = DEFAULT_PANEL_CONFIG
85
- ) {
86
- this._config = config;
87
- this._events = events;
88
- this._start();
89
- }
90
-
91
- private _start(): void {
92
- this._unsub = this._events.onEnvelope('OPS_AUDIT', (envelope) => {
93
- this._handleAudit(envelope as RuntimeEventEnvelope<'OPS_AUDIT', OpsAuditEvent>);
94
- });
95
- }
96
-
97
- private _handleAudit(
98
- envelope: RuntimeEventEnvelope<'OPS_AUDIT', OpsAuditEvent>
99
- ): void {
100
- const payload = envelope.payload;
101
-
102
- const record: MutableAuditRecord = {
103
- seq: ++this._seq,
104
- ts: envelope.ts,
105
- action: payload.action,
106
- targetId: payload.targetId,
107
- targetKind: payload.targetKind,
108
- reason: payload.reason,
109
- note: payload.note,
110
- outcome: payload.outcome,
111
- errorMessage: payload.errorMessage,
112
- traceId: envelope.traceId ?? '',
113
- sessionId: envelope.sessionId ?? '',
114
- };
115
-
116
- appendBounded(this._audit, record, this._config.bufferLimit);
117
- this._notify();
118
- }
119
-
120
- /**
121
- * Returns a filtered snapshot of the audit log, most recent first.
122
- */
123
- public getSnapshot(filter?: DiagnosticFilter): OpsAuditEntry[] {
124
- const entries = this._audit.map((r) => ({ ...r }) as OpsAuditEntry);
125
- return applyFilter(entries, filter, (e) => e.ts);
126
- }
127
-
128
- /**
129
- * Subscribe to data changes. Returns an unsubscribe function.
130
- */
131
- public subscribe(callback: () => void): () => void {
132
- this._subscribers.add(callback);
133
- return () => { this._subscribers.delete(callback); };
134
- }
135
-
136
- /**
137
- * Dispose the panel, unsubscribing from the event bus.
138
- */
139
- public dispose(): void {
140
- if (this._unsub) {
141
- this._unsub();
142
- this._unsub = null;
143
- }
144
- this._subscribers.clear();
145
- }
146
-
147
- private _notify(): void {
148
- for (const cb of this._subscribers) {
149
- try {
150
- cb();
151
- } catch {
152
- // Non-fatal — subscriber errors must not crash the panel
153
- }
154
- }
155
- }
156
- }
@@ -1,100 +0,0 @@
1
- import type { ConfigManager, PersistedFlagState } from '../config/index.ts';
2
- import { surfaceFeatureGateId } from '@/runtime/index.ts';
3
-
4
- export const CONTROL_PLANE_FEATURE_FLAG = 'control-plane-gateway';
5
- export const ROUTE_BINDING_FEATURE_FLAG = 'route-binding';
6
- export const DELIVERY_ENGINE_FEATURE_FLAG = 'delivery-engine';
7
- export const SERVICE_MANAGEMENT_FEATURE_FLAG = 'service-management';
8
-
9
- const CORE_CHANNEL_FEATURE_FLAGS = [
10
- CONTROL_PLANE_FEATURE_FLAG,
11
- ROUTE_BINDING_FEATURE_FLAG,
12
- DELIVERY_ENGINE_FEATURE_FLAG,
13
- ] as const;
14
-
15
- export type FeatureFlagConfigKey = 'featureFlags' | `featureFlags.${string}`;
16
-
17
- function isRecord(value: unknown): value is Record<string, unknown> {
18
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
19
- }
20
-
21
- export function isFeatureFlagConfigKey(key: string): key is FeatureFlagConfigKey {
22
- return key === 'featureFlags' || key.startsWith('featureFlags.');
23
- }
24
-
25
- function isPersistedFlagState(value: unknown): value is PersistedFlagState {
26
- return value === 'enabled' || value === 'disabled';
27
- }
28
-
29
- export function readFeatureFlagConfigValue(config: Pick<ConfigManager, 'getCategory'>, key: FeatureFlagConfigKey): unknown {
30
- const flags = config.getCategory('featureFlags');
31
- if (key === 'featureFlags') return flags;
32
- return flags[key.slice('featureFlags.'.length)];
33
- }
34
-
35
- export function mergeFeatureFlagConfigValue(config: Pick<ConfigManager, 'mergeCategory'>, key: FeatureFlagConfigKey, value: unknown): void {
36
- if (key === 'featureFlags') {
37
- if (!isRecord(value)) throw new Error('featureFlags expects an object value.');
38
- const patch: Partial<Record<string, PersistedFlagState>> = {};
39
- for (const [flagId, state] of Object.entries(value)) {
40
- if (!isPersistedFlagState(state)) throw new Error(`featureFlags.${flagId} expects enabled or disabled.`);
41
- patch[flagId] = state;
42
- }
43
- config.mergeCategory('featureFlags', patch);
44
- return;
45
- }
46
-
47
- if (!isPersistedFlagState(value)) throw new Error(`${key} expects enabled or disabled.`);
48
- config.mergeCategory('featureFlags', {
49
- [key.slice('featureFlags.'.length)]: value,
50
- });
51
- }
52
-
53
- export function getSurfaceFeatureFlag(surfaceId: string): string | null {
54
- return surfaceFeatureGateId(surfaceId);
55
- }
56
-
57
- export function getServerSurfaceFeatureFlags(options: {
58
- readonly serverBacked?: boolean;
59
- readonly web?: boolean;
60
- readonly externalSurfaces?: readonly string[];
61
- }): readonly string[] {
62
- const flags = new Set<string>();
63
- const hasExternalSurfaces = (options.externalSurfaces?.length ?? 0) > 0;
64
-
65
- if (options.serverBacked || options.web || hasExternalSurfaces) {
66
- flags.add(CONTROL_PLANE_FEATURE_FLAG);
67
- flags.add(SERVICE_MANAGEMENT_FEATURE_FLAG);
68
- }
69
- if (options.web) {
70
- const webFlag = getSurfaceFeatureFlag('web');
71
- if (webFlag) flags.add(webFlag);
72
- }
73
-
74
- if (hasExternalSurfaces) {
75
- for (const flag of CORE_CHANNEL_FEATURE_FLAGS) flags.add(flag);
76
- for (const surfaceId of options.externalSurfaces ?? []) {
77
- const surfaceFlag = getSurfaceFeatureFlag(surfaceId);
78
- if (surfaceFlag) flags.add(surfaceFlag);
79
- }
80
- }
81
-
82
- return [...flags].sort((left, right) => left.localeCompare(right));
83
- }
84
-
85
- export function isFeatureFlagEnabled(config: Pick<ConfigManager, 'getCategory'>, flagId: string): boolean {
86
- const flags = config.getCategory('featureFlags');
87
- return flags[flagId] === 'enabled';
88
- }
89
-
90
- export function getMissingSurfaceFeatureFlags(config: Pick<ConfigManager, 'getCategory'>, surfaceId: string): readonly string[] {
91
- const required = surfaceId === 'web'
92
- ? getServerSurfaceFeatureFlags({ web: true })
93
- : getServerSurfaceFeatureFlags({ externalSurfaces: [surfaceId] });
94
- return required.filter((flagId) => !isFeatureFlagEnabled(config, flagId));
95
- }
96
-
97
- export function enableFeatureFlags(config: Pick<ConfigManager, 'mergeCategory'>, flagIds: readonly string[]): void {
98
- const patch = Object.fromEntries(flagIds.map((flagId) => [flagId, 'enabled'] as const));
99
- config.mergeCategory('featureFlags', patch);
100
- }