@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. 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
+ }