@pellux/goodvibes-tui 0.24.1 → 0.26.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 +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
2
|
+
import {
|
|
3
|
+
isSecretReferenceValue,
|
|
4
|
+
isMalformedGoodVibesSecretReferenceValue,
|
|
5
|
+
} from '../../../config/secret-config.ts';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Backend vocabulary + per-backend config shapes
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export type BackendKind = 'docker' | 'ssh' | 'cloud-terminal' | 'local-process';
|
|
12
|
+
|
|
13
|
+
export type CloudProvider = 'gcp' | 'aws' | 'azure';
|
|
14
|
+
|
|
15
|
+
export interface DockerBackendConfig {
|
|
16
|
+
containerName: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional Docker host. When it points at a remote daemon over TLS this MUST
|
|
19
|
+
* be a goodvibes://secrets/ reference (never a raw URL with embedded creds).
|
|
20
|
+
* A bare local socket path (unix://...) or tcp host without credentials is
|
|
21
|
+
* also accepted.
|
|
22
|
+
*/
|
|
23
|
+
dockerHost?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SshBackendConfig {
|
|
27
|
+
sshHost: string;
|
|
28
|
+
sshPort?: number;
|
|
29
|
+
sshUser: string;
|
|
30
|
+
/** goodvibes://secrets/ reference to the private key — never the raw key. */
|
|
31
|
+
identityRef: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CloudTerminalBackendConfig {
|
|
35
|
+
provider: CloudProvider;
|
|
36
|
+
projectId?: string;
|
|
37
|
+
/** goodvibes://secrets/ reference to the provider credential. */
|
|
38
|
+
credentialRef: string;
|
|
39
|
+
/** Optional zone/region/location passed to the provider CLI. */
|
|
40
|
+
location?: string;
|
|
41
|
+
/** For gcp: the Cloud Shell / VM instance to target. */
|
|
42
|
+
instance?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LocalProcessBackendConfig {
|
|
46
|
+
/** Optional working directory for spawned processes. */
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/** Optional allowlist of executables; when set, only these may be invoked. */
|
|
49
|
+
allowedCommands?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type BackendConfig =
|
|
53
|
+
| ({ kind: 'docker' } & DockerBackendConfig)
|
|
54
|
+
| ({ kind: 'ssh' } & SshBackendConfig)
|
|
55
|
+
| ({ kind: 'cloud-terminal' } & CloudTerminalBackendConfig)
|
|
56
|
+
| ({ kind: 'local-process' } & LocalProcessBackendConfig);
|
|
57
|
+
|
|
58
|
+
export interface PeerRecord {
|
|
59
|
+
peerId: string;
|
|
60
|
+
displayName: string;
|
|
61
|
+
backendKind: BackendKind;
|
|
62
|
+
backendConfig: BackendConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PeerRegistrationInput {
|
|
66
|
+
peerId: string;
|
|
67
|
+
displayName: string;
|
|
68
|
+
backendKind: BackendKind;
|
|
69
|
+
// Raw, untrusted config from the operator method body. Normalized + validated.
|
|
70
|
+
backendConfig: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Validation helpers — backendConfig must hold ONLY secret refs for any
|
|
75
|
+
// credential-bearing field. Raw secrets are rejected outright.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
class PeerRegistryValidationError extends Error {
|
|
79
|
+
constructor(message: string) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = 'PeerRegistryValidationError';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireString(value: unknown, field: string): string {
|
|
86
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
87
|
+
throw new PeerRegistryValidationError(`Field '${field}' is required and must be a non-empty string.`);
|
|
88
|
+
}
|
|
89
|
+
return value.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
93
|
+
if (value === undefined || value === null) return undefined;
|
|
94
|
+
if (typeof value !== 'string') {
|
|
95
|
+
throw new PeerRegistryValidationError(`Field '${field}' must be a string when provided.`);
|
|
96
|
+
}
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requireSecretRef(value: unknown, field: string): string {
|
|
102
|
+
const ref = requireString(value, field);
|
|
103
|
+
if (!isSecretReferenceValue(ref)) {
|
|
104
|
+
throw new PeerRegistryValidationError(
|
|
105
|
+
`Field '${field}' must be a goodvibes://secrets/ reference, not a raw credential.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return ref;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
112
|
+
// A dockerHost is accepted in exactly two shapes, mirroring how docker.ts
|
|
113
|
+
// resolves it (docker.ts: `startsWith('goodvibes://') ? resolveRef(...) : raw`):
|
|
114
|
+
// 1. A goodvibes://secrets/ reference — resolved from the credential store.
|
|
115
|
+
// 2. A credential-free local/plain address (unix:// socket, or a bare
|
|
116
|
+
// tcp/host with no embedded userinfo) — used verbatim.
|
|
117
|
+
// Enforcement here must match that resolution so no credential-bearing or
|
|
118
|
+
// unresolvable value slips through to docker.ts.
|
|
119
|
+
if (value === undefined) return;
|
|
120
|
+
|
|
121
|
+
// A valid secret ref is always allowed: docker.ts resolves it from the store.
|
|
122
|
+
if (isSecretReferenceValue(value)) return;
|
|
123
|
+
|
|
124
|
+
// A `goodvibes://` value that is NOT a well-formed secret ref would be handed
|
|
125
|
+
// to credentials.resolveRef() and fail opaquely (REMOTE_BACKEND_CREDENTIAL_MISSING)
|
|
126
|
+
// — or, worse, a near-miss could be treated as a literal host. Reject it at
|
|
127
|
+
// registration so the misconfiguration surfaces immediately.
|
|
128
|
+
if (isMalformedGoodVibesSecretReferenceValue(value)) {
|
|
129
|
+
throw new PeerRegistryValidationError(
|
|
130
|
+
`Field '${field}' looks like a goodvibes:// reference but is malformed; use a valid goodvibes://secrets/ reference.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Embedded userinfo credentials (e.g. tcp://user:pass@host) must never be
|
|
135
|
+
// stored raw — docker.ts would pass them verbatim as DOCKER_HOST.
|
|
136
|
+
if (value.includes('@')) {
|
|
137
|
+
throw new PeerRegistryValidationError(
|
|
138
|
+
`Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A remote daemon reached over TLS carries its credentials out-of-band and
|
|
143
|
+
// MUST be referenced through the credential store, never pinned as a raw
|
|
144
|
+
// host string the daemon would use unauthenticated. docker.ts only treats a
|
|
145
|
+
// goodvibes:// value as a secret, so a raw `https://`/`tcp+tls://` endpoint
|
|
146
|
+
// here would bypass credential resolution entirely.
|
|
147
|
+
const lowered = value.toLowerCase();
|
|
148
|
+
if (lowered.startsWith('https://') || lowered.startsWith('tcp+tls://')) {
|
|
149
|
+
throw new PeerRegistryValidationError(
|
|
150
|
+
`Field '${field}' points at a TLS Docker daemon; pass a goodvibes://secrets/ reference instead of a raw URL.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Normalize + validate raw backendConfig into a typed, ref-only BackendConfig. */
|
|
156
|
+
export function normalizeBackendConfig(
|
|
157
|
+
backendKind: BackendKind,
|
|
158
|
+
raw: Record<string, unknown>,
|
|
159
|
+
): BackendConfig {
|
|
160
|
+
switch (backendKind) {
|
|
161
|
+
case 'docker': {
|
|
162
|
+
const dockerHost = optionalString(raw.dockerHost, 'dockerHost');
|
|
163
|
+
assertDockerHostSafe(dockerHost, 'dockerHost');
|
|
164
|
+
return {
|
|
165
|
+
kind: 'docker',
|
|
166
|
+
containerName: requireString(raw.containerName, 'containerName'),
|
|
167
|
+
...(dockerHost !== undefined ? { dockerHost } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case 'ssh': {
|
|
171
|
+
const portValue = raw.sshPort;
|
|
172
|
+
let sshPort: number | undefined;
|
|
173
|
+
if (portValue !== undefined && portValue !== null) {
|
|
174
|
+
const parsed = typeof portValue === 'number' ? portValue : Number(portValue);
|
|
175
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
176
|
+
throw new PeerRegistryValidationError("Field 'sshPort' must be an integer between 1 and 65535.");
|
|
177
|
+
}
|
|
178
|
+
sshPort = parsed;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind: 'ssh',
|
|
182
|
+
sshHost: requireString(raw.sshHost, 'sshHost'),
|
|
183
|
+
sshUser: requireString(raw.sshUser, 'sshUser'),
|
|
184
|
+
identityRef: requireSecretRef(raw.identityRef, 'identityRef'),
|
|
185
|
+
...(sshPort !== undefined ? { sshPort } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
case 'cloud-terminal': {
|
|
189
|
+
const provider = requireString(raw.provider, 'provider');
|
|
190
|
+
if (provider !== 'gcp' && provider !== 'aws' && provider !== 'azure') {
|
|
191
|
+
throw new PeerRegistryValidationError("Field 'provider' must be one of 'gcp' | 'aws' | 'azure'.");
|
|
192
|
+
}
|
|
193
|
+
const projectId = optionalString(raw.projectId, 'projectId');
|
|
194
|
+
const location = optionalString(raw.location, 'location');
|
|
195
|
+
const instance = optionalString(raw.instance, 'instance');
|
|
196
|
+
return {
|
|
197
|
+
kind: 'cloud-terminal',
|
|
198
|
+
provider,
|
|
199
|
+
credentialRef: requireSecretRef(raw.credentialRef, 'credentialRef'),
|
|
200
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
201
|
+
...(location !== undefined ? { location } : {}),
|
|
202
|
+
...(instance !== undefined ? { instance } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
case 'local-process': {
|
|
206
|
+
const cwd = optionalString(raw.cwd, 'cwd');
|
|
207
|
+
let allowedCommands: string[] | undefined;
|
|
208
|
+
if (raw.allowedCommands !== undefined && raw.allowedCommands !== null) {
|
|
209
|
+
if (!Array.isArray(raw.allowedCommands)) {
|
|
210
|
+
throw new PeerRegistryValidationError("Field 'allowedCommands' must be an array of strings.");
|
|
211
|
+
}
|
|
212
|
+
const list = raw.allowedCommands
|
|
213
|
+
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
|
214
|
+
.filter((entry) => entry.length > 0);
|
|
215
|
+
allowedCommands = list;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
kind: 'local-process',
|
|
219
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
220
|
+
...(allowedCommands !== undefined ? { allowedCommands } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
throw new PeerRegistryValidationError(`Unknown backendKind: ${String(backendKind)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Peer registry — persisted via HandlerSqliteStore (peer-registry.sqlite)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
const PEER_REGISTRY_FILE = 'peer-registry.sqlite';
|
|
233
|
+
|
|
234
|
+
const SCHEMA = [
|
|
235
|
+
`CREATE TABLE IF NOT EXISTS peers (
|
|
236
|
+
peerId TEXT PRIMARY KEY,
|
|
237
|
+
displayName TEXT NOT NULL,
|
|
238
|
+
backendKind TEXT NOT NULL,
|
|
239
|
+
backendConfig TEXT NOT NULL
|
|
240
|
+
)`,
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
interface PeerRow {
|
|
244
|
+
peerId: string;
|
|
245
|
+
displayName: string;
|
|
246
|
+
backendKind: string;
|
|
247
|
+
backendConfig: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const VALID_BACKEND_KINDS: ReadonlySet<BackendKind> = new Set([
|
|
251
|
+
'docker',
|
|
252
|
+
'ssh',
|
|
253
|
+
'cloud-terminal',
|
|
254
|
+
'local-process',
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
function rowToRecord(row: PeerRow): PeerRecord {
|
|
258
|
+
const backendKind = row.backendKind as BackendKind;
|
|
259
|
+
const parsed = JSON.parse(row.backendConfig) as BackendConfig;
|
|
260
|
+
return {
|
|
261
|
+
peerId: row.peerId,
|
|
262
|
+
displayName: row.displayName,
|
|
263
|
+
backendKind,
|
|
264
|
+
backendConfig: parsed,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class PeerRegistry {
|
|
269
|
+
private readonly store: HandlerSqliteStore;
|
|
270
|
+
private initialized = false;
|
|
271
|
+
|
|
272
|
+
constructor(workingDirectory: string) {
|
|
273
|
+
this.store = new HandlerSqliteStore({
|
|
274
|
+
workingDirectory,
|
|
275
|
+
fileName: PEER_REGISTRY_FILE,
|
|
276
|
+
schema: SCHEMA,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
get dbPath(): string {
|
|
281
|
+
return this.store.dbPath;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async init(): Promise<void> {
|
|
285
|
+
if (this.initialized) return;
|
|
286
|
+
await this.store.init();
|
|
287
|
+
this.initialized = true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private requireInit(): void {
|
|
291
|
+
if (!this.initialized) {
|
|
292
|
+
throw new Error('PeerRegistry not initialized: call init() first.');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Register (upsert) a peer. Validates + normalizes backendConfig to refs-only. */
|
|
297
|
+
async register(input: PeerRegistrationInput): Promise<PeerRecord> {
|
|
298
|
+
this.requireInit();
|
|
299
|
+
const peerId = requireString(input.peerId, 'peerId');
|
|
300
|
+
const displayName = requireString(input.displayName, 'displayName');
|
|
301
|
+
if (!VALID_BACKEND_KINDS.has(input.backendKind)) {
|
|
302
|
+
throw new PeerRegistryValidationError(`Unknown backendKind: ${String(input.backendKind)}`);
|
|
303
|
+
}
|
|
304
|
+
const backendConfig = normalizeBackendConfig(input.backendKind, input.backendConfig ?? {});
|
|
305
|
+
const serialized = JSON.stringify(backendConfig);
|
|
306
|
+
this.store.run(
|
|
307
|
+
`INSERT INTO peers (peerId, displayName, backendKind, backendConfig)
|
|
308
|
+
VALUES (?, ?, ?, ?)
|
|
309
|
+
ON CONFLICT(peerId) DO UPDATE SET
|
|
310
|
+
displayName = excluded.displayName,
|
|
311
|
+
backendKind = excluded.backendKind,
|
|
312
|
+
backendConfig = excluded.backendConfig`,
|
|
313
|
+
[peerId, displayName, input.backendKind, serialized],
|
|
314
|
+
);
|
|
315
|
+
await this.store.save();
|
|
316
|
+
return { peerId, displayName, backendKind: input.backendKind, backendConfig };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Look up a peer by id. Returns null when not registered. */
|
|
320
|
+
get(peerId: string): PeerRecord | null {
|
|
321
|
+
this.requireInit();
|
|
322
|
+
const row = this.store.get<PeerRow>(
|
|
323
|
+
'SELECT peerId, displayName, backendKind, backendConfig FROM peers WHERE peerId = ?',
|
|
324
|
+
[peerId],
|
|
325
|
+
);
|
|
326
|
+
return row ? rowToRecord(row) : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** List all registered peers (config included; contains only secret refs). */
|
|
330
|
+
list(): PeerRecord[] {
|
|
331
|
+
this.requireInit();
|
|
332
|
+
const rows = this.store.all<PeerRow>(
|
|
333
|
+
'SELECT peerId, displayName, backendKind, backendConfig FROM peers ORDER BY peerId ASC',
|
|
334
|
+
);
|
|
335
|
+
return rows.map(rowToRecord);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Remove a peer. Returns true when a row was deleted. */
|
|
339
|
+
async remove(peerId: string): Promise<boolean> {
|
|
340
|
+
this.requireInit();
|
|
341
|
+
const existed = this.get(peerId) !== null;
|
|
342
|
+
if (existed) {
|
|
343
|
+
this.store.run('DELETE FROM peers WHERE peerId = ?', [peerId]);
|
|
344
|
+
await this.store.save();
|
|
345
|
+
}
|
|
346
|
+
return existed;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
close(): void {
|
|
350
|
+
if (this.initialized) {
|
|
351
|
+
this.store.close();
|
|
352
|
+
this.initialized = false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export { PeerRegistryValidationError };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host implementation of the daemon-sdk `DistributedRuntimeRouteService` (17
|
|
3
|
+
* methods). The SDK ships NO docker/ssh/cloud backend, so the host owns it:
|
|
4
|
+
*
|
|
5
|
+
* - `invokePeer` executes against the host's own remote backends through the
|
|
6
|
+
* `RemoteDispatcher` (docker / ssh / cloud-terminal / local-process). This is
|
|
7
|
+
* the only method that performs real command execution.
|
|
8
|
+
* - The 16 peer / pairing / work-queue methods are backed by the SDK's
|
|
9
|
+
* `DistributedRuntimeManager` (constructed by the surface), which owns peer
|
|
10
|
+
* records, pairing challenges, token rotation, and the work queue.
|
|
11
|
+
*
|
|
12
|
+
* The SDK injects this instance into `DaemonRemoteRouteContext.distributedRuntime`
|
|
13
|
+
* so the published `remote.peers.*` HTTP routes dispatch to it. No catalog
|
|
14
|
+
* descriptor or schema is authored here.
|
|
15
|
+
*/
|
|
16
|
+
import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
|
|
17
|
+
import type {
|
|
18
|
+
DistributedRuntimeRouteService,
|
|
19
|
+
RemotePeerAuth,
|
|
20
|
+
} from '../contracts.ts';
|
|
21
|
+
import { RemoteDispatcher } from './dispatcher.ts';
|
|
22
|
+
import type { DispatchPayload } from './backends/index.ts';
|
|
23
|
+
|
|
24
|
+
type DistributedRuntimeManager = operations.DistributedRuntimeManager;
|
|
25
|
+
type DistributedPeerAuth = operations.DistributedPeerAuth;
|
|
26
|
+
|
|
27
|
+
/** Coerce an injected `RemotePeerAuth` (typed `unknown` by the SDK) to the manager's auth shape. */
|
|
28
|
+
function asPeerAuth(auth: RemotePeerAuth): DistributedPeerAuth {
|
|
29
|
+
return auth as DistributedPeerAuth;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Narrow an arbitrary route-supplied input bag to a typed manager input. */
|
|
33
|
+
function asInput<T>(input: Record<string, unknown>): T {
|
|
34
|
+
return input as unknown as T;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readString(input: Record<string, unknown>, key: string): string {
|
|
38
|
+
const value = input[key];
|
|
39
|
+
return typeof value === 'string' ? value : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readPrincipal(input: Record<string, unknown>): string {
|
|
43
|
+
const actor = input.actor ?? input.principalId ?? input.queuedBy;
|
|
44
|
+
return typeof actor === 'string' && actor.length > 0 ? actor : 'remote';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readPayload(input: Record<string, unknown>): DispatchPayload | undefined {
|
|
48
|
+
const payload = input.payload;
|
|
49
|
+
if (payload && typeof payload === 'object') {
|
|
50
|
+
return payload as DispatchPayload;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class HostDistributedRuntime implements DistributedRuntimeRouteService {
|
|
56
|
+
private readonly manager: DistributedRuntimeManager;
|
|
57
|
+
private readonly dispatcher: RemoteDispatcher;
|
|
58
|
+
|
|
59
|
+
constructor(manager: DistributedRuntimeManager, dispatcher: RemoteDispatcher) {
|
|
60
|
+
this.manager = manager;
|
|
61
|
+
this.dispatcher = dispatcher;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Pairing -------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
listPairRequests(): unknown {
|
|
67
|
+
return this.manager.listPairRequests();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
requestPairing(input: Record<string, unknown>): Promise<unknown> {
|
|
71
|
+
return this.manager.requestPairing(
|
|
72
|
+
asInput<Parameters<DistributedRuntimeManager['requestPairing']>[0]>(input),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
approvePairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null> {
|
|
77
|
+
return this.manager.approvePairRequest(
|
|
78
|
+
requestId,
|
|
79
|
+
asInput<Parameters<DistributedRuntimeManager['approvePairRequest']>[1]>(input),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
rejectPairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null> {
|
|
84
|
+
return this.manager.rejectPairRequest(
|
|
85
|
+
requestId,
|
|
86
|
+
asInput<Parameters<DistributedRuntimeManager['rejectPairRequest']>[1]>(input),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
verifyPairRequest(
|
|
91
|
+
requestId: string,
|
|
92
|
+
challenge: string,
|
|
93
|
+
input: Record<string, unknown>,
|
|
94
|
+
): Promise<unknown | null> {
|
|
95
|
+
return this.manager.verifyPairRequest(
|
|
96
|
+
requestId,
|
|
97
|
+
challenge,
|
|
98
|
+
asInput<Parameters<DistributedRuntimeManager['verifyPairRequest']>[2]>(input),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Peer management -----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
listPeers(): unknown {
|
|
105
|
+
return this.manager.listPeers();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
rotatePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null> {
|
|
109
|
+
return this.manager.rotatePeerToken(
|
|
110
|
+
peerId,
|
|
111
|
+
asInput<Parameters<DistributedRuntimeManager['rotatePeerToken']>[1]>(input),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
revokePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null> {
|
|
116
|
+
return this.manager.revokePeerToken(
|
|
117
|
+
peerId,
|
|
118
|
+
asInput<Parameters<DistributedRuntimeManager['revokePeerToken']>[1]>(input),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
disconnectPeer(peerId: string, input: Record<string, unknown>): Promise<unknown | null> {
|
|
123
|
+
return this.manager.disconnectPeer(
|
|
124
|
+
peerId,
|
|
125
|
+
asInput<Parameters<DistributedRuntimeManager['disconnectPeer']>[1]>(input),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getNodeHostContract(): unknown {
|
|
130
|
+
return this.manager.getNodeHostContract();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Peer-authenticated heartbeat + work claim ---------------------------
|
|
134
|
+
|
|
135
|
+
heartbeatPeer(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown> {
|
|
136
|
+
return this.manager.heartbeatPeer(
|
|
137
|
+
asPeerAuth(auth),
|
|
138
|
+
asInput<Parameters<DistributedRuntimeManager['heartbeatPeer']>[1]>(input),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
claimWork(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown> {
|
|
143
|
+
return this.manager.claimWork(
|
|
144
|
+
asPeerAuth(auth),
|
|
145
|
+
asInput<Parameters<DistributedRuntimeManager['claimWork']>[1]>(input),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
completeWork(
|
|
150
|
+
auth: RemotePeerAuth,
|
|
151
|
+
workId: string,
|
|
152
|
+
input: Record<string, unknown>,
|
|
153
|
+
): Promise<unknown | null> {
|
|
154
|
+
return this.manager.completeWork(
|
|
155
|
+
asPeerAuth(auth),
|
|
156
|
+
workId,
|
|
157
|
+
asInput<Parameters<DistributedRuntimeManager['completeWork']>[2]>(input),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Work queue ----------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
listWork(): unknown {
|
|
164
|
+
return this.manager.listWork();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
cancelWork(workId: string, input: Record<string, unknown>): Promise<unknown | null> {
|
|
168
|
+
return this.manager.cancelWork(
|
|
169
|
+
workId,
|
|
170
|
+
asInput<Parameters<DistributedRuntimeManager['cancelWork']>[1]>(input),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Command execution (host-owned backends) -----------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Execute a command on a registered peer through the host backends. Unlike the
|
|
178
|
+
* manager's enqueue-only `invokePeer`, this performs real docker/ssh/cloud/
|
|
179
|
+
* local-process execution and returns a receipt with a full-stdout digest.
|
|
180
|
+
*/
|
|
181
|
+
async invokePeer(input: Record<string, unknown>): Promise<unknown> {
|
|
182
|
+
const payload = readPayload(input);
|
|
183
|
+
return this.dispatcher.dispatch({
|
|
184
|
+
peerId: readString(input, 'peerId'),
|
|
185
|
+
command: readString(input, 'command'),
|
|
186
|
+
principalId: readPrincipal(input),
|
|
187
|
+
async: input.async === true,
|
|
188
|
+
...(payload !== undefined ? { payload } : {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Inbox <-> Routing resolver bridge.
|
|
3
|
+
//
|
|
4
|
+
// The inbox provider adapters expose a best-effort route-resolution seam: given
|
|
5
|
+
// an inbound item's `{ provider, fromDigest, kind, routeId? }` they ask "which
|
|
6
|
+
// daemon route binding handles this item?" and stamp the answer onto
|
|
7
|
+
// `item.routeId` when one resolves. The routing control plane owns the
|
|
8
|
+
// channel<->profile bindings and exposes a `RoutingResolver` whose
|
|
9
|
+
// `getProfileForChannel(surfaceKind, routeId?)` applies the canonical
|
|
10
|
+
// resolution order:
|
|
11
|
+
//
|
|
12
|
+
// 1. exact — surfaceKind AND routeId both match
|
|
13
|
+
// 2. surface — surfaceKind matches, route has no routeId
|
|
14
|
+
// 3. wildcard — a route with surfaceKind === 'any' (no routeId)
|
|
15
|
+
//
|
|
16
|
+
// This module bridges the two shapes. The bridge maps an inbound item's
|
|
17
|
+
// `provider` to the routing `surfaceKind` and forwards an optional `routeId`
|
|
18
|
+
// refinement; `fromDigest`/`kind` are NOT used as routing keys (the routing
|
|
19
|
+
// store is keyed by surfaceKind + optional routeId only, never by sender digest
|
|
20
|
+
// or message kind — see RoutingChannelRoute). When no binding matches the
|
|
21
|
+
// resolver returns null and the bridge yields `undefined`, so the adapter falls
|
|
22
|
+
// back to leaving the item unrouted (offline/default behaviour intact). The
|
|
23
|
+
// bridge NEVER throws: the underlying resolver is a pure in-memory lookup.
|
|
24
|
+
//
|
|
25
|
+
// The inbox surface is a separate follow-up module, so this bridge declares the
|
|
26
|
+
// minimal `RouteResolver` seam locally rather than importing a sibling that the
|
|
27
|
+
// routing slice does not own — keeping the routing handler layer free of any
|
|
28
|
+
// cross-surface import edge.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import type { RoutingResolver } from './routing-resolver.ts';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fields the bridge consults on an inbound item. `provider` maps to the routing
|
|
35
|
+
* `surfaceKind`; an optional `routeId` (e.g. a Slack channel id or email
|
|
36
|
+
* mailbox tag) enables exact (surfaceKind+routeId) matches. Additional inbound
|
|
37
|
+
* fields are ignored for routing purposes.
|
|
38
|
+
*/
|
|
39
|
+
export interface RouteResolverInput {
|
|
40
|
+
readonly provider?: unknown;
|
|
41
|
+
readonly routeId?: unknown;
|
|
42
|
+
readonly [key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Best-effort seam the inbox provider adapters invoke per inbound item. Returns
|
|
47
|
+
* the resolved profile-route binding id, or `undefined` when nothing matches.
|
|
48
|
+
*/
|
|
49
|
+
export type RouteResolver = (input: RouteResolverInput) => string | undefined;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a {@link RouteResolver} backed by the routing surface's
|
|
53
|
+
* {@link RoutingResolver}. Maps `provider -> surfaceKind` and forwards an
|
|
54
|
+
* optional `routeId` refinement, then applies the canonical
|
|
55
|
+
* exact > surface-only > wildcard resolution order via `getProfileForChannel`.
|
|
56
|
+
* Returns the resolved profileId (the daemon route binding) or `undefined` when
|
|
57
|
+
* nothing matches.
|
|
58
|
+
*/
|
|
59
|
+
export function createInboxRouteResolver(resolver: RoutingResolver): RouteResolver {
|
|
60
|
+
return (input: RouteResolverInput): string | undefined => {
|
|
61
|
+
const surfaceKind = typeof input.provider === 'string' ? input.provider : '';
|
|
62
|
+
if (surfaceKind.length === 0) return undefined;
|
|
63
|
+
const routeId = typeof input.routeId === 'string' && input.routeId.length > 0
|
|
64
|
+
? input.routeId
|
|
65
|
+
: undefined;
|
|
66
|
+
// getProfileForChannel applies exact (surfaceKind+routeId) > surface-only >
|
|
67
|
+
// wildcard ('any') and returns null when no assignment matches.
|
|
68
|
+
const profileId = resolver.getProfileForChannel(surfaceKind, routeId);
|
|
69
|
+
return profileId ?? undefined;
|
|
70
|
+
};
|
|
71
|
+
}
|