@pellux/goodvibes-agent 1.9.0 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +28 -1
- package/dist/package/main.js +36631 -28780
- package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
- package/docs/README.md +1 -1
- package/docs/connected-host.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/release-and-publishing.md +5 -1
- package/docs/tools-and-commands.md +1 -0
- package/package.json +3 -3
- package/release/live-verification/live-verification.json +11 -11
- package/release/live-verification/live-verification.md +12 -12
- package/src/agent/email/email-service.ts +1 -1
- package/src/agent/email/imap-client.ts +4 -4
- package/src/agent/email/smtp-client.ts +5 -5
- package/src/cli/config-overrides.ts +29 -14
- package/src/cli/entrypoint.ts +12 -4
- package/src/cli/launch-auto-update.ts +218 -0
- package/src/cli/relay-command.ts +4 -4
- package/src/cli/service-posture.ts +2 -2
- package/src/cli/tui-startup.ts +31 -0
- package/src/cli/workspaces-command.ts +5 -1
- package/src/cli-flags.ts +1 -1
- package/src/config/index.ts +1 -1
- package/src/config/update-settings.ts +45 -0
- package/src/config/workspace-registration.ts +214 -15
- package/src/input/commands/runtime-services.ts +0 -5
- package/src/input/commands/update-runtime.ts +313 -0
- package/src/input/commands.ts +2 -0
- package/src/input/feed-context-factory.ts +2 -2
- package/src/input/handler-feed.ts +8 -8
- package/src/input/handler.ts +1 -1
- package/src/input/mcp-workspace.ts +5 -1
- package/src/input/panel-paste-flood-guard.ts +1 -1
- package/src/input/settings-modal-types.ts +11 -5
- package/src/input/settings-modal.ts +56 -61
- package/src/main.ts +19 -20
- package/src/renderer/activity-sidebar.ts +53 -4
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +37 -25
- package/src/runtime/bootstrap-core.ts +16 -5
- package/src/runtime/bootstrap-external-services.ts +107 -11
- package/src/runtime/bootstrap-hook-bridge.ts +2 -2
- package/src/runtime/bootstrap-shell.ts +4 -4
- package/src/runtime/bootstrap.ts +34 -12
- package/src/runtime/connected-host-autostart.ts +269 -0
- package/src/runtime/daemon-receipts.ts +66 -0
- package/src/runtime/diagnostics/panels/index.ts +0 -2
- package/src/runtime/feature-enablement.ts +176 -0
- package/src/runtime/index.ts +12 -29
- package/src/runtime/memory-spine-adoption.ts +18 -0
- package/src/runtime/onboarding/apply.ts +50 -37
- package/src/runtime/onboarding/types.ts +2 -2
- package/src/runtime/onboarding/verify.ts +22 -4
- package/src/runtime/release-artifacts.ts +113 -0
- package/src/runtime/services.ts +228 -19
- package/src/runtime/session-spine-rest-transport.ts +84 -4
- package/src/runtime/ui-services.ts +1 -1
- package/src/runtime/update-check.ts +64 -0
- package/src/shell/ui-openers.ts +8 -0
- package/src/tools/agent-harness-metadata.ts +8 -0
- package/src/tools/agent-harness-setup-connected-host.ts +1 -1
- package/src/tools/agent-harness-setup-posture.ts +1 -1
- package/src/version.ts +1 -1
- package/src/runtime/diagnostics/panels/ops.ts +0 -156
- 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(
|
|
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?: (
|
|
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
|
|
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
|
+
}
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -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
|
|
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
|
+
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
|
-
}
|