@pellux/goodvibes-tui 0.24.1 → 0.25.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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
@@ -0,0 +1,71 @@
1
+ import type { DaemonCredentialStore } from '../../operator/index.ts';
2
+ import type { OperatorLogger } from '../../operator/index.ts';
3
+ import type { PeerRecord } from '../peer-registry.ts';
4
+
5
+ /**
6
+ * Payload accepted alongside a command on remote.peers.invoke. All fields are
7
+ * optional; backends interpret what they support.
8
+ */
9
+ export interface DispatchPayload {
10
+ /** Positional args appended to the command (already tokenized). */
11
+ args?: string[];
12
+ /** Data piped to the process stdin. */
13
+ stdin?: string;
14
+ /** Per-invocation environment overlay (never includes secrets). */
15
+ env?: Record<string, string>;
16
+ /** Hard timeout for synchronous execution, in milliseconds. */
17
+ timeoutMs?: number;
18
+ /** Working directory override (backend-dependent). */
19
+ cwd?: string;
20
+ }
21
+
22
+ export interface BackendDispatchResult {
23
+ exitCode?: number;
24
+ workId?: string;
25
+ stdout: string;
26
+ stderr: string;
27
+ }
28
+
29
+ export interface BackendContext {
30
+ credentials: DaemonCredentialStore;
31
+ logger: OperatorLogger;
32
+ /** Daemon home dir — used for ephemeral key material under a 0700 subdir. */
33
+ homeDirectory: string;
34
+ }
35
+
36
+ /**
37
+ * A remote execution backend. Each backend dispatches a single command for a
38
+ * given peer and returns the captured stdout/stderr plus an exit code.
39
+ *
40
+ * Backends must NEVER place raw credentials in the returned stdout/stderr or in
41
+ * any thrown error message. Credentials are resolved internally from the daemon
42
+ * credential store via secret references on the peer's backendConfig.
43
+ */
44
+ export interface Backend {
45
+ readonly kind: PeerRecord['backendKind'];
46
+ dispatch(
47
+ peer: PeerRecord,
48
+ command: string,
49
+ payload?: DispatchPayload,
50
+ ): Promise<BackendDispatchResult>;
51
+ }
52
+
53
+ export const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
54
+ export const MAX_SYNC_TIMEOUT_MS = 600_000;
55
+
56
+ export function resolveTimeout(payload?: DispatchPayload): number {
57
+ const requested = payload?.timeoutMs;
58
+ if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) {
59
+ return Math.min(requested, MAX_SYNC_TIMEOUT_MS);
60
+ }
61
+ return DEFAULT_SYNC_TIMEOUT_MS;
62
+ }
63
+
64
+ export class BackendDispatchError extends Error {
65
+ readonly code: string;
66
+ constructor(message: string, code = 'REMOTE_BACKEND_DISPATCH_FAILED') {
67
+ super(message);
68
+ this.name = 'BackendDispatchError';
69
+ this.code = code;
70
+ }
71
+ }
@@ -0,0 +1,160 @@
1
+ import { sha256First } from '../operator/index.ts';
2
+ import type { OperatorLogger, DaemonCredentialStore } from '../operator/index.ts';
3
+ import { PeerRegistry, type PeerRecord } from './peer-registry.ts';
4
+ import {
5
+ type Backend,
6
+ type BackendContext,
7
+ type DispatchPayload,
8
+ BackendDispatchError,
9
+ createBackends,
10
+ } from './backends/index.ts';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Work-item hook — long-running invocations are enqueued as work items visible
14
+ // in remote.work.list. The surface does not own the distributed runtime; the
15
+ // integrator wires this hook to the existing remoteRunnerRegistry.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface RemoteWorkItemInput {
19
+ peerId: string;
20
+ command: string;
21
+ payload?: DispatchPayload;
22
+ /** Echoed onto the work item so the runner can pick the right backend. */
23
+ backendKind: PeerRecord['backendKind'];
24
+ queuedBy: string;
25
+ }
26
+
27
+ export interface RemoteWorkEnqueuer {
28
+ enqueue(item: RemoteWorkItemInput): Promise<{ workId: string }>;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Invoke result — returned to the agent through remote.peers.invoke. Includes
33
+ // stdoutDigest (sha256 of FULL stdout, 64 hex chars) per the receipt contract.
34
+ // The agent may receive only a truncated stdout preview.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export const STDOUT_PREVIEW_LIMIT = 4_096;
38
+
39
+ export interface RemoteInvokeResult {
40
+ peerId: string;
41
+ backendKind: PeerRecord['backendKind'];
42
+ /** Present for synchronous completion. */
43
+ exitCode?: number;
44
+ /** Present for async/long-running dispatch. */
45
+ workId?: string;
46
+ completed: boolean;
47
+ stdout: string;
48
+ stderr: string;
49
+ /** SHA-256 of the full stdout, 64 hex chars. */
50
+ stdoutDigest: string;
51
+ }
52
+
53
+ export interface RemoteDispatcherOptions {
54
+ registry: PeerRegistry;
55
+ credentials: DaemonCredentialStore;
56
+ logger: OperatorLogger;
57
+ homeDirectory: string;
58
+ /** Optional hook for enqueuing long-running work items. */
59
+ workEnqueuer?: RemoteWorkEnqueuer;
60
+ /** Override the backend map (tests inject fakes). */
61
+ backends?: Map<PeerRecord['backendKind'], Backend>;
62
+ }
63
+
64
+ export interface DispatchRequest {
65
+ peerId: string;
66
+ command: string;
67
+ payload?: DispatchPayload;
68
+ /** Principal that requested the dispatch (for work-item attribution). */
69
+ principalId: string;
70
+ /** When true (and a work enqueuer exists), run as an async work item. */
71
+ async?: boolean;
72
+ }
73
+
74
+ function truncate(value: string, limit: number): string {
75
+ return value.length > limit ? value.slice(0, limit) : value;
76
+ }
77
+
78
+ /**
79
+ * Routes remote.peers.invoke commands to the correct execution backend.
80
+ * Synchronous commands return an exitCode; long-running commands (async:true
81
+ * with a configured work enqueuer) return a workId tracked via remote.work.list.
82
+ */
83
+ export class RemoteDispatcher {
84
+ private readonly registry: PeerRegistry;
85
+ private readonly backends: Map<PeerRecord['backendKind'], Backend>;
86
+ private readonly workEnqueuer?: RemoteWorkEnqueuer;
87
+ private readonly logger: OperatorLogger;
88
+
89
+ constructor(options: RemoteDispatcherOptions) {
90
+ this.registry = options.registry;
91
+ this.logger = options.logger;
92
+ if (options.workEnqueuer) this.workEnqueuer = options.workEnqueuer;
93
+ const backendContext: BackendContext = {
94
+ credentials: options.credentials,
95
+ logger: options.logger,
96
+ homeDirectory: options.homeDirectory,
97
+ };
98
+ this.backends = options.backends ?? createBackends(backendContext);
99
+ }
100
+
101
+ async dispatch(request: DispatchRequest): Promise<RemoteInvokeResult> {
102
+ const peerId = typeof request.peerId === 'string' ? request.peerId.trim() : '';
103
+ if (peerId.length === 0) {
104
+ throw new BackendDispatchError('peerId is required.', 'REMOTE_PEER_ID_REQUIRED');
105
+ }
106
+ const command = typeof request.command === 'string' ? request.command : '';
107
+ if (command.trim().length === 0) {
108
+ throw new BackendDispatchError('command is required.', 'REMOTE_COMMAND_REQUIRED');
109
+ }
110
+ const peer = this.registry.get(peerId);
111
+ if (!peer) {
112
+ throw new BackendDispatchError(
113
+ `No registered peer with id '${peerId}'.`,
114
+ 'REMOTE_PEER_NOT_FOUND',
115
+ );
116
+ }
117
+ const backend = this.backends.get(peer.backendKind);
118
+ if (!backend) {
119
+ throw new BackendDispatchError(
120
+ `No backend available for kind '${peer.backendKind}'.`,
121
+ 'REMOTE_BACKEND_UNAVAILABLE',
122
+ );
123
+ }
124
+
125
+ // Async path: enqueue a work item and return its id immediately.
126
+ if (request.async === true && this.workEnqueuer) {
127
+ const { workId } = await this.workEnqueuer.enqueue({
128
+ peerId: peer.peerId,
129
+ command,
130
+ backendKind: peer.backendKind,
131
+ queuedBy: request.principalId,
132
+ ...(request.payload !== undefined ? { payload: request.payload } : {}),
133
+ });
134
+ this.logger.info('remote invoke enqueued', { peerId: peer.peerId, workId });
135
+ return {
136
+ peerId: peer.peerId,
137
+ backendKind: peer.backendKind,
138
+ workId,
139
+ completed: false,
140
+ stdout: '',
141
+ stderr: '',
142
+ stdoutDigest: sha256First('', 64),
143
+ };
144
+ }
145
+
146
+ // Synchronous path: run on the backend and capture output.
147
+ const result = await backend.dispatch(peer, command, request.payload);
148
+ const fullStdout = result.stdout ?? '';
149
+ return {
150
+ peerId: peer.peerId,
151
+ backendKind: peer.backendKind,
152
+ ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
153
+ ...(result.workId !== undefined ? { workId: result.workId } : {}),
154
+ completed: result.workId === undefined,
155
+ stdout: truncate(fullStdout, STDOUT_PREVIEW_LIMIT),
156
+ stderr: truncate(result.stderr ?? '', STDOUT_PREVIEW_LIMIT),
157
+ stdoutDigest: sha256First(fullStdout, 64),
158
+ };
159
+ }
160
+ }
@@ -0,0 +1,74 @@
1
+ // Remote Execution Backends surface (Docker / SSH / Cloud Terminal / local).
2
+ //
3
+ // Publishes the daemon-internal `remote.peers.register` operator method and a
4
+ // dispatch adapter for the already-published `remote.peers.invoke` route. The
5
+ // integrator attaches the dispatch adapter to the existing invoke router and
6
+ // (optionally) wires the work enqueuer to the distributed runtime work queue.
7
+
8
+ export {
9
+ PeerRegistry,
10
+ PeerRegistryValidationError,
11
+ normalizeBackendConfig,
12
+ } from './peer-registry.ts';
13
+ export type {
14
+ BackendKind,
15
+ CloudProvider,
16
+ BackendConfig,
17
+ DockerBackendConfig,
18
+ SshBackendConfig,
19
+ CloudTerminalBackendConfig,
20
+ LocalProcessBackendConfig,
21
+ PeerRecord,
22
+ PeerRegistrationInput,
23
+ } from './peer-registry.ts';
24
+
25
+ export {
26
+ createBackends,
27
+ createLocalProcessBackend,
28
+ createDockerBackend,
29
+ createSshBackend,
30
+ createCloudTerminalBackend,
31
+ tokenizeCommand,
32
+ runProcess,
33
+ BackendDispatchError,
34
+ resolveTimeout,
35
+ DEFAULT_SYNC_TIMEOUT_MS,
36
+ MAX_SYNC_TIMEOUT_MS,
37
+ } from './backends/index.ts';
38
+ export type {
39
+ Backend,
40
+ BackendContext,
41
+ BackendDispatchResult,
42
+ DispatchPayload,
43
+ RunOptions,
44
+ RunResult,
45
+ } from './backends/index.ts';
46
+
47
+ export {
48
+ RemoteDispatcher,
49
+ STDOUT_PREVIEW_LIMIT,
50
+ } from './dispatcher.ts';
51
+ export type {
52
+ RemoteDispatcherOptions,
53
+ RemoteInvokeResult,
54
+ RemoteWorkEnqueuer,
55
+ RemoteWorkItemInput,
56
+ DispatchRequest,
57
+ } from './dispatcher.ts';
58
+
59
+ export {
60
+ createRemoteSurface,
61
+ registerRemoteMethods,
62
+ registerRemoteDispatch,
63
+ attachRemoteInvokeRoute,
64
+ REMOTE_PEERS_REGISTER,
65
+ REMOTE_PEERS_INVOKE,
66
+ REMOTE_PEERS_INVOKE_DESCRIPTOR,
67
+ } from './register.ts';
68
+ export type {
69
+ RemoteSurface,
70
+ RemoteSurfaceOptions,
71
+ RegisteredRemoteMethods,
72
+ RemoteInvokeAdapter,
73
+ RegisterPeerResult,
74
+ } from './register.ts';
@@ -0,0 +1,321 @@
1
+ import { OperatorSqliteStore } from '../operator/index.ts';
2
+ import { isSecretReferenceValue } from '../../config/secret-config.ts';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Backend vocabulary + per-backend config shapes
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type BackendKind = 'docker' | 'ssh' | 'cloud-terminal' | 'local-process';
9
+
10
+ export type CloudProvider = 'gcp' | 'aws' | 'azure';
11
+
12
+ export interface DockerBackendConfig {
13
+ containerName: string;
14
+ /**
15
+ * Optional Docker host. When it points at a remote daemon over TLS this MUST
16
+ * be a goodvibes://secrets/ reference (never a raw URL with embedded creds).
17
+ * A bare local socket path (unix://...) or tcp host without credentials is
18
+ * also accepted.
19
+ */
20
+ dockerHost?: string;
21
+ }
22
+
23
+ export interface SshBackendConfig {
24
+ sshHost: string;
25
+ sshPort?: number;
26
+ sshUser: string;
27
+ /** goodvibes://secrets/ reference to the private key — never the raw key. */
28
+ identityRef: string;
29
+ }
30
+
31
+ export interface CloudTerminalBackendConfig {
32
+ provider: CloudProvider;
33
+ projectId?: string;
34
+ /** goodvibes://secrets/ reference to the provider credential. */
35
+ credentialRef: string;
36
+ /** Optional zone/region/location passed to the provider CLI. */
37
+ location?: string;
38
+ /** For gcp: the Cloud Shell / VM instance to target. */
39
+ instance?: string;
40
+ }
41
+
42
+ export interface LocalProcessBackendConfig {
43
+ /** Optional working directory for spawned processes. */
44
+ cwd?: string;
45
+ /** Optional allowlist of executables; when set, only these may be invoked. */
46
+ allowedCommands?: string[];
47
+ }
48
+
49
+ export type BackendConfig =
50
+ | ({ kind: 'docker' } & DockerBackendConfig)
51
+ | ({ kind: 'ssh' } & SshBackendConfig)
52
+ | ({ kind: 'cloud-terminal' } & CloudTerminalBackendConfig)
53
+ | ({ kind: 'local-process' } & LocalProcessBackendConfig);
54
+
55
+ export interface PeerRecord {
56
+ peerId: string;
57
+ displayName: string;
58
+ backendKind: BackendKind;
59
+ backendConfig: BackendConfig;
60
+ }
61
+
62
+ export interface PeerRegistrationInput {
63
+ peerId: string;
64
+ displayName: string;
65
+ backendKind: BackendKind;
66
+ // Raw, untrusted config from the operator method body. Normalized + validated.
67
+ backendConfig: Record<string, unknown>;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Validation helpers — backendConfig must hold ONLY secret refs for any
72
+ // credential-bearing field. Raw secrets are rejected outright.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ class PeerRegistryValidationError extends Error {
76
+ constructor(message: string) {
77
+ super(message);
78
+ this.name = 'PeerRegistryValidationError';
79
+ }
80
+ }
81
+
82
+ function requireString(value: unknown, field: string): string {
83
+ if (typeof value !== 'string' || value.trim().length === 0) {
84
+ throw new PeerRegistryValidationError(`Field '${field}' is required and must be a non-empty string.`);
85
+ }
86
+ return value.trim();
87
+ }
88
+
89
+ function optionalString(value: unknown, field: string): string | undefined {
90
+ if (value === undefined || value === null) return undefined;
91
+ if (typeof value !== 'string') {
92
+ throw new PeerRegistryValidationError(`Field '${field}' must be a string when provided.`);
93
+ }
94
+ const trimmed = value.trim();
95
+ return trimmed.length === 0 ? undefined : trimmed;
96
+ }
97
+
98
+ function requireSecretRef(value: unknown, field: string): string {
99
+ const ref = requireString(value, field);
100
+ if (!isSecretReferenceValue(ref)) {
101
+ throw new PeerRegistryValidationError(
102
+ `Field '${field}' must be a goodvibes://secrets/ reference, not a raw credential.`,
103
+ );
104
+ }
105
+ return ref;
106
+ }
107
+
108
+ function assertSecretRefIfPresent(value: string | undefined, field: string): void {
109
+ // A dockerHost may legitimately be a non-credential socket/tcp address, but if
110
+ // it looks like it embeds credentials (userinfo segment) it must be a ref.
111
+ if (value === undefined) return;
112
+ if (value.includes('@') && !isSecretReferenceValue(value)) {
113
+ throw new PeerRegistryValidationError(
114
+ `Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
115
+ );
116
+ }
117
+ }
118
+
119
+ /** Normalize + validate raw backendConfig into a typed, ref-only BackendConfig. */
120
+ export function normalizeBackendConfig(
121
+ backendKind: BackendKind,
122
+ raw: Record<string, unknown>,
123
+ ): BackendConfig {
124
+ switch (backendKind) {
125
+ case 'docker': {
126
+ const dockerHost = optionalString(raw.dockerHost, 'dockerHost');
127
+ assertSecretRefIfPresent(dockerHost, 'dockerHost');
128
+ return {
129
+ kind: 'docker',
130
+ containerName: requireString(raw.containerName, 'containerName'),
131
+ ...(dockerHost !== undefined ? { dockerHost } : {}),
132
+ };
133
+ }
134
+ case 'ssh': {
135
+ const portValue = raw.sshPort;
136
+ let sshPort: number | undefined;
137
+ if (portValue !== undefined && portValue !== null) {
138
+ const parsed = typeof portValue === 'number' ? portValue : Number(portValue);
139
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
140
+ throw new PeerRegistryValidationError("Field 'sshPort' must be an integer between 1 and 65535.");
141
+ }
142
+ sshPort = parsed;
143
+ }
144
+ return {
145
+ kind: 'ssh',
146
+ sshHost: requireString(raw.sshHost, 'sshHost'),
147
+ sshUser: requireString(raw.sshUser, 'sshUser'),
148
+ identityRef: requireSecretRef(raw.identityRef, 'identityRef'),
149
+ ...(sshPort !== undefined ? { sshPort } : {}),
150
+ };
151
+ }
152
+ case 'cloud-terminal': {
153
+ const provider = requireString(raw.provider, 'provider');
154
+ if (provider !== 'gcp' && provider !== 'aws' && provider !== 'azure') {
155
+ throw new PeerRegistryValidationError("Field 'provider' must be one of 'gcp' | 'aws' | 'azure'.");
156
+ }
157
+ const projectId = optionalString(raw.projectId, 'projectId');
158
+ const location = optionalString(raw.location, 'location');
159
+ const instance = optionalString(raw.instance, 'instance');
160
+ return {
161
+ kind: 'cloud-terminal',
162
+ provider,
163
+ credentialRef: requireSecretRef(raw.credentialRef, 'credentialRef'),
164
+ ...(projectId !== undefined ? { projectId } : {}),
165
+ ...(location !== undefined ? { location } : {}),
166
+ ...(instance !== undefined ? { instance } : {}),
167
+ };
168
+ }
169
+ case 'local-process': {
170
+ const cwd = optionalString(raw.cwd, 'cwd');
171
+ let allowedCommands: string[] | undefined;
172
+ if (raw.allowedCommands !== undefined && raw.allowedCommands !== null) {
173
+ if (!Array.isArray(raw.allowedCommands)) {
174
+ throw new PeerRegistryValidationError("Field 'allowedCommands' must be an array of strings.");
175
+ }
176
+ const list = raw.allowedCommands
177
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
178
+ .filter((entry) => entry.length > 0);
179
+ allowedCommands = list;
180
+ }
181
+ return {
182
+ kind: 'local-process',
183
+ ...(cwd !== undefined ? { cwd } : {}),
184
+ ...(allowedCommands !== undefined ? { allowedCommands } : {}),
185
+ };
186
+ }
187
+ default:
188
+ throw new PeerRegistryValidationError(`Unknown backendKind: ${String(backendKind)}`);
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Peer registry — persisted via OperatorSqliteStore
194
+ // ---------------------------------------------------------------------------
195
+
196
+ const PEER_REGISTRY_FILE = 'remote-peers.sqlite';
197
+
198
+ const SCHEMA = [
199
+ `CREATE TABLE IF NOT EXISTS peers (
200
+ peerId TEXT PRIMARY KEY,
201
+ displayName TEXT NOT NULL,
202
+ backendKind TEXT NOT NULL,
203
+ backendConfig TEXT NOT NULL
204
+ )`,
205
+ ];
206
+
207
+ interface PeerRow {
208
+ peerId: string;
209
+ displayName: string;
210
+ backendKind: string;
211
+ backendConfig: string;
212
+ }
213
+
214
+ const VALID_BACKEND_KINDS: ReadonlySet<BackendKind> = new Set([
215
+ 'docker',
216
+ 'ssh',
217
+ 'cloud-terminal',
218
+ 'local-process',
219
+ ]);
220
+
221
+ function rowToRecord(row: PeerRow): PeerRecord {
222
+ const backendKind = row.backendKind as BackendKind;
223
+ const parsed = JSON.parse(row.backendConfig) as BackendConfig;
224
+ return {
225
+ peerId: row.peerId,
226
+ displayName: row.displayName,
227
+ backendKind,
228
+ backendConfig: parsed,
229
+ };
230
+ }
231
+
232
+ export class PeerRegistry {
233
+ private readonly store: OperatorSqliteStore;
234
+ private initialized = false;
235
+
236
+ constructor(workingDirectory: string) {
237
+ this.store = new OperatorSqliteStore({
238
+ workingDirectory,
239
+ fileName: PEER_REGISTRY_FILE,
240
+ schema: SCHEMA,
241
+ });
242
+ }
243
+
244
+ get dbPath(): string {
245
+ return this.store.dbPath;
246
+ }
247
+
248
+ async init(): Promise<void> {
249
+ if (this.initialized) return;
250
+ await this.store.init();
251
+ this.initialized = true;
252
+ }
253
+
254
+ private requireInit(): void {
255
+ if (!this.initialized) {
256
+ throw new Error('PeerRegistry not initialized: call init() first.');
257
+ }
258
+ }
259
+
260
+ /** Register (upsert) a peer. Validates + normalizes backendConfig to refs-only. */
261
+ async register(input: PeerRegistrationInput): Promise<PeerRecord> {
262
+ this.requireInit();
263
+ const peerId = requireString(input.peerId, 'peerId');
264
+ const displayName = requireString(input.displayName, 'displayName');
265
+ if (!VALID_BACKEND_KINDS.has(input.backendKind)) {
266
+ throw new PeerRegistryValidationError(`Unknown backendKind: ${String(input.backendKind)}`);
267
+ }
268
+ const backendConfig = normalizeBackendConfig(input.backendKind, input.backendConfig ?? {});
269
+ const serialized = JSON.stringify(backendConfig);
270
+ this.store.run(
271
+ `INSERT INTO peers (peerId, displayName, backendKind, backendConfig)
272
+ VALUES (?, ?, ?, ?)
273
+ ON CONFLICT(peerId) DO UPDATE SET
274
+ displayName = excluded.displayName,
275
+ backendKind = excluded.backendKind,
276
+ backendConfig = excluded.backendConfig`,
277
+ [peerId, displayName, input.backendKind, serialized],
278
+ );
279
+ await this.store.save();
280
+ return { peerId, displayName, backendKind: input.backendKind, backendConfig };
281
+ }
282
+
283
+ /** Look up a peer by id. Returns null when not registered. */
284
+ get(peerId: string): PeerRecord | null {
285
+ this.requireInit();
286
+ const row = this.store.get<PeerRow>(
287
+ 'SELECT peerId, displayName, backendKind, backendConfig FROM peers WHERE peerId = ?',
288
+ [peerId],
289
+ );
290
+ return row ? rowToRecord(row) : null;
291
+ }
292
+
293
+ /** List all registered peers (config included; contains only secret refs). */
294
+ list(): PeerRecord[] {
295
+ this.requireInit();
296
+ const rows = this.store.all<PeerRow>(
297
+ 'SELECT peerId, displayName, backendKind, backendConfig FROM peers ORDER BY peerId ASC',
298
+ );
299
+ return rows.map(rowToRecord);
300
+ }
301
+
302
+ /** Remove a peer. Returns true when a row was deleted. */
303
+ async remove(peerId: string): Promise<boolean> {
304
+ this.requireInit();
305
+ const existed = this.get(peerId) !== null;
306
+ if (existed) {
307
+ this.store.run('DELETE FROM peers WHERE peerId = ?', [peerId]);
308
+ await this.store.save();
309
+ }
310
+ return existed;
311
+ }
312
+
313
+ close(): void {
314
+ if (this.initialized) {
315
+ this.store.close();
316
+ this.initialized = false;
317
+ }
318
+ }
319
+ }
320
+
321
+ export { PeerRegistryValidationError };