@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,125 @@
|
|
|
1
|
+
import { mkdir, writeFile, rm, chmod } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import type { PeerRecord } from '../peer-registry.ts';
|
|
5
|
+
import type { SshBackendConfig } from '../peer-registry.ts';
|
|
6
|
+
import {
|
|
7
|
+
type Backend,
|
|
8
|
+
type BackendContext,
|
|
9
|
+
type BackendDispatchResult,
|
|
10
|
+
type DispatchPayload,
|
|
11
|
+
BackendDispatchError,
|
|
12
|
+
resolveTimeout,
|
|
13
|
+
buildRemoteShellCommand,
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
import { runProcess } from './process-runner.ts';
|
|
16
|
+
import { tokenizeCommand } from './local-process.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Persistent-key material is written to {homeDirectory}/.goodvibes/tui/operator/
|
|
20
|
+
* ssh-keys/{peerId}.key with 0600 permissions and reused across invocations
|
|
21
|
+
* (connection pooling via the OpenSSH ControlMaster multiplexer). The key value
|
|
22
|
+
* itself comes only from the daemon credential store — never argv, never logs.
|
|
23
|
+
*/
|
|
24
|
+
interface PooledIdentity {
|
|
25
|
+
keyPath: string;
|
|
26
|
+
controlPath: string;
|
|
27
|
+
identityRef: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createSshBackend(ctx: BackendContext): Backend {
|
|
31
|
+
const pool = new Map<string, PooledIdentity>();
|
|
32
|
+
const keyDir = join(ctx.homeDirectory, '.goodvibes', 'tui', 'operator', 'ssh-keys');
|
|
33
|
+
|
|
34
|
+
async function ensureIdentity(
|
|
35
|
+
peer: PeerRecord,
|
|
36
|
+
config: SshBackendConfig,
|
|
37
|
+
): Promise<PooledIdentity> {
|
|
38
|
+
const existing = pool.get(peer.peerId);
|
|
39
|
+
if (existing && existing.identityRef === config.identityRef) {
|
|
40
|
+
return existing;
|
|
41
|
+
}
|
|
42
|
+
const key = await ctx.credentials.resolveRef(config.identityRef);
|
|
43
|
+
if (!key || key.length === 0) {
|
|
44
|
+
throw new BackendDispatchError(
|
|
45
|
+
`Could not resolve SSH identity for peer '${peer.peerId}'.`,
|
|
46
|
+
'REMOTE_BACKEND_CREDENTIAL_MISSING',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
await mkdir(keyDir, { recursive: true });
|
|
50
|
+
await chmod(keyDir, 0o700).catch(() => {});
|
|
51
|
+
const suffix = randomBytes(4).toString('hex');
|
|
52
|
+
const keyPath = join(keyDir, `${peer.peerId}.${suffix}.key`);
|
|
53
|
+
const normalizedKey = key.endsWith('\n') ? key : `${key}\n`;
|
|
54
|
+
await writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
|
55
|
+
await chmod(keyPath, 0o600).catch(() => {});
|
|
56
|
+
const controlPath = join(keyDir, `${peer.peerId}.${suffix}.ctl`);
|
|
57
|
+
const identity: PooledIdentity = { keyPath, controlPath, identityRef: config.identityRef };
|
|
58
|
+
// Replace any prior identity for this peer and clean up its key file.
|
|
59
|
+
if (existing) await rm(existing.keyPath, { force: true }).catch(() => {});
|
|
60
|
+
pool.set(peer.peerId, identity);
|
|
61
|
+
return identity;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
kind: 'ssh',
|
|
66
|
+
async dispatch(
|
|
67
|
+
peer: PeerRecord,
|
|
68
|
+
command: string,
|
|
69
|
+
payload?: DispatchPayload,
|
|
70
|
+
): Promise<BackendDispatchResult> {
|
|
71
|
+
if (peer.backendConfig.kind !== 'ssh') {
|
|
72
|
+
throw new BackendDispatchError(
|
|
73
|
+
`Peer '${peer.peerId}' is not an ssh peer.`,
|
|
74
|
+
'REMOTE_BACKEND_KIND_MISMATCH',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const config = peer.backendConfig as { kind: 'ssh' } & SshBackendConfig;
|
|
78
|
+
if (tokenizeCommand(command).length === 0) {
|
|
79
|
+
throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
|
|
80
|
+
}
|
|
81
|
+
const identity = await ensureIdentity(peer, config);
|
|
82
|
+
const port = config.sshPort ?? 22;
|
|
83
|
+
const target = `${config.sshUser}@${config.sshHost}`;
|
|
84
|
+
const remoteCommand = buildRemoteShellCommand(command, payload?.args);
|
|
85
|
+
|
|
86
|
+
const args = [
|
|
87
|
+
'ssh',
|
|
88
|
+
'-i', identity.keyPath,
|
|
89
|
+
'-p', String(port),
|
|
90
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
91
|
+
'-o', 'BatchMode=yes',
|
|
92
|
+
'-o', 'ConnectTimeout=15',
|
|
93
|
+
// Connection pooling: reuse a multiplexed master for ~60s.
|
|
94
|
+
'-o', 'ControlMaster=auto',
|
|
95
|
+
'-o', `ControlPath=${identity.controlPath}`,
|
|
96
|
+
'-o', 'ControlPersist=60',
|
|
97
|
+
target,
|
|
98
|
+
remoteCommand,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
ctx.logger.info('remote ssh dispatch', {
|
|
102
|
+
peerId: peer.peerId,
|
|
103
|
+
host: config.sshHost,
|
|
104
|
+
port,
|
|
105
|
+
});
|
|
106
|
+
const result = await runProcess({
|
|
107
|
+
args,
|
|
108
|
+
timeoutMs: resolveTimeout(payload),
|
|
109
|
+
...(payload?.env !== undefined ? { env: payload.env } : {}),
|
|
110
|
+
...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
exitCode: result.timedOut ? 124 : result.exitCode,
|
|
114
|
+
stdout: result.stdout,
|
|
115
|
+
stderr: result.timedOut ? `${result.stderr}\n[remote] ssh command timed out` : result.stderr,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
async teardown(): Promise<void> {
|
|
119
|
+
// Sweep every pooled key file plus the ssh-keys/ dir so no private-key
|
|
120
|
+
// material (or stale ControlMaster sockets) outlives the daemon process.
|
|
121
|
+
pool.clear();
|
|
122
|
+
await rm(keyDir, { recursive: true, force: true }).catch(() => {});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
2
|
+
import type { HandlerLogger } from '../../context.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: HandlerLogger;
|
|
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
|
+
* Best-effort cleanup of any on-disk material the backend created (ephemeral
|
|
53
|
+
* key/credential files and their containing dirs). Called from the surface
|
|
54
|
+
* teardown so secrets do not outlive the daemon process. Optional: backends
|
|
55
|
+
* that write nothing to disk omit it.
|
|
56
|
+
*/
|
|
57
|
+
teardown?(): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
|
|
61
|
+
export const MAX_SYNC_TIMEOUT_MS = 600_000;
|
|
62
|
+
|
|
63
|
+
export function resolveTimeout(payload?: DispatchPayload): number {
|
|
64
|
+
const requested = payload?.timeoutMs;
|
|
65
|
+
if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) {
|
|
66
|
+
return Math.min(requested, MAX_SYNC_TIMEOUT_MS);
|
|
67
|
+
}
|
|
68
|
+
return DEFAULT_SYNC_TIMEOUT_MS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class BackendDispatchError extends Error {
|
|
72
|
+
readonly code: string;
|
|
73
|
+
constructor(message: string, code = 'REMOTE_BACKEND_DISPATCH_FAILED') {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'BackendDispatchError';
|
|
76
|
+
this.code = code;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compose the remote-shell command line for the shell-style backends
|
|
82
|
+
* (docker `sh -c`, ssh remote command, cloud-CLI `--command`/`--scripts`).
|
|
83
|
+
*
|
|
84
|
+
* REMOTE-SHELL SEMANTICS (intentional, documented asymmetry vs local-process):
|
|
85
|
+
* positional `payload.args` are joined onto the command with a single space and
|
|
86
|
+
* are NOT shell-escaped, because these backends hand a single command STRING to
|
|
87
|
+
* a remote shell — the operator's `command` may itself contain pipes, redirects,
|
|
88
|
+
* globs, or quoting that must survive the hop verbatim. The local-process
|
|
89
|
+
* backend, by contrast, never invokes a shell and passes args as discrete argv.
|
|
90
|
+
*
|
|
91
|
+
* This surface is operator/admin-gated (the SDK route enforces
|
|
92
|
+
* confirmation/explicitUserRequest before dispatch), so callers that need
|
|
93
|
+
* literal arguments must pre-quote them inside `command` or `args`.
|
|
94
|
+
*/
|
|
95
|
+
export function buildRemoteShellCommand(command: string, args?: string[]): string {
|
|
96
|
+
return args && args.length > 0 ? `${command} ${args.join(' ')}` : command;
|
|
97
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { HandlerLogger } from '../context.ts';
|
|
3
|
+
import type { DaemonCredentialStore } from '../credentials.ts';
|
|
4
|
+
import { PeerRegistry, type PeerRecord } from './peer-registry.ts';
|
|
5
|
+
import {
|
|
6
|
+
type Backend,
|
|
7
|
+
type BackendContext,
|
|
8
|
+
type DispatchPayload,
|
|
9
|
+
BackendDispatchError,
|
|
10
|
+
createBackends,
|
|
11
|
+
} from './backends/index.ts';
|
|
12
|
+
|
|
13
|
+
/** SHA-256 of input, truncated to the first `hexChars` hex characters. */
|
|
14
|
+
function sha256First(input: string, hexChars: number): string {
|
|
15
|
+
const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
|
|
16
|
+
return digest.slice(0, Math.max(0, hexChars));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Work-item hook — long-running invocations are enqueued as work items visible
|
|
21
|
+
// in remote.work.list. The dispatcher does not own the distributed runtime; the
|
|
22
|
+
// integrator wires this hook to the DistributedRuntimeManager work queue.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface RemoteWorkItemInput {
|
|
26
|
+
peerId: string;
|
|
27
|
+
command: string;
|
|
28
|
+
payload?: DispatchPayload;
|
|
29
|
+
/** Echoed onto the work item so the runner can pick the right backend. */
|
|
30
|
+
backendKind: PeerRecord['backendKind'];
|
|
31
|
+
queuedBy: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RemoteWorkEnqueuer {
|
|
35
|
+
enqueue(item: RemoteWorkItemInput): Promise<{ workId: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Invoke result — returned to the agent through remote.peers.invoke. Includes
|
|
40
|
+
// stdoutDigest (sha256 of FULL stdout, 64 hex chars) per the receipt contract.
|
|
41
|
+
// The agent may receive only a truncated stdout preview.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export const STDOUT_PREVIEW_LIMIT = 4_096;
|
|
45
|
+
|
|
46
|
+
export interface RemoteInvokeResult {
|
|
47
|
+
peerId: string;
|
|
48
|
+
backendKind: PeerRecord['backendKind'];
|
|
49
|
+
/** Present for synchronous completion. */
|
|
50
|
+
exitCode?: number;
|
|
51
|
+
/** Present for async/long-running dispatch. */
|
|
52
|
+
workId?: string;
|
|
53
|
+
completed: boolean;
|
|
54
|
+
stdout: string;
|
|
55
|
+
stderr: string;
|
|
56
|
+
/** SHA-256 of the full stdout, 64 hex chars. */
|
|
57
|
+
stdoutDigest: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RemoteDispatcherOptions {
|
|
61
|
+
registry: PeerRegistry;
|
|
62
|
+
credentials: DaemonCredentialStore;
|
|
63
|
+
logger: HandlerLogger;
|
|
64
|
+
homeDirectory: string;
|
|
65
|
+
/** Optional hook for enqueuing long-running work items. */
|
|
66
|
+
workEnqueuer?: RemoteWorkEnqueuer;
|
|
67
|
+
/** Override the backend map (tests inject fakes). */
|
|
68
|
+
backends?: Map<PeerRecord['backendKind'], Backend>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DispatchRequest {
|
|
72
|
+
peerId: string;
|
|
73
|
+
command: string;
|
|
74
|
+
payload?: DispatchPayload;
|
|
75
|
+
/** Principal that requested the dispatch (for work-item attribution). */
|
|
76
|
+
principalId: string;
|
|
77
|
+
/** When true (and a work enqueuer exists), run as an async work item. */
|
|
78
|
+
async?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function truncate(value: string, limit: number): string {
|
|
82
|
+
return value.length > limit ? value.slice(0, limit) : value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Routes remote.peers.invoke commands to the correct execution backend.
|
|
87
|
+
* Synchronous commands return an exitCode; long-running commands (async:true
|
|
88
|
+
* with a configured work enqueuer) return a workId tracked via remote.work.list.
|
|
89
|
+
*/
|
|
90
|
+
export class RemoteDispatcher {
|
|
91
|
+
private readonly registry: PeerRegistry;
|
|
92
|
+
private readonly backends: Map<PeerRecord['backendKind'], Backend>;
|
|
93
|
+
private readonly workEnqueuer?: RemoteWorkEnqueuer;
|
|
94
|
+
private readonly logger: HandlerLogger;
|
|
95
|
+
|
|
96
|
+
constructor(options: RemoteDispatcherOptions) {
|
|
97
|
+
this.registry = options.registry;
|
|
98
|
+
this.logger = options.logger;
|
|
99
|
+
if (options.workEnqueuer) this.workEnqueuer = options.workEnqueuer;
|
|
100
|
+
const backendContext: BackendContext = {
|
|
101
|
+
credentials: options.credentials,
|
|
102
|
+
logger: options.logger,
|
|
103
|
+
homeDirectory: options.homeDirectory,
|
|
104
|
+
};
|
|
105
|
+
this.backends = options.backends ?? createBackends(backendContext);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async dispatch(request: DispatchRequest): Promise<RemoteInvokeResult> {
|
|
109
|
+
const peerId = typeof request.peerId === 'string' ? request.peerId.trim() : '';
|
|
110
|
+
if (peerId.length === 0) {
|
|
111
|
+
throw new BackendDispatchError('peerId is required.', 'REMOTE_PEER_ID_REQUIRED');
|
|
112
|
+
}
|
|
113
|
+
const command = typeof request.command === 'string' ? request.command : '';
|
|
114
|
+
if (command.trim().length === 0) {
|
|
115
|
+
throw new BackendDispatchError('command is required.', 'REMOTE_COMMAND_REQUIRED');
|
|
116
|
+
}
|
|
117
|
+
const peer = this.registry.get(peerId);
|
|
118
|
+
if (!peer) {
|
|
119
|
+
throw new BackendDispatchError(
|
|
120
|
+
`No registered peer with id '${peerId}'.`,
|
|
121
|
+
'REMOTE_PEER_NOT_FOUND',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const backend = this.backends.get(peer.backendKind);
|
|
125
|
+
if (!backend) {
|
|
126
|
+
throw new BackendDispatchError(
|
|
127
|
+
`No backend available for kind '${peer.backendKind}'.`,
|
|
128
|
+
'REMOTE_BACKEND_UNAVAILABLE',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Async path: enqueue a work item and return its id immediately.
|
|
133
|
+
if (request.async === true && this.workEnqueuer) {
|
|
134
|
+
const { workId } = await this.workEnqueuer.enqueue({
|
|
135
|
+
peerId: peer.peerId,
|
|
136
|
+
command,
|
|
137
|
+
backendKind: peer.backendKind,
|
|
138
|
+
queuedBy: request.principalId,
|
|
139
|
+
...(request.payload !== undefined ? { payload: request.payload } : {}),
|
|
140
|
+
});
|
|
141
|
+
this.logger.info('remote invoke enqueued', { peerId: peer.peerId, workId });
|
|
142
|
+
return {
|
|
143
|
+
peerId: peer.peerId,
|
|
144
|
+
backendKind: peer.backendKind,
|
|
145
|
+
workId,
|
|
146
|
+
completed: false,
|
|
147
|
+
stdout: '',
|
|
148
|
+
stderr: '',
|
|
149
|
+
stdoutDigest: sha256First('', 64),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Synchronous path: run on the backend and capture output.
|
|
154
|
+
const result = await backend.dispatch(peer, command, request.payload);
|
|
155
|
+
const fullStdout = result.stdout ?? '';
|
|
156
|
+
return {
|
|
157
|
+
peerId: peer.peerId,
|
|
158
|
+
backendKind: peer.backendKind,
|
|
159
|
+
...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
|
|
160
|
+
...(result.workId !== undefined ? { workId: result.workId } : {}),
|
|
161
|
+
completed: result.workId === undefined,
|
|
162
|
+
stdout: truncate(fullStdout, STDOUT_PREVIEW_LIMIT),
|
|
163
|
+
stderr: truncate(result.stderr ?? '', STDOUT_PREVIEW_LIMIT),
|
|
164
|
+
stdoutDigest: sha256First(fullStdout, 64),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Best-effort teardown: invoke every backend's optional teardown so ephemeral
|
|
170
|
+
* key/credential material (ssh-keys/, cloud-creds/) is swept from disk and
|
|
171
|
+
* does not outlive the daemon. Failures are swallowed — teardown must never
|
|
172
|
+
* throw during surface shutdown.
|
|
173
|
+
*/
|
|
174
|
+
async teardown(): Promise<void> {
|
|
175
|
+
await Promise.all(
|
|
176
|
+
[...this.backends.values()].map((backend) =>
|
|
177
|
+
backend.teardown ? backend.teardown().catch(() => {}) : Promise.resolve(),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote handler surface — the host backend for `remote.peers.*`.
|
|
3
|
+
*
|
|
4
|
+
* `remote.peers.invoke` is NOT a catalog method: the SDK publishes it as an HTTP
|
|
5
|
+
* route and injects a `DistributedRuntimeRouteService` (the host's
|
|
6
|
+
* `HostDistributedRuntime`) into `DaemonRemoteRouteContext.distributedRuntime`.
|
|
7
|
+
* This module wires that service together:
|
|
8
|
+
*
|
|
9
|
+
* - a `PeerRegistry` (peer-registry.sqlite; credential fields are
|
|
10
|
+
* goodvibes://secrets/ refs, embedded secrets rejected),
|
|
11
|
+
* - a `RemoteDispatcher` routing by backendKind to the docker/ssh/
|
|
12
|
+
* cloud-terminal/local-process backends,
|
|
13
|
+
* - the SDK `DistributedRuntimeManager` (store: tui/remote/distributed-runtime.json)
|
|
14
|
+
* backing the 16 peer/pairing/work methods,
|
|
15
|
+
* - the `HostDistributedRuntime` service implementing the 17-method contract.
|
|
16
|
+
*
|
|
17
|
+
* The returned registration is plugged into the foundation
|
|
18
|
+
* `DaemonHandlerSurfaceProviders.registerRemote` and surfaced on
|
|
19
|
+
* `DaemonHandlerSurfaces.remoteSurface` + `.remoteDispatch`. Stores init lazily
|
|
20
|
+
* (peer registry init is backgrounded) so wiring stays synchronous.
|
|
21
|
+
*/
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
|
|
24
|
+
import type { HandlerContext } from '../context.ts';
|
|
25
|
+
import type {
|
|
26
|
+
RemoteSurfaceRegistration,
|
|
27
|
+
RemoteInvokeAdapter,
|
|
28
|
+
} from '../index.ts';
|
|
29
|
+
import { PeerRegistry } from './peer-registry.ts';
|
|
30
|
+
import { RemoteDispatcher, type RemoteWorkEnqueuer } from './dispatcher.ts';
|
|
31
|
+
import { HostDistributedRuntime } from './service.ts';
|
|
32
|
+
|
|
33
|
+
type DistributedRuntimeManager = operations.DistributedRuntimeManager;
|
|
34
|
+
|
|
35
|
+
const DISTRIBUTED_RUNTIME_STORE = join('tui', 'remote', 'distributed-runtime.json');
|
|
36
|
+
|
|
37
|
+
export interface RegisterRemoteSurfaceOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Inject the distributed runtime manager (integration may construct it in
|
|
40
|
+
* services.ts so other runtime bridges can attach to the same instance). When
|
|
41
|
+
* omitted, the surface builds its own manager rooted under the project's
|
|
42
|
+
* .goodvibes directory.
|
|
43
|
+
*/
|
|
44
|
+
readonly manager?: DistributedRuntimeManager;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the remote surface. Returns the teardown, the host
|
|
49
|
+
* `DistributedRuntimeRouteService` the SDK facade injects, and the
|
|
50
|
+
* `remote.peers.invoke` dispatch adapter.
|
|
51
|
+
*/
|
|
52
|
+
export function registerRemoteSurface(
|
|
53
|
+
ctx: HandlerContext,
|
|
54
|
+
options?: RegisterRemoteSurfaceOptions,
|
|
55
|
+
): RemoteSurfaceRegistration {
|
|
56
|
+
const registry = new PeerRegistry(ctx.workingDirectory);
|
|
57
|
+
|
|
58
|
+
const manager =
|
|
59
|
+
options?.manager
|
|
60
|
+
?? new operations.DistributedRuntimeManager(
|
|
61
|
+
join(ctx.workingDirectory, '.goodvibes', DISTRIBUTED_RUNTIME_STORE),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Adapt the SDK manager's work queue to the dispatcher's enqueue hook so a
|
|
65
|
+
// production `remote.peers.invoke {async:true}` creates a work item visible
|
|
66
|
+
// in remote.work.list and returns its id (instead of running synchronously).
|
|
67
|
+
const workEnqueuer: RemoteWorkEnqueuer = {
|
|
68
|
+
enqueue: async (item) => {
|
|
69
|
+
// item.backendKind is intentionally NOT forwarded: the SDK enqueueWork
|
|
70
|
+
// contract has no backendKind parameter, and the work runner re-resolves
|
|
71
|
+
// the backend from the live peer record at claim time (so a peer that is
|
|
72
|
+
// re-registered onto a different backend before the work runs is honored).
|
|
73
|
+
const work = await manager.enqueueWork({
|
|
74
|
+
peerId: item.peerId,
|
|
75
|
+
command: item.command,
|
|
76
|
+
actor: item.queuedBy,
|
|
77
|
+
...(item.payload !== undefined ? { payload: item.payload } : {}),
|
|
78
|
+
});
|
|
79
|
+
return { workId: work.id };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const dispatcher = new RemoteDispatcher({
|
|
84
|
+
registry,
|
|
85
|
+
credentials: ctx.credentials,
|
|
86
|
+
logger: ctx.logger,
|
|
87
|
+
homeDirectory: ctx.homeDirectory,
|
|
88
|
+
workEnqueuer,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const service = new HostDistributedRuntime(manager, dispatcher);
|
|
92
|
+
|
|
93
|
+
// Initialize persistent state lazily in the background so surface
|
|
94
|
+
// construction stays synchronous and never blocks daemon bootstrap.
|
|
95
|
+
void registry.init().catch((error) => {
|
|
96
|
+
ctx.logger.error('remote peer registry init failed', { error });
|
|
97
|
+
});
|
|
98
|
+
if (!options?.manager) {
|
|
99
|
+
void manager.start().catch((error) => {
|
|
100
|
+
ctx.logger.error('distributed runtime manager start failed', { error });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dispatch: RemoteInvokeAdapter = {
|
|
105
|
+
invoke: (input: Record<string, unknown>) => service.invokePeer(input),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const unregister = (): void => {
|
|
109
|
+
registry.close();
|
|
110
|
+
// Best-effort, fire-and-forget sweep of ephemeral key/credential material
|
|
111
|
+
// (ssh-keys/, cloud-creds/) so no secret-bearing file outlives the surface.
|
|
112
|
+
// Kept off the synchronous teardown path; failures are swallowed inside.
|
|
113
|
+
void dispatcher.teardown().catch((error) => {
|
|
114
|
+
ctx.logger.error('remote backend teardown failed', { error });
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return { unregister, service, dispatch };
|
|
119
|
+
}
|