@pellux/goodvibes-tui 1.23.0 → 1.24.1
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 +22 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -3
- package/src/cli/bundle-command.ts +4 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/management-utils.ts +4 -0
- package/src/cluster/commands.ts +382 -0
- package/src/cluster/remote-daemon-target.ts +225 -0
- package/src/cluster/render.ts +268 -0
- package/src/core/conversation-line-cache.ts +11 -6
- package/src/core/conversation-render-context.ts +122 -0
- package/src/core/conversation-rendering.ts +86 -89
- package/src/daemon/cli.ts +73 -2
- package/src/daemon/handlers/inbox/cursor-store.ts +12 -1
- package/src/daemon/handlers/inbox/index.ts +63 -3
- package/src/daemon/handlers/inbox/poller.ts +66 -10
- package/src/daemon/handlers/index.ts +9 -0
- package/src/input/command-args-hint.ts +2 -0
- package/src/input/commands/cluster-runtime.ts +53 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-data.ts +6 -0
- package/src/main.ts +3 -0
- package/src/renderer/conversation-surface.ts +23 -6
- package/src/renderer/conversation-tree.ts +142 -93
- package/src/renderer/tool-call.ts +13 -12
- package/src/runtime/agent-graph-composition.ts +78 -0
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/daemon-handler-composition.ts +80 -0
- package/src/runtime/disposal-wiring.ts +83 -0
- package/src/runtime/durability-services.ts +7 -3
- package/src/runtime/embedded-service-factories.ts +19 -1
- package/src/runtime/process-lifecycle.ts +9 -1
- package/src/runtime/remote-execution-composition.ts +62 -0
- package/src/runtime/services.ts +73 -76
- package/src/verification/verification-ledger.ts +38 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cluster-composition.ts — this daemon's seat in the LAN leader election.
|
|
3
|
+
*
|
|
4
|
+
* When the same goodvibes install runs more than once on one network — a
|
|
5
|
+
* laptop and a desktop, or two processes on one machine — every copy
|
|
6
|
+
* independently reads the shared inbox, so one message is picked up twice and
|
|
7
|
+
* answered twice. The SDK's cluster coordinator elects exactly one node to be
|
|
8
|
+
* responsible for inbound consumption; everything else stays warm and silent.
|
|
9
|
+
*
|
|
10
|
+
* This file exists because the goodvibes-tui daemon does NOT get that for free
|
|
11
|
+
* from the SDK facade. The facade gates the consumers IT owns (Telegram
|
|
12
|
+
* ingress, the ntfy/Slack/Discord provider runtime), but this repository
|
|
13
|
+
* composes an inbound consumer of its own — the Slack/Discord/email inbox
|
|
14
|
+
* poller in daemon/handlers/inbox — and the facade knows nothing about it.
|
|
15
|
+
* A fix that only landed in the facade would leave the poller double-reading
|
|
16
|
+
* on exactly the machine this product runs on.
|
|
17
|
+
*
|
|
18
|
+
* So this composition builds the ONE coordinator for the process, registers
|
|
19
|
+
* the inbox poller with it, and hands the same instance to the DaemonServer
|
|
20
|
+
* (via `DaemonConfig.clusterCoordinator`) so the SDK's consumers ride the same
|
|
21
|
+
* leadership rather than holding a second, competing election.
|
|
22
|
+
*
|
|
23
|
+
* What leadership does NOT gate, here or anywhere: outbound delivery,
|
|
24
|
+
* sessions, the control plane, the HTTP listener, the UI. A standby node is a
|
|
25
|
+
* complete goodvibes daemon that simply is not the one reading the inbox.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
ClusterCoordinator,
|
|
29
|
+
inboxSurface,
|
|
30
|
+
readClusterSettings,
|
|
31
|
+
type ClusterConsumerGate,
|
|
32
|
+
type ClusterTransport,
|
|
33
|
+
} from '@pellux/goodvibes-sdk/platform/cluster';
|
|
34
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
35
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
36
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
37
|
+
import { GOODVIBES_TUI_SURFACE_ROOT } from '../config/surface.ts';
|
|
38
|
+
import { VERSION } from '../version.ts';
|
|
39
|
+
|
|
40
|
+
/** What an inbound poller must expose to be placed under leadership. */
|
|
41
|
+
export interface GatedPollerControl {
|
|
42
|
+
/** Begin polling. Must not resolve until polling has actually begun. */
|
|
43
|
+
start(): Promise<void>;
|
|
44
|
+
/** Stop polling. Must not resolve until no further poll can run. */
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the process's single coordinator.
|
|
50
|
+
*
|
|
51
|
+
* Constructing it is inert: no socket is opened and no state is written until
|
|
52
|
+
* `start()` runs, so composing a runtime in a test never joins a network.
|
|
53
|
+
*
|
|
54
|
+
* The version is THIS BINARY's version, not the SDK package's — the same
|
|
55
|
+
* distinction daemon/cli.ts already makes for the self-update artifact. It is
|
|
56
|
+
* the first ranking tier, so getting it wrong would let a stale build hold the
|
|
57
|
+
* role through an update.
|
|
58
|
+
*/
|
|
59
|
+
export function createClusterComposition(options: {
|
|
60
|
+
/**
|
|
61
|
+
* Narrowed to what is actually read, which is exactly what
|
|
62
|
+
* `readClusterSettings` asks for. The daemon passes a whole ConfigManager;
|
|
63
|
+
* the handler context carries only this slice, and a test standing in a
|
|
64
|
+
* minimal config object should not have to fabricate the full fifty-odd
|
|
65
|
+
* members to compose a coordinator — a cast there would hide a real shape
|
|
66
|
+
* mismatch rather than document one.
|
|
67
|
+
*/
|
|
68
|
+
readonly configManager: Pick<ConfigManager, 'getCategory'>;
|
|
69
|
+
readonly shellPaths: ShellPathService;
|
|
70
|
+
/**
|
|
71
|
+
* The datagram transport to coordinate over.
|
|
72
|
+
*
|
|
73
|
+
* Supplied by the group layer (cluster-group-composition.ts), which owns the
|
|
74
|
+
* socket and wraps every election datagram in the group envelope, signed with
|
|
75
|
+
* the current group key. That is what stops a daemon belonging to somebody
|
|
76
|
+
* else on the same network from taking part in this election at all.
|
|
77
|
+
*
|
|
78
|
+
* Absent — the plain default — means the coordinator opens its own socket and
|
|
79
|
+
* coordinates with anything that answers on the configured port.
|
|
80
|
+
*/
|
|
81
|
+
readonly transport?: ClusterTransport | undefined;
|
|
82
|
+
}): ClusterCoordinator {
|
|
83
|
+
return new ClusterCoordinator({
|
|
84
|
+
settings: readClusterSettings(options.configManager),
|
|
85
|
+
version: VERSION,
|
|
86
|
+
// Surface-scoped, alongside this surface's other durable state: the node
|
|
87
|
+
// identity is per-surface and must not leak across surface roots.
|
|
88
|
+
stateDirectory: options.shellPaths.resolveProjectPath(GOODVIBES_TUI_SURFACE_ROOT, 'cluster'),
|
|
89
|
+
logger,
|
|
90
|
+
...(options.transport ? { transport: options.transport } : {}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* ONE inbox provider as a leadership gate.
|
|
96
|
+
*
|
|
97
|
+
* One gate per account, not one for the poller. Each inbox account is its own
|
|
98
|
+
* surface in the election, so this laptop can hold the work Slack account
|
|
99
|
+
* while the desktop holds the mailbox — and losing one machine moves only the
|
|
100
|
+
* accounts it was reading. A single gate covering the whole poller could not
|
|
101
|
+
* express that: it would hand every account over together, and a node with a
|
|
102
|
+
* credential for only one of them could never take part at all.
|
|
103
|
+
*
|
|
104
|
+
* The provider id is the surface's local discriminator and is hashed before it
|
|
105
|
+
* reaches the network, so what travels is a digest and not an account name.
|
|
106
|
+
*
|
|
107
|
+
* No replay cursor is threaded in, and that is deliberate rather than an
|
|
108
|
+
* omission: the inbox keeps a persisted per-provider cursor of its own
|
|
109
|
+
* (InboxCursorStore), so a node taking an account over resumes from where the
|
|
110
|
+
* previous one committed. ntfy needs an explicit `since=` because it has no
|
|
111
|
+
* server-side per-subscriber cursor; this poller does not.
|
|
112
|
+
*/
|
|
113
|
+
export function inboxPollerGate(providerId: string, control: GatedPollerControl): ClusterConsumerGate {
|
|
114
|
+
return {
|
|
115
|
+
id: `inbox-poller:${providerId}`,
|
|
116
|
+
surface: inboxSurface(providerId),
|
|
117
|
+
start: async () => {
|
|
118
|
+
await control.start();
|
|
119
|
+
},
|
|
120
|
+
stop: async () => {
|
|
121
|
+
await control.stop();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -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_TUI_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_TUI_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,80 @@
|
|
|
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
|
+
* channel-composition.ts 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 { registerCalendar } from '../daemon/handlers/calendar/index.ts';
|
|
31
|
+
import { registerEmailMethods } from '../daemon/handlers/email/index.ts';
|
|
32
|
+
import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
|
|
33
|
+
import { inboxPollerGate } from './cluster-composition.ts';
|
|
34
|
+
|
|
35
|
+
export interface DaemonHandlerCompositionOptions {
|
|
36
|
+
readonly gatewayMethods: GatewayMethodCatalog;
|
|
37
|
+
readonly secretsManager: SecretsManager;
|
|
38
|
+
readonly configManager: ConfigManager;
|
|
39
|
+
readonly workingDirectory: string;
|
|
40
|
+
readonly homeDirectory: string;
|
|
41
|
+
readonly distributedRuntime: NonNullable<Parameters<typeof registerRemoteSurface>[1]>['manager'];
|
|
42
|
+
/**
|
|
43
|
+
* Decides whether THIS node polls the shared inbox. Always supplied by the
|
|
44
|
+
* composition root; the poller is never started outside it.
|
|
45
|
+
*/
|
|
46
|
+
readonly clusterCoordinator: ClusterCoordinator;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createDaemonHandlerComposition(
|
|
50
|
+
options: DaemonHandlerCompositionOptions,
|
|
51
|
+
): DaemonHandlerSurfaces {
|
|
52
|
+
const handlerLogger: HandlerLogger = {
|
|
53
|
+
info: (message, meta) => console.info(message, meta ?? ''),
|
|
54
|
+
warn: (message, meta) => console.warn(message, meta ?? ''),
|
|
55
|
+
error: (message, meta) => console.error(message, meta ?? ''),
|
|
56
|
+
};
|
|
57
|
+
const handlerContext: HandlerContext = {
|
|
58
|
+
catalog: options.gatewayMethods,
|
|
59
|
+
credentials: createDaemonCredentialStore(options.secretsManager),
|
|
60
|
+
configManager: options.configManager,
|
|
61
|
+
workingDirectory: options.workingDirectory,
|
|
62
|
+
homeDirectory: options.homeDirectory,
|
|
63
|
+
logger: handlerLogger,
|
|
64
|
+
};
|
|
65
|
+
return registerDaemonHandlers(handlerContext, {
|
|
66
|
+
registerRouting,
|
|
67
|
+
registerInbox: (ctx, routing) =>
|
|
68
|
+
registerTriagedInbox(ctx, (inboxCtx) => registerInboxMethods(inboxCtx, routing, {
|
|
69
|
+
// Hands polling to leadership. The `channels.inbox.list` read stays
|
|
70
|
+
// available on every node — a standby still SERVES the persisted feed,
|
|
71
|
+
// it just does not FETCH into it.
|
|
72
|
+
gatePolling: (providerId, control) =>
|
|
73
|
+
options.clusterCoordinator.register(inboxPollerGate(providerId, control)),
|
|
74
|
+
})).unregister,
|
|
75
|
+
registerDrafts: (ctx) => registerDraftMethods(ctx),
|
|
76
|
+
registerCalendar,
|
|
77
|
+
registerEmail: (ctx) => registerEmailMethods(ctx),
|
|
78
|
+
registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disposal-wiring.ts
|
|
3
|
+
*
|
|
4
|
+
* Teardown for every poller this surface's runtime graph starts.
|
|
5
|
+
*
|
|
6
|
+
* The mechanics — the ordered, best-effort, idempotent scope and the
|
|
7
|
+
* all-required owner list — live in the SDK (`platform/runtime/disposal`), so
|
|
8
|
+
* this surface and the SDK's own composition root cannot drift into two
|
|
9
|
+
* different ideas of what "stop the graph" means. What lives here is only the
|
|
10
|
+
* mapping from this fork's assembled graph onto that list.
|
|
11
|
+
*
|
|
12
|
+
* It is a separate module because `runtime/services.ts` sits against the repo's
|
|
13
|
+
* 800-line source cap (check-architecture.ts) with no room for the wiring.
|
|
14
|
+
*
|
|
15
|
+
* Ownership note, and the reason any of this exists: this surface builds the
|
|
16
|
+
* graph and hands the SAME object to `DaemonServer`. The SDK facade disposes
|
|
17
|
+
* only a graph it constructed itself, so it deliberately leaves this one alone
|
|
18
|
+
* — nothing upstream will ever stop these pollers. The shutdown paths in
|
|
19
|
+
* daemon/cli.ts, main.ts's teardown registry and the one-shot CLI commands are
|
|
20
|
+
* the only things that can.
|
|
21
|
+
*
|
|
22
|
+
* The owner type below is declared structurally rather than imported from
|
|
23
|
+
* services.ts: that module imports this one, and a type-only edge is still a
|
|
24
|
+
* cycle to the architecture check.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createDisposalScope,
|
|
29
|
+
registerRuntimePollers,
|
|
30
|
+
type DisposalRegistry,
|
|
31
|
+
type RuntimePollerOwners,
|
|
32
|
+
} from '@pellux/goodvibes-sdk/platform/runtime/disposal';
|
|
33
|
+
|
|
34
|
+
/** Re-exported so the composition root reaches the whole seam through one import. */
|
|
35
|
+
export { createDisposalScope };
|
|
36
|
+
|
|
37
|
+
/** The assembled graph, narrowed to the poller owners it exposes as fields. */
|
|
38
|
+
export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 'stopConfigWatch'> {
|
|
39
|
+
/** Fork-only: the repeating crash-residue sweep (durability-services.ts). */
|
|
40
|
+
readonly stopDurabilityHousekeeping: () => void;
|
|
41
|
+
/**
|
|
42
|
+
* Fork-only: the daemon handler surfaces (daemon-handler-composition.ts).
|
|
43
|
+
*
|
|
44
|
+
* `unregister()` detaches the gateway handlers AND stops the two pollers this
|
|
45
|
+
* product's inbox surface owns — the retention sweep inside `InboxCursorStore`
|
|
46
|
+
* and the per-provider `InboundPoller` intervals. The SDK does not know either
|
|
47
|
+
* exists, so if this surface does not stop them nothing does.
|
|
48
|
+
*/
|
|
49
|
+
readonly daemonHandlers: { readonly unregister: () => void };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The poller owners this fork holds that are NOT reachable from the assembled
|
|
54
|
+
* graph — handles the factory keeps as locals.
|
|
55
|
+
*/
|
|
56
|
+
export interface RuntimeDisposalExtras {
|
|
57
|
+
/** Handle returned by `ConfigManager.watchConfigFiles()` (durability-services.ts). */
|
|
58
|
+
readonly stopConfigWatch: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register the stop call for every poller the graph started.
|
|
63
|
+
*
|
|
64
|
+
* `services` is the fully-assembled graph, which already exposes each poller
|
|
65
|
+
* owner as a field — so a poller whose owner reaches the public surface is
|
|
66
|
+
* wired by name rather than by threading another local out of the factory.
|
|
67
|
+
*/
|
|
68
|
+
export function registerSurfaceRuntimePollers(
|
|
69
|
+
registry: DisposalRegistry,
|
|
70
|
+
services: SurfaceRuntimePollerOwners,
|
|
71
|
+
extras: RuntimeDisposalExtras,
|
|
72
|
+
): void {
|
|
73
|
+
registerRuntimePollers(registry, { ...services, stopConfigWatch: extras.stopConfigWatch });
|
|
74
|
+
registry.add('durability housekeeping', services.stopDurabilityHousekeeping);
|
|
75
|
+
// Registered LAST so it tears down FIRST (the scope unwinds in reverse), which
|
|
76
|
+
// is the order daemon/cli.ts already used by hand: release the handler surfaces
|
|
77
|
+
// — closing the inbox store and stopping its poll timers — before the pollers
|
|
78
|
+
// the rest of the graph owns. Being on this list is what makes a plain
|
|
79
|
+
// `dispose()` total: every shutdown path (daemon/cli.ts, main.ts's teardown
|
|
80
|
+
// registry, the one-shot CLI commands) now stops these two, not just the one
|
|
81
|
+
// that remembered to call `unregister()` itself.
|
|
82
|
+
registry.add('daemon handler surfaces', () => services.daemonHandlers.unregister());
|
|
83
|
+
}
|
|
@@ -93,6 +93,8 @@ export interface DurabilityServices {
|
|
|
93
93
|
* the work rather than to release the loop.
|
|
94
94
|
*/
|
|
95
95
|
readonly stopDurabilityHousekeeping: () => void;
|
|
96
|
+
/** Stops the live config-file watch this factory started. */
|
|
97
|
+
readonly stopConfigWatch: () => void;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
export function createDurabilityServices(input: DurabilityServicesInput): DurabilityServices {
|
|
@@ -141,11 +143,13 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
|
|
|
141
143
|
extraQuarantineDirs: [configManager.getControlPlaneConfigDir()],
|
|
142
144
|
});
|
|
143
145
|
// External config edits apply LIVE through the same subscribe() pipeline an
|
|
144
|
-
// in-process set() uses; the underlying watchers are unref'd.
|
|
145
|
-
|
|
146
|
+
// in-process set() uses; the underlying watchers are unref'd. The returned
|
|
147
|
+
// handle is the only way to stop them — dropping it left a 250ms poll running
|
|
148
|
+
// for the life of the process after the graph that started it was gone.
|
|
149
|
+
const stopConfigWatch = configManager.watchConfigFiles();
|
|
146
150
|
|
|
147
151
|
const userPermissionRuleStore = new UserPermissionRuleStore(join(configManager.getControlPlaneConfigDir(), 'permission-rules.json'));
|
|
148
152
|
void userPermissionRuleStore.init().catch((error) => logger.warn('user permission rule store init failed; asks will prompt', { error: summarizeError(error) }));
|
|
149
153
|
|
|
150
|
-
return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping };
|
|
154
|
+
return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping, stopConfigWatch };
|
|
151
155
|
}
|
|
@@ -11,12 +11,23 @@
|
|
|
11
11
|
* at all. See daemon/lifecycle.ts.
|
|
12
12
|
*/
|
|
13
13
|
import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
14
|
+
import type { ClusterCoordinator, ClusterGroupVerbSurface } from '@pellux/goodvibes-sdk/platform/cluster';
|
|
14
15
|
import { createSafeHostServeFactory } from '../daemon/safe-serve.ts';
|
|
15
16
|
import type { startExternalServices } from '@/runtime/index.ts';
|
|
16
17
|
|
|
17
18
|
export type ExternalServiceFactories = NonNullable<Parameters<typeof startExternalServices>[4]>;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* `clusterCoordinator` is threaded through explicitly rather than read off the
|
|
22
|
+
* runtimeServices the SDK hands the factory: that object is typed by the SDK,
|
|
23
|
+
* which has no field for a coordinator this product composed. Passing it here
|
|
24
|
+
* keeps the ONE-coordinator-per-process rule enforceable by the type checker.
|
|
25
|
+
*/
|
|
26
|
+
export function createEmbeddedServiceFactories(
|
|
27
|
+
sharedDaemonToken: string,
|
|
28
|
+
clusterCoordinator: ClusterCoordinator,
|
|
29
|
+
clusterGroupVerbs: ClusterGroupVerbSurface,
|
|
30
|
+
): ExternalServiceFactories {
|
|
20
31
|
return {
|
|
21
32
|
sharedDaemonToken,
|
|
22
33
|
createDaemonServer: (bus, userAuth, runtimeServices) => {
|
|
@@ -27,6 +38,13 @@ export function createEmbeddedServiceFactories(sharedDaemonToken: string): Exter
|
|
|
27
38
|
userAuth,
|
|
28
39
|
runtimeServices,
|
|
29
40
|
serveFactory: createSafeHostServeFactory('Embedded daemon'),
|
|
41
|
+
// The SAME coordinator the inbox poller registered with. Letting the
|
|
42
|
+
// facade compose its own would put two election nodes in one process,
|
|
43
|
+
// and whichever lost would silence consumers the other owns.
|
|
44
|
+
clusterCoordinator,
|
|
45
|
+
// The `cluster` verbs (/api/cluster/*), so the CLI and the TUI command
|
|
46
|
+
// reach one implementation whether the daemon is embedded or standalone.
|
|
47
|
+
clusterGroupVerbs,
|
|
30
48
|
});
|
|
31
49
|
return daemonServer;
|
|
32
50
|
},
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* `unsubs` registry is shared by reference and drained on exit.
|
|
20
20
|
*/
|
|
21
21
|
import { existsSync } from 'node:fs';
|
|
22
|
-
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
22
|
+
import { flushActivityLogSync, logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
23
23
|
import { createTerminalLifecycle, TERMINAL_ESCAPES } from '@pellux/goodvibes-terminal-shell';
|
|
24
24
|
import { formatUserFacingErrorLine } from '../core/format-user-error.ts';
|
|
25
25
|
import { allowTerminalWrite } from './terminal-output-guard.ts';
|
|
@@ -201,11 +201,17 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
|
|
|
201
201
|
const uncaughtExceptionHandler = (err: Error): void => {
|
|
202
202
|
restoreTerminal();
|
|
203
203
|
logger.error('uncaughtException — terminal restored, exiting', { error: summarizeError(err) });
|
|
204
|
+
// The line naming the crash is the only record of it. process.exit does not
|
|
205
|
+
// drain the activity log's buffer, so it goes to disk here rather than
|
|
206
|
+
// relying on the exit hook that also runs — the crash log is worth stating
|
|
207
|
+
// the intent for at the site that produces it.
|
|
208
|
+
flushActivityLogSync();
|
|
204
209
|
process.exit(1);
|
|
205
210
|
};
|
|
206
211
|
const terminationSignalHandler = (signal: NodeJS.Signals): void => {
|
|
207
212
|
restoreTerminal();
|
|
208
213
|
logger.error(`Received ${signal} — terminal restored, exiting`, {});
|
|
214
|
+
flushActivityLogSync();
|
|
209
215
|
process.exit(signal === 'SIGHUP' ? 129 : 143);
|
|
210
216
|
};
|
|
211
217
|
const exitListener = (): void => { restoreTerminal(); };
|
|
@@ -285,6 +291,8 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
|
|
|
285
291
|
// stop claiming the session is live regardless of whether the durable
|
|
286
292
|
// save itself completed in time.
|
|
287
293
|
removeLivenessMarker(ctx.services.surface, ctx.runtime.sessionId);
|
|
294
|
+
// An orderly exit's own record lands before the process stops being one.
|
|
295
|
+
flushActivityLogSync();
|
|
288
296
|
process.exit(0);
|
|
289
297
|
};
|
|
290
298
|
|