@pellux/goodvibes-daemon 1.28.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 +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -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/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -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 +126 -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 +120 -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 +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -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/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cluster-group-composition.ts — this machine's membership of a LAN group.
|
|
3
|
+
*
|
|
4
|
+
* Leader election answers "which of us reads the inbox". This answers the
|
|
5
|
+
* question underneath it: "which machines are US". Without a group, any daemon
|
|
6
|
+
* that happened to be on the same network — a neighbour's, a colleague's, a
|
|
7
|
+
* container someone left running — would join the same coordination and one of
|
|
8
|
+
* you would silently stop receiving messages.
|
|
9
|
+
*
|
|
10
|
+
* The group layer owns the socket and the election rides on it: every
|
|
11
|
+
* coordination datagram is wrapped in the group envelope and signed with the
|
|
12
|
+
* current group key, so a datagram from outside the group never reaches the
|
|
13
|
+
* election at all. That is why `createClusterComposition` is handed a transport
|
|
14
|
+
* from here instead of opening one of its own.
|
|
15
|
+
*
|
|
16
|
+
* Key material lives ONLY in the encrypted secrets store. Never in config,
|
|
17
|
+
* never in a log line, never in `/status`.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
ClusterGroupRuntime,
|
|
21
|
+
createClusterGroupVerbs,
|
|
22
|
+
createSystemClusterClock,
|
|
23
|
+
readClusterSettings,
|
|
24
|
+
rejoinGroup,
|
|
25
|
+
resolveClusterGroupSettings,
|
|
26
|
+
resolveNodeIdentity,
|
|
27
|
+
stillOnRoster,
|
|
28
|
+
UdpClusterTransport,
|
|
29
|
+
type ClusterGroupVerbSurface,
|
|
30
|
+
type ClusterSurfaceHolding,
|
|
31
|
+
type GroupOperationsContext,
|
|
32
|
+
} from '@pellux/goodvibes-sdk/platform/cluster';
|
|
33
|
+
import type { ConfigManager, SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
34
|
+
import type { ClusterTransport } from '@pellux/goodvibes-sdk/platform/cluster';
|
|
35
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
36
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
37
|
+
import { createClusterComposition } from './cluster-composition.ts';
|
|
38
|
+
import type { ClusterCoordinator } from '@pellux/goodvibes-sdk/platform/cluster';
|
|
39
|
+
import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
|
|
40
|
+
import { VERSION } from '../version.ts';
|
|
41
|
+
|
|
42
|
+
export interface ClusterGroupComposition {
|
|
43
|
+
readonly runtime: ClusterGroupRuntime;
|
|
44
|
+
readonly verbs: ClusterGroupVerbSurface;
|
|
45
|
+
/** The transport the leader election must use, so its traffic is group-signed. */
|
|
46
|
+
readonly electionTransport: ClusterTransport;
|
|
47
|
+
/** Start the group layer. Safe to call more than once. */
|
|
48
|
+
start(): Promise<void>;
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A label for THIS machine in the member list.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately NOT the hostname. The roster replicates to every member and the
|
|
56
|
+
* group name reaches the network in a beacon, so defaulting to a hostname would
|
|
57
|
+
* quietly publish "mikes-laptop" to anything listening. A short slice of the
|
|
58
|
+
* node id is meaningless and safe, and `cluster nodes` shows it plainly so an
|
|
59
|
+
* operator who wants a real name can see there is one to set.
|
|
60
|
+
*/
|
|
61
|
+
function defaultNodeDisplayName(nodeId: string): string {
|
|
62
|
+
return `machine ${nodeId.slice(0, 8)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the group layer.
|
|
67
|
+
*
|
|
68
|
+
* Constructing it is inert: no socket is opened and no key material is read
|
|
69
|
+
* until `start()`, so composing a runtime in a test never touches the network
|
|
70
|
+
* or the secrets store.
|
|
71
|
+
*/
|
|
72
|
+
export function createClusterGroupComposition(options: {
|
|
73
|
+
readonly configManager: ConfigManager;
|
|
74
|
+
readonly shellPaths: ShellPathService;
|
|
75
|
+
readonly secretsManager: SecretsManager;
|
|
76
|
+
/**
|
|
77
|
+
* The leader election's own answer to "am I the master".
|
|
78
|
+
*
|
|
79
|
+
* Config replication needs exactly one machine issuing revisions, and it must
|
|
80
|
+
* be the SAME machine leadership already picked — two notions of master in
|
|
81
|
+
* one process would disagree the moment one of them changed.
|
|
82
|
+
*
|
|
83
|
+
* Late-bound because the coordinator is built from this composition's
|
|
84
|
+
* transport: it does not exist yet when this function runs.
|
|
85
|
+
*/
|
|
86
|
+
readonly isMaster?: (() => boolean) | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Which surfaces this machine currently holds, and why.
|
|
89
|
+
*
|
|
90
|
+
* The per-surface election establishes this; the group layer only reports it,
|
|
91
|
+
* in `cluster status` and in /status. Late-bound for the same reason
|
|
92
|
+
* `isMaster` is. Absent means the group layer reports that the information is
|
|
93
|
+
* unavailable rather than reporting an empty list as though this machine held
|
|
94
|
+
* nothing — a distinction an operator diagnosing a silent inbox depends on.
|
|
95
|
+
*/
|
|
96
|
+
readonly surfaceHoldings?: (() => readonly ClusterSurfaceHolding[]) | undefined;
|
|
97
|
+
}): ClusterGroupComposition {
|
|
98
|
+
const settings = readClusterSettings(options.configManager);
|
|
99
|
+
const groupSettings = resolveClusterGroupSettings(
|
|
100
|
+
(options.configManager as { getCategory?: (name: string) => unknown }).getCategory?.('cluster'),
|
|
101
|
+
);
|
|
102
|
+
// Surface-scoped, alongside the election's node identity and this surface's
|
|
103
|
+
// other durable state.
|
|
104
|
+
const stateDirectory = options.shellPaths.resolveProjectPath(GOODVIBES_DAEMON_SURFACE_ROOT, 'cluster');
|
|
105
|
+
const nodeId = resolveNodeIdentity({ stateDirectory, logger }).nodeId;
|
|
106
|
+
|
|
107
|
+
const runtime = new ClusterGroupRuntime({
|
|
108
|
+
settings: groupSettings,
|
|
109
|
+
transport: new UdpClusterTransport({
|
|
110
|
+
port: settings.port,
|
|
111
|
+
multicastGroup: settings.multicastGroup,
|
|
112
|
+
peers: settings.peers,
|
|
113
|
+
logger,
|
|
114
|
+
}),
|
|
115
|
+
// Narrower than the SecretsManager itself: the group layer gets get/set/
|
|
116
|
+
// delete on one key and nothing else.
|
|
117
|
+
secrets: {
|
|
118
|
+
get: (key) => options.secretsManager.get(key),
|
|
119
|
+
set: (key, value) => options.secretsManager.set(key, value),
|
|
120
|
+
delete: (key) => options.secretsManager.delete(key),
|
|
121
|
+
},
|
|
122
|
+
stateDirectory,
|
|
123
|
+
nodeId,
|
|
124
|
+
nodeDisplayName: defaultNodeDisplayName(nodeId),
|
|
125
|
+
version: VERSION,
|
|
126
|
+
clock: createSystemClusterClock(),
|
|
127
|
+
logger,
|
|
128
|
+
...(options.isMaster ? { isMaster: options.isMaster } : {}),
|
|
129
|
+
...(options.surfaceHoldings ? { surfaceHoldings: options.surfaceHoldings } : {}),
|
|
130
|
+
// Only daemon-owned, group-scoped keys ever reach this; the SDK's
|
|
131
|
+
// replication policy decides which, and refuses anything machine-specific.
|
|
132
|
+
config: {
|
|
133
|
+
get: (path) => (options.configManager as unknown as {
|
|
134
|
+
get(key: string): unknown;
|
|
135
|
+
}).get(path),
|
|
136
|
+
set: (path, value) => (options.configManager as unknown as {
|
|
137
|
+
set(key: string, value: unknown): void;
|
|
138
|
+
}).set(path, value),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const context: GroupOperationsContext = {
|
|
143
|
+
runtime,
|
|
144
|
+
secrets: {
|
|
145
|
+
get: (key) => options.secretsManager.get(key),
|
|
146
|
+
set: (key, value) => options.secretsManager.set(key, value),
|
|
147
|
+
delete: (key) => options.secretsManager.delete(key),
|
|
148
|
+
},
|
|
149
|
+
settings: groupSettings,
|
|
150
|
+
nodeId,
|
|
151
|
+
nodeDisplayName: defaultNodeDisplayName(nodeId),
|
|
152
|
+
version: VERSION,
|
|
153
|
+
now: () => Date.now(),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let started = false;
|
|
157
|
+
return {
|
|
158
|
+
runtime,
|
|
159
|
+
verbs: createClusterGroupVerbs(context),
|
|
160
|
+
electionTransport: runtime.electionTransport(),
|
|
161
|
+
start: async () => {
|
|
162
|
+
if (started) return;
|
|
163
|
+
started = true;
|
|
164
|
+
await runtime.start();
|
|
165
|
+
await announceReturn(runtime, context, nodeId);
|
|
166
|
+
},
|
|
167
|
+
stop: async () => {
|
|
168
|
+
started = false;
|
|
169
|
+
await runtime.stop();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build both halves of the LAN cluster, wired to each other.
|
|
176
|
+
*
|
|
177
|
+
* They are mutually dependent and the dependency runs both ways, which is why
|
|
178
|
+
* this exists rather than two calls at the composition root: the election
|
|
179
|
+
* coordinates over the GROUP's transport, and the group's config replication
|
|
180
|
+
* needs the ELECTION's answer to "am I the master". The master signal is read
|
|
181
|
+
* through a closure because the coordinator does not exist yet when the group
|
|
182
|
+
* layer is constructed.
|
|
183
|
+
*
|
|
184
|
+
* Constructing either is inert — no socket, no key material read — until
|
|
185
|
+
* `startCluster` runs.
|
|
186
|
+
*/
|
|
187
|
+
export function createClusterServices(options: {
|
|
188
|
+
readonly configManager: ConfigManager;
|
|
189
|
+
readonly shellPaths: ShellPathService;
|
|
190
|
+
readonly secretsManager: SecretsManager;
|
|
191
|
+
}): { readonly clusterGroup: ClusterGroupComposition; readonly clusterCoordinator: ClusterCoordinator } {
|
|
192
|
+
let coordinator: ClusterCoordinator | null = null;
|
|
193
|
+
const clusterGroup = createClusterGroupComposition({
|
|
194
|
+
...options,
|
|
195
|
+
isMaster: () => coordinator?.isMaster ?? false,
|
|
196
|
+
// The elections actually running are the only honest source for this, so
|
|
197
|
+
// the group layer reads them rather than keeping a second tally that could
|
|
198
|
+
// disagree. Before the coordinator exists there is nothing to report, and
|
|
199
|
+
// an empty list is the truthful answer at that point: no election has been
|
|
200
|
+
// held, so this machine holds nothing.
|
|
201
|
+
surfaceHoldings: () => coordinator?.surfaceHoldings() ?? [],
|
|
202
|
+
});
|
|
203
|
+
coordinator = createClusterComposition({
|
|
204
|
+
configManager: options.configManager,
|
|
205
|
+
shellPaths: options.shellPaths,
|
|
206
|
+
transport: clusterGroup.electionTransport,
|
|
207
|
+
});
|
|
208
|
+
return { clusterGroup, clusterCoordinator: coordinator };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Start the LAN cluster, in the order it has to be started in.
|
|
213
|
+
*
|
|
214
|
+
* The group layer FIRST: it owns the socket the leader election coordinates
|
|
215
|
+
* over, and starting the election against a transport whose group is not yet
|
|
216
|
+
* loaded would sign its first datagrams with nothing. Both calls are
|
|
217
|
+
* idempotent, so a composition root that reaches this twice is fine.
|
|
218
|
+
*/
|
|
219
|
+
export async function startClusterServices(services: {
|
|
220
|
+
readonly clusterGroup: Pick<ClusterGroupComposition, 'start'>;
|
|
221
|
+
readonly clusterCoordinator: { start(): Promise<void> };
|
|
222
|
+
}): Promise<void> {
|
|
223
|
+
await services.clusterGroup.start();
|
|
224
|
+
await services.clusterCoordinator.start();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Ask the group to take this machine back, on start.
|
|
229
|
+
*
|
|
230
|
+
* This is the zero-touch return. A machine that has been switched off for
|
|
231
|
+
* months — through many group-key rotations and possibly a join-key change —
|
|
232
|
+
* comes up holding stale keys, says "it is still me" with a key that never
|
|
233
|
+
* rotates, and is re-keyed to the current generation by whichever member
|
|
234
|
+
* answers. The operator does nothing.
|
|
235
|
+
*
|
|
236
|
+
* It runs only when this machine still believes it is on the roster. A machine
|
|
237
|
+
* that was REMOVED asks for nothing: it would be refused, and asking anyway
|
|
238
|
+
* would put a pointless refusal in everyone's logs every time it started.
|
|
239
|
+
*/
|
|
240
|
+
async function announceReturn(
|
|
241
|
+
runtime: ClusterGroupRuntime,
|
|
242
|
+
context: GroupOperationsContext,
|
|
243
|
+
nodeId: string,
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
if (runtime.membership !== 'member') return;
|
|
246
|
+
if (!stillOnRoster(runtime.groupState, nodeId)) return;
|
|
247
|
+
const result = await rejoinGroup(context);
|
|
248
|
+
if (result.ok) {
|
|
249
|
+
logger.info('cluster: rejoined the group and took the current group key', {
|
|
250
|
+
group: result.data.groupName,
|
|
251
|
+
members: result.data.memberCount,
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (result.terminal) {
|
|
256
|
+
// A member refused this machine and proved it was a member when it did.
|
|
257
|
+
// Retrying will never succeed, so this is not a quiet debug line: without
|
|
258
|
+
// it the operator sees a healthy daemon that is simply never given any
|
|
259
|
+
// work, with nothing anywhere saying why. Stated at ERROR with the command
|
|
260
|
+
// that fixes it, the same standard a surface this node cannot serve is
|
|
261
|
+
// held to.
|
|
262
|
+
logger.error('cluster: this machine is no longer in its group and will not be given work', {
|
|
263
|
+
reason: result.error,
|
|
264
|
+
action: result.fix,
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (result.failure === 'unverifiable-replies') {
|
|
269
|
+
// Something answered and none of it could be authenticated. This machine
|
|
270
|
+
// cannot tell a removal it slept through from a stranger making noise, so
|
|
271
|
+
// it says exactly that and does not assert either. WARN rather than ERROR
|
|
272
|
+
// for the same reason: anything on the network can produce this, and a
|
|
273
|
+
// machine that shouted "you were removed" on cue would be worse than one
|
|
274
|
+
// that stayed quiet.
|
|
275
|
+
logger.warn('cluster: replies to this machine\'s return could not be authenticated', {
|
|
276
|
+
reason: result.error,
|
|
277
|
+
action: result.fix,
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// Not an error. A machine that starts first, or is alone on the network, has
|
|
282
|
+
// nobody to answer it and carries on with the keys it already holds.
|
|
283
|
+
logger.debug('cluster: no other machine answered the return announcement', { reason: result.error });
|
|
284
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// conversation-rewind-port.ts — the daemon's RewindConversationPort for the
|
|
3
|
+
// SDK's unified rewind service.
|
|
4
|
+
//
|
|
5
|
+
// The SDK's UnifiedRewindService (platform/rewind) joins files rewind (workspace
|
|
6
|
+
// checkpoints) with conversation rewind through two ports. The conversation port
|
|
7
|
+
// is "a daemon-hosted mutable conversation store": preview() reports how many
|
|
8
|
+
// messages would truncate to a recorded turn boundary, and rewind() performs the
|
|
9
|
+
// truncation and captures the pre-/post-truncation snapshots so the reversal can
|
|
10
|
+
// be undone and re-applied. The truncation boundary is the message count recorded
|
|
11
|
+
// for the anchor's turnId at TURN_COMPLETED (the SDK's platform/rewind turn
|
|
12
|
+
// anchors) — the same
|
|
13
|
+
// join key files rewind uses against the workspace checkpoint.
|
|
14
|
+
//
|
|
15
|
+
// This module is the daemon's implementation of that port, for sessions THIS
|
|
16
|
+
// process holds the conversation for. It resolves the live conversation per
|
|
17
|
+
// anchor.sessionId from the registry below.
|
|
18
|
+
//
|
|
19
|
+
// While conversation loops run in the surfaces, that registry is empty here, and
|
|
20
|
+
// it used to answer an empty resolution with "0 messages to drop" — the same
|
|
21
|
+
// answer a conversation already at the anchor gives, which is a confident wrong
|
|
22
|
+
// answer rather than a missing one. It now reports the anchor as UNAVAILABLE
|
|
23
|
+
// with the reason, which the SDK's rewind service turns into a plan warning.
|
|
24
|
+
//
|
|
25
|
+
// The surfaces reach conversation rewind a different way now: they offer their
|
|
26
|
+
// live conversation over the control plane (rewind.conversation.*), and the
|
|
27
|
+
// SDK's host broker asks them directly. This port is the fallback the broker
|
|
28
|
+
// falls through to for sessions no surface has offered — which is exactly the
|
|
29
|
+
// case where the daemon hosts the conversation itself. Files-scope rewind,
|
|
30
|
+
// entirely daemon-owned, was never affected either way.
|
|
31
|
+
//
|
|
32
|
+
// The port is written against a structural conversation shape rather than any
|
|
33
|
+
// one surface's conversation class, so nothing about it is terminal-specific.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
import type {
|
|
37
|
+
RewindAnchor,
|
|
38
|
+
RewindConversationOutcome,
|
|
39
|
+
RewindConversationPort,
|
|
40
|
+
RewindConversationPreview,
|
|
41
|
+
} from '@pellux/goodvibes-sdk/platform/rewind';
|
|
42
|
+
import { resolveTurnAnchor } from '@pellux/goodvibes-sdk/platform/rewind';
|
|
43
|
+
|
|
44
|
+
/** Whatever a conversation is serialized as by the process that owns it. */
|
|
45
|
+
type ConversationJson = unknown;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The five operations a rewind needs from a conversation. Any host that can
|
|
49
|
+
* count its messages, serialize them, truncate to a boundary and reload a
|
|
50
|
+
* snapshot satisfies this.
|
|
51
|
+
*/
|
|
52
|
+
export interface RewindableConversation {
|
|
53
|
+
getMessageCount(): number;
|
|
54
|
+
toJSON(): ConversationJson;
|
|
55
|
+
fromJSON(json: ConversationJson): void;
|
|
56
|
+
rebuildHistory(): void;
|
|
57
|
+
removeMessagesAfter(count: number): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The port plus the reversal accessors an undo/redo surface needs. */
|
|
61
|
+
export interface ConversationRewindPort extends RewindConversationPort {
|
|
62
|
+
/** Restore the pre-truncation conversation (the /undo direction). */
|
|
63
|
+
restoreBefore(undoSnapshotId: string): boolean;
|
|
64
|
+
/** Restore the post-truncation conversation (the /redo direction). */
|
|
65
|
+
restoreAfter(undoSnapshotId: string): boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One truncation's captured state — the target conversation and its snapshots. */
|
|
69
|
+
interface SnapshotPair {
|
|
70
|
+
readonly conv: RewindableConversation;
|
|
71
|
+
readonly before: ConversationJson;
|
|
72
|
+
readonly after: ConversationJson;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What this port says when it holds no conversation for the session asked about. */
|
|
76
|
+
const NO_LIVE_CONVERSATION =
|
|
77
|
+
'this daemon holds no live conversation for that session, so it cannot count or drop its messages';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a conversation rewind port. `resolveConversation` maps an anchor's
|
|
81
|
+
* sessionId to the live conversation: the daemon looks the session up in the
|
|
82
|
+
* registry below. A null resolution is reported as unavailable with the reason
|
|
83
|
+
* — never as a count, because "nobody here is holding those messages" and
|
|
84
|
+
* "there are no messages to drop" are different facts and only one of them is
|
|
85
|
+
* true.
|
|
86
|
+
*/
|
|
87
|
+
export function createConversationRewindPort(
|
|
88
|
+
resolveConversation: (sessionId: string) => RewindableConversation | null,
|
|
89
|
+
): ConversationRewindPort {
|
|
90
|
+
const snapshots = new Map<string, SnapshotPair>();
|
|
91
|
+
|
|
92
|
+
function keepFor(anchor: RewindAnchor): { conv: RewindableConversation | null; keep: number; total: number } {
|
|
93
|
+
const conv = resolveConversation(anchor.sessionId);
|
|
94
|
+
if (!conv) return { conv: null, keep: 0, total: 0 };
|
|
95
|
+
const total = conv.getMessageCount();
|
|
96
|
+
const rec = anchor.turnId ? resolveTurnAnchor(anchor.sessionId, anchor.turnId) : null;
|
|
97
|
+
const keep = rec ? Math.min(rec.messageCount, total) : total;
|
|
98
|
+
return { conv, keep, total };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function restore(snapshot: SnapshotPair | undefined, which: 'before' | 'after'): boolean {
|
|
102
|
+
if (!snapshot) return false;
|
|
103
|
+
snapshot.conv.fromJSON(snapshot[which]);
|
|
104
|
+
snapshot.conv.rebuildHistory();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
async preview(anchor: RewindAnchor): Promise<RewindConversationPreview> {
|
|
110
|
+
const { conv, keep, total } = keepFor(anchor);
|
|
111
|
+
if (!conv) {
|
|
112
|
+
return { messagesToDrop: 0, messagesRemaining: 0, available: false, unavailableReason: NO_LIVE_CONVERSATION };
|
|
113
|
+
}
|
|
114
|
+
return { messagesToDrop: Math.max(0, total - keep), messagesRemaining: keep };
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async rewind(anchor: RewindAnchor): Promise<RewindConversationOutcome> {
|
|
118
|
+
const { conv, keep, total } = keepFor(anchor);
|
|
119
|
+
const undoSnapshotId = `rwc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
120
|
+
if (!conv) {
|
|
121
|
+
return { droppedMessages: 0, undoSnapshotId: '', available: false, unavailableReason: NO_LIVE_CONVERSATION };
|
|
122
|
+
}
|
|
123
|
+
const before = conv.toJSON();
|
|
124
|
+
conv.removeMessagesAfter(keep);
|
|
125
|
+
conv.rebuildHistory();
|
|
126
|
+
const after = conv.toJSON();
|
|
127
|
+
snapshots.set(undoSnapshotId, { conv, before, after });
|
|
128
|
+
return { droppedMessages: Math.max(0, total - keep), undoSnapshotId };
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
restoreBefore(undoSnapshotId: string): boolean {
|
|
132
|
+
return restore(snapshots.get(undoSnapshotId), 'before');
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
restoreAfter(undoSnapshotId: string): boolean {
|
|
136
|
+
return restore(snapshots.get(undoSnapshotId), 'after');
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Live per-session conversation registry — the daemon-hosted mutable store the
|
|
143
|
+
// composed daemon's rewind.plan/apply verbs fall back to. A process INSIDE this
|
|
144
|
+
// daemon that runs a conversation registers it here; a surface in another
|
|
145
|
+
// process offers its conversation over the control plane instead
|
|
146
|
+
// (rewind.conversation.host.register), and that offer is consulted first.
|
|
147
|
+
// A session in neither reports conversation rewind as unavailable, with the
|
|
148
|
+
// reason, rather than as a count.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const liveConversations = new Map<string, RewindableConversation>();
|
|
152
|
+
|
|
153
|
+
/** Register a session's live conversation so the daemon rewind verbs can serve it. */
|
|
154
|
+
export function registerSessionConversation(sessionId: string, conversation: RewindableConversation): void {
|
|
155
|
+
if (sessionId) liveConversations.set(sessionId, conversation);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Drop a session's conversation registration. */
|
|
159
|
+
export function unregisterSessionConversation(sessionId: string): void {
|
|
160
|
+
liveConversations.delete(sessionId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The conversation rewind port the composed daemon threads into
|
|
165
|
+
* registerGatewayVerbGroups — it resolves each anchor's live conversation from
|
|
166
|
+
* the registry above, so the daemon's own rewind verbs serve conversation scope
|
|
167
|
+
* live in this process.
|
|
168
|
+
*/
|
|
169
|
+
export function createSessionConversationRewindPort(): ConversationRewindPort {
|
|
170
|
+
return createConversationRewindPort((sessionId) => liveConversations.get(sessionId) ?? null);
|
|
171
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential-composition.ts
|
|
3
|
+
*
|
|
4
|
+
* The credential and identity seam: the secret store, the step-up ceremony
|
|
5
|
+
* service that verifies against it, and the per-device pairing tokens.
|
|
6
|
+
*
|
|
7
|
+
* These are grouped because they share one fact, and it is the fact this round
|
|
8
|
+
* had to fix: WHERE the daemon's own credentials live. `daemonHome` is threaded
|
|
9
|
+
* rather than defaulted, because without it a daemon told to run out of a temp
|
|
10
|
+
* tree moved its identity directory and nothing else — the credential store
|
|
11
|
+
* stayed in the real home, so an "isolated" test daemon held live credentials
|
|
12
|
+
* and long-polled a real account. A default here would silently restore that.
|
|
13
|
+
*
|
|
14
|
+
* Extracted from services.ts because that file sits at the 800-line
|
|
15
|
+
* architecture cap. Moving a coherent seam out is what the cap is for; trimming
|
|
16
|
+
* unrelated comments to buy headroom is the thing it exists to prevent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { SecretsManager } from '../config/secrets.ts';
|
|
20
|
+
import { StepUpService } from '@pellux/goodvibes-sdk/daemon';
|
|
21
|
+
import { PairingTokenManager } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
22
|
+
|
|
23
|
+
interface CredentialCompositionInput {
|
|
24
|
+
readonly workingDirectory: string;
|
|
25
|
+
readonly homeDirectory: string;
|
|
26
|
+
/**
|
|
27
|
+
* The daemon's state root when the host was told one. Absent means "not
|
|
28
|
+
* overridden" and must stay absent rather than being defaulted — see above.
|
|
29
|
+
*/
|
|
30
|
+
readonly daemonHomeDirectory?: string | undefined;
|
|
31
|
+
readonly configManager: ConstructorParameters<typeof SecretsManager>[0]['configManager'];
|
|
32
|
+
readonly pairingTokenPath: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function composeCredentialServices(input: CredentialCompositionInput): {
|
|
36
|
+
readonly secretsManager: SecretsManager;
|
|
37
|
+
readonly stepUpService: StepUpService;
|
|
38
|
+
readonly pairingTokens: PairingTokenManager;
|
|
39
|
+
} {
|
|
40
|
+
const secretsManager = new SecretsManager({
|
|
41
|
+
projectRoot: input.workingDirectory,
|
|
42
|
+
globalHome: input.homeDirectory,
|
|
43
|
+
// Threaded, not defaulted: else an isolated daemon reads the real store.
|
|
44
|
+
...(input.daemonHomeDirectory === undefined ? {} : { daemonHome: input.daemonHomeDirectory }),
|
|
45
|
+
configManager: input.configManager,
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
secretsManager,
|
|
49
|
+
// Shared between the ceremony gateway verbs and the relay gate's verifier,
|
|
50
|
+
// so both check a step-up against the same store.
|
|
51
|
+
stepUpService: new StepUpService({ secrets: secretsManager }),
|
|
52
|
+
pairingTokens: new PairingTokenManager(input.pairingTokenPath),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-handler-composition.ts — the daemon's HOST-side handler surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Attaches this repository's handlers to the SDK-auto-registered builtin
|
|
5
|
+
* gateway descriptors (channels.* / email.* / calendar.*) via
|
|
6
|
+
* catalog.register(descriptor, handler, { replace: true }) — the SDK owns
|
|
7
|
+
* every id, descriptor and schema; only the behaviour is ours. The remote
|
|
8
|
+
* surface reuses the SAME DistributedRuntimeManager the SDK facade injects.
|
|
9
|
+
*
|
|
10
|
+
* Split out of services.ts for the 800-line file cap, the same reason
|
|
11
|
+
* the SDK's channel composition exists.
|
|
12
|
+
*
|
|
13
|
+
* The one behavioural decision that lives here: the inbox poller is handed to
|
|
14
|
+
* the cluster coordinator instead of being started eagerly. It is this
|
|
15
|
+
* product's own inbound consumer — the SDK facade does not know it exists —
|
|
16
|
+
* so if it is not gated here it is not gated anywhere, and two goodvibes nodes
|
|
17
|
+
* on one network each read the shared inbox and answer the same message twice.
|
|
18
|
+
*/
|
|
19
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
20
|
+
import type { ClusterCoordinator } from '@pellux/goodvibes-sdk/platform/cluster';
|
|
21
|
+
import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
22
|
+
import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
23
|
+
import { registerDaemonHandlers, type DaemonHandlerSurfaces } from '../daemon/handlers/index.ts';
|
|
24
|
+
import type { HandlerContext, HandlerLogger } from '../daemon/handlers/context.ts';
|
|
25
|
+
import { createDaemonCredentialStore } from '../daemon/handlers/credentials.ts';
|
|
26
|
+
import { registerRouting } from '../daemon/handlers/routing/index.ts';
|
|
27
|
+
import { registerInboxMethods } from '../daemon/handlers/inbox/index.ts';
|
|
28
|
+
import { registerTriagedInbox } from '../daemon/handlers/triage/index.ts';
|
|
29
|
+
import { registerDraftMethods } from '../daemon/handlers/drafts/index.ts';
|
|
30
|
+
import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
|
|
31
|
+
import { inboxPollerGate } from './cluster-composition.ts';
|
|
32
|
+
|
|
33
|
+
export interface DaemonHandlerCompositionOptions {
|
|
34
|
+
readonly gatewayMethods: GatewayMethodCatalog;
|
|
35
|
+
readonly secretsManager: SecretsManager;
|
|
36
|
+
readonly configManager: ConfigManager;
|
|
37
|
+
readonly workingDirectory: string;
|
|
38
|
+
readonly homeDirectory: string;
|
|
39
|
+
readonly distributedRuntime: NonNullable<Parameters<typeof registerRemoteSurface>[1]>['manager'];
|
|
40
|
+
/**
|
|
41
|
+
* Decides whether THIS node polls the shared inbox. Always supplied by the
|
|
42
|
+
* composition root; the poller is never started outside it.
|
|
43
|
+
*/
|
|
44
|
+
readonly clusterCoordinator: ClusterCoordinator;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createDaemonHandlerComposition(
|
|
48
|
+
options: DaemonHandlerCompositionOptions,
|
|
49
|
+
): DaemonHandlerSurfaces {
|
|
50
|
+
const handlerLogger: HandlerLogger = {
|
|
51
|
+
info: (message, meta) => console.info(message, meta ?? ''),
|
|
52
|
+
warn: (message, meta) => console.warn(message, meta ?? ''),
|
|
53
|
+
error: (message, meta) => console.error(message, meta ?? ''),
|
|
54
|
+
};
|
|
55
|
+
const handlerContext: HandlerContext = {
|
|
56
|
+
catalog: options.gatewayMethods,
|
|
57
|
+
credentials: createDaemonCredentialStore(options.secretsManager),
|
|
58
|
+
configManager: options.configManager,
|
|
59
|
+
workingDirectory: options.workingDirectory,
|
|
60
|
+
homeDirectory: options.homeDirectory,
|
|
61
|
+
logger: handlerLogger,
|
|
62
|
+
};
|
|
63
|
+
return registerDaemonHandlers(handlerContext, {
|
|
64
|
+
registerRouting,
|
|
65
|
+
registerInbox: (ctx, routing) =>
|
|
66
|
+
registerTriagedInbox(ctx, (inboxCtx) => registerInboxMethods(inboxCtx, routing, {
|
|
67
|
+
// Hands polling to leadership. The `channels.inbox.list` read stays
|
|
68
|
+
// available on every node — a standby still SERVES the persisted feed,
|
|
69
|
+
// it just does not FETCH into it.
|
|
70
|
+
gatePolling: (providerId, control) =>
|
|
71
|
+
options.clusterCoordinator.register(inboxPollerGate(providerId, control)),
|
|
72
|
+
})).unregister,
|
|
73
|
+
registerDrafts: (ctx) => registerDraftMethods(ctx),
|
|
74
|
+
registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
|
|
75
|
+
});
|
|
76
|
+
}
|