@pellux/goodvibes-daemon 1.28.19 → 1.28.20
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 +85 -63
- package/README.md +13 -13
- package/bin/launcher-support.js +5 -5
- package/package.json +6 -4
- package/scripts/postinstall.js +8 -8
- package/src/cli/command-catalog.ts +22 -22
- package/src/cli/completion.ts +4 -4
- package/src/cli/help.ts +5 -5
- package/src/cli/index.ts +3 -3
- package/src/cli/parser.ts +2 -2
- package/src/cli/surface-catalog.ts +1 -1
- package/src/cli/types.ts +2 -2
- package/src/cluster/daemon-ws-call.ts +5 -5
- package/src/cluster/raw-reply-route.ts +5 -5
- package/src/config/checkpoint-settings.ts +7 -7
- package/src/config/config-key-guard.ts +22 -0
- package/src/config/run-daemon-config-migration.ts +3 -3
- package/src/config/secret-config.ts +7 -7
- package/src/config/surface.ts +3 -3
- package/src/core/pairing-banner.ts +5 -5
- package/src/daemon/cli.ts +45 -43
- package/src/daemon/config-command.ts +15 -15
- package/src/daemon/handlers/context.ts +1 -1
- package/src/daemon/handlers/contracts.ts +4 -4
- package/src/daemon/handlers/credentials.ts +1 -1
- package/src/daemon/handlers/drafts/draft-store.ts +3 -3
- package/src/daemon/handlers/drafts/register.ts +4 -4
- package/src/daemon/handlers/inbox/aggregator.ts +8 -8
- package/src/daemon/handlers/inbox/cursor-store.ts +10 -10
- package/src/daemon/handlers/inbox/index.ts +7 -7
- package/src/daemon/handlers/inbox/mapping.ts +2 -2
- package/src/daemon/handlers/inbox/poller.ts +5 -5
- package/src/daemon/handlers/inbox/provider-adapter.ts +8 -8
- package/src/daemon/handlers/inbox/providers/discord.ts +6 -6
- package/src/daemon/handlers/inbox/providers/email.ts +3 -3
- package/src/daemon/handlers/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/handlers/inbox/providers/slack.ts +4 -4
- package/src/daemon/handlers/index.ts +18 -8
- package/src/daemon/handlers/payments/card-store.ts +486 -0
- package/src/daemon/handlers/payments/index.ts +32 -0
- package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
- package/src/daemon/handlers/payments/register.ts +391 -0
- package/src/daemon/handlers/register.ts +3 -3
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
- package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
- package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
- package/src/daemon/handlers/remote/backends/types.ts +2 -2
- package/src/daemon/handlers/remote/dispatcher.ts +3 -3
- package/src/daemon/handlers/remote/index.ts +1 -1
- package/src/daemon/handlers/remote/peer-registry.ts +62 -13
- package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
- package/src/daemon/handlers/routing/index.ts +1 -1
- package/src/daemon/handlers/routing/route-store.ts +1 -1
- package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
- package/src/daemon/handlers/sqlite-store.ts +9 -9
- package/src/daemon/handlers/triage/index.ts +1 -1
- package/src/daemon/handlers/triage/integration.ts +3 -3
- package/src/daemon/handlers/triage/pipeline.ts +2 -2
- package/src/daemon/handlers/triage/scorer.ts +2 -2
- package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
- package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
- package/src/daemon/handlers/triage/tagger/index.ts +1 -1
- package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
- package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
- package/src/daemon/handlers/triage/types.ts +2 -2
- package/src/daemon/lifecycle.ts +5 -5
- package/src/daemon/local-daemon-state.ts +7 -7
- package/src/daemon/pair-command.ts +14 -14
- package/src/daemon/provision-wake-model.ts +5 -5
- package/src/daemon/send/channels.ts +7 -7
- package/src/daemon/send/command.ts +11 -11
- package/src/daemon/send/composition.ts +5 -5
- package/src/daemon/send/failure-text.ts +6 -6
- package/src/daemon/send/inert-text.ts +18 -18
- package/src/daemon/send/stdin.ts +3 -3
- package/src/daemon/service-commands.ts +32 -32
- package/src/daemon/sessions-command.ts +7 -7
- package/src/daemon/status-command.ts +22 -22
- package/src/daemon/webui-command.ts +14 -14
- package/src/runtime/boot-tasks.ts +1 -1
- package/src/runtime/cluster-composition.ts +9 -9
- package/src/runtime/cluster-group-composition.ts +7 -7
- package/src/runtime/conversation-rewind-port.ts +8 -8
- package/src/runtime/credential-composition.ts +2 -2
- package/src/runtime/daemon-handler-composition.ts +44 -4
- package/src/runtime/device-posture-composition.ts +10 -10
- package/src/runtime/disposal-wiring.ts +8 -8
- package/src/runtime/fleet-needs-input-push.ts +4 -4
- package/src/runtime/fleet-services.ts +1 -1
- package/src/runtime/hosted-session-composition.ts +13 -13
- package/src/runtime/index.ts +1 -1
- package/src/runtime/knowledge-services.ts +2 -2
- package/src/runtime/legacy-daemon-migration.ts +43 -43
- package/src/runtime/legacy-daemon-reconcile.ts +30 -30
- package/src/runtime/mail-composition.ts +6 -6
- package/src/runtime/notification-dispatch.ts +7 -7
- package/src/runtime/payments-composition.ts +143 -0
- package/src/runtime/plugin-composition.ts +7 -7
- package/src/runtime/runtime-services-types.ts +9 -9
- package/src/runtime/services.ts +29 -27
- package/src/runtime/trigger-services.ts +1 -1
- package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
- package/src/runtime/trust/trust-gated-approvals.ts +9 -9
- package/src/runtime/update-check.ts +4 -4
- package/src/runtime/workspace-checkpointing.ts +6 -6
- package/src/testing/daemon-fixture.ts +11 -11
- package/src/testing/hosted-session-failures.ts +4 -4
- package/src/version.ts +2 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* status-command.ts
|
|
2
|
+
* status-command.ts, `goodvibes-daemon status` and `goodvibes-daemon update`.
|
|
3
3
|
*
|
|
4
4
|
* The question a headless box's operator asks first: is it up, what version, on
|
|
5
5
|
* what address, is anything unhealthy, and what did it do to itself while I was
|
|
6
|
-
* not looking. Before this, the binary answered none of that
|
|
6
|
+
* not looking. Before this, the binary answered none of that, `status` fell
|
|
7
7
|
* through the parser and started a SECOND daemon in the foreground.
|
|
8
8
|
*
|
|
9
9
|
* WHERE EACH LINE COMES FROM
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* established: `/status` for identity, `/api/health` for the health roll-up and
|
|
14
14
|
* the address it actually bound, `/api/channels/status` for the channels, and
|
|
15
15
|
* `/api/cluster/status` for this machine's place in its group. Hosted sessions
|
|
16
|
-
* are a ws-only verb family, so they go through `callDaemonWsVerb
|
|
16
|
+
* are a ws-only verb family, so they go through `callDaemonWsVerb`, same
|
|
17
17
|
* target, same token, different transport.
|
|
18
18
|
*
|
|
19
|
-
* Everything about the daemon's own HISTORY
|
|
20
|
-
* the version an automatic rollback rejected
|
|
19
|
+
* Everything about the daemon's own HISTORY, uptime, the receipts it wrote,
|
|
20
|
+
* the version an automatic rollback rejected, comes from files on the daemon's
|
|
21
21
|
* host, because no verb reports them. That makes those lines local-only, and
|
|
22
22
|
* they say so for a remote target instead of being guessed at.
|
|
23
23
|
*
|
|
@@ -76,8 +76,8 @@ export interface RemoteCommandDeps {
|
|
|
76
76
|
*
|
|
77
77
|
* It carries a `cluster` block the operator contract does not list, and that
|
|
78
78
|
* block is where this daemon's ROLE in its group comes from. `cluster.uptimeMs`
|
|
79
|
-
* is the coordinator's, not the daemon's
|
|
80
|
-
* up for hours
|
|
79
|
+
* is the coordinator's, not the daemon's, it reads 0 on a daemon that has been
|
|
80
|
+
* up for hours, so the uptime line comes from the lifecycle marker instead.
|
|
81
81
|
*/
|
|
82
82
|
interface ControlStatusPayload {
|
|
83
83
|
readonly status?: string;
|
|
@@ -179,7 +179,7 @@ function optionalLine(label: string, value: string | undefined): string[] {
|
|
|
179
179
|
*
|
|
180
180
|
* `/status` reports `version` from the platform package while the cluster block
|
|
181
181
|
* it carries reports the DAEMON build's version, and against a live daemon
|
|
182
|
-
* those disagreed
|
|
182
|
+
* those disagreed, 1.21.0 against 1.28.0. Printing one of them silently would
|
|
183
183
|
* put a number on this page that is wrong for whichever question the reader had
|
|
184
184
|
* in mind, so both are printed and labelled until the daemon states one.
|
|
185
185
|
*/
|
|
@@ -210,7 +210,7 @@ function localStateLines(state: LocalDaemonState): string[] {
|
|
|
210
210
|
}
|
|
211
211
|
if (state.marker?.rejectedVersion !== undefined) {
|
|
212
212
|
lines.push(
|
|
213
|
-
` rejected: ${state.marker.rejectedVersion} crash looped and was rolled back
|
|
213
|
+
` rejected: ${state.marker.rejectedVersion} crash looped and was rolled back; `
|
|
214
214
|
+ 'the update loop will not install that version again',
|
|
215
215
|
);
|
|
216
216
|
}
|
|
@@ -229,7 +229,7 @@ function localStateLines(state: LocalDaemonState): string[] {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
function healthLines(outcome: DaemonVerbOutcome<HealthPayload>): string[] {
|
|
232
|
-
if (!outcome.ok) return [` health: could not read
|
|
232
|
+
if (!outcome.ok) return [` health: could not read: ${outcome.error}`];
|
|
233
233
|
const health = outcome.data;
|
|
234
234
|
const lines = [` health: ${health.overall ?? 'unknown'}`];
|
|
235
235
|
const binding = health.network?.controlPlane;
|
|
@@ -250,12 +250,12 @@ function healthLines(outcome: DaemonVerbOutcome<HealthPayload>): string[] {
|
|
|
250
250
|
*
|
|
251
251
|
* Only a channel that is switched ON and not healthy is named. Every channel
|
|
252
252
|
* the daemon knows about appears in this payload, and a daemon with one
|
|
253
|
-
* configured channel ships sixteen more in state `disabled
|
|
253
|
+
* configured channel ships sixteen more in state `disabled`, listing those as
|
|
254
254
|
* problems produced a seventeen-line wall under a healthy daemon and buried the
|
|
255
255
|
* one line that meant something.
|
|
256
256
|
*/
|
|
257
257
|
function channelLines(outcome: DaemonVerbOutcome<ChannelsPayload>): string[] {
|
|
258
|
-
if (!outcome.ok) return [` channels: could not read
|
|
258
|
+
if (!outcome.ok) return [` channels: could not read: ${outcome.error}`];
|
|
259
259
|
const channels = outcome.data.channels ?? [];
|
|
260
260
|
if (channels.length === 0) return [' channels: none configured'];
|
|
261
261
|
const on = channels.filter((channel) => channel.enabled !== false);
|
|
@@ -276,7 +276,7 @@ function channelLines(outcome: DaemonVerbOutcome<ChannelsPayload>): string[] {
|
|
|
276
276
|
* Two sources, because neither answers the whole question: `/status` carries
|
|
277
277
|
* the ROLE this node currently holds, and `/api/cluster/status` carries the
|
|
278
278
|
* GROUP it holds that role in. A daemon with sharing switched off says so and
|
|
279
|
-
* stops
|
|
279
|
+
* stops, a role inside no group is not information.
|
|
280
280
|
*/
|
|
281
281
|
function clusterLines(
|
|
282
282
|
identity: ControlStatusPayload,
|
|
@@ -284,18 +284,18 @@ function clusterLines(
|
|
|
284
284
|
): string[] {
|
|
285
285
|
const role = identity.cluster?.role;
|
|
286
286
|
if (identity.cluster?.enabled === false) {
|
|
287
|
-
return [' cluster: off
|
|
287
|
+
return [' cluster: off, this machine handles its own inbound work'];
|
|
288
288
|
}
|
|
289
289
|
if (!outcome.ok) {
|
|
290
290
|
return [
|
|
291
291
|
role === undefined
|
|
292
|
-
? ` cluster: could not read
|
|
293
|
-
: ` cluster: ${role} (the group view could not be read
|
|
292
|
+
? ` cluster: could not read: ${outcome.error}`
|
|
293
|
+
: ` cluster: ${role} (the group view could not be read: ${outcome.error})`,
|
|
294
294
|
];
|
|
295
295
|
}
|
|
296
296
|
const cluster = outcome.data;
|
|
297
297
|
if (cluster.membership === 'no-group') {
|
|
298
|
-
return [' cluster: in no group yet
|
|
298
|
+
return [' cluster: in no group yet, `goodvibes-daemon cluster create` starts one'];
|
|
299
299
|
}
|
|
300
300
|
const group = cluster.groupName ?? cluster.groupId ?? 'its group';
|
|
301
301
|
const members = cluster.memberCount === undefined ? '' : ` of ${cluster.memberCount}`;
|
|
@@ -303,7 +303,7 @@ function clusterLines(
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
function hostedSessionLines(outcome: DaemonVerbOutcome<HostedSessionsPayload>): string[] {
|
|
306
|
-
if (!outcome.ok) return [` sessions: could not read
|
|
306
|
+
if (!outcome.ok) return [` sessions: could not read: ${outcome.error}`];
|
|
307
307
|
const sessions = outcome.data.sessions ?? [];
|
|
308
308
|
return [` sessions: ${sessions.length} hosted by this daemon`];
|
|
309
309
|
}
|
|
@@ -317,7 +317,7 @@ export interface RunStatusCommandInput extends RemoteCommandDeps {
|
|
|
317
317
|
*
|
|
318
318
|
* Exit 0 when the daemon answered its identity call, 1 when it could not be
|
|
319
319
|
* reached. Every other sub-question that fails is one line inside a successful
|
|
320
|
-
* report
|
|
320
|
+
* report, a daemon with a broken channel is up, and saying otherwise would be
|
|
321
321
|
* the kind of wrong that makes an operator distrust the whole page.
|
|
322
322
|
*/
|
|
323
323
|
export async function runStatusCommand(input: RunStatusCommandInput): Promise<DaemonCommandResult> {
|
|
@@ -407,8 +407,8 @@ export interface RunUpdateCommandInput extends RemoteCommandDeps {
|
|
|
407
407
|
* rejected, and whether a rollback is in force.
|
|
408
408
|
*
|
|
409
409
|
* --check is honest about a gap. The daemon runs the whole self-update loop
|
|
410
|
-
* itself
|
|
411
|
-
* binary
|
|
410
|
+
* itself, it checks hourly, swaps at an idle moment and keeps the outgoing
|
|
411
|
+
* binary, but the control plane publishes NO verb to trigger that check early:
|
|
412
412
|
* the operator contract this build was written against has no update method of
|
|
413
413
|
* any kind (no `update.*`, no `admin.update`, nothing under `control.` that
|
|
414
414
|
* checks). Rather than invent a verb this daemon does not answer, --check says
|
|
@@ -439,7 +439,7 @@ export async function runUpdateCommand(input: RunUpdateCommandInput): Promise<Da
|
|
|
439
439
|
'',
|
|
440
440
|
'update --check: this daemon publishes no verb to trigger an update check early.',
|
|
441
441
|
' It checks once an hour on its own and swaps only at an idle moment.',
|
|
442
|
-
' To make it check now, restart it
|
|
442
|
+
' To make it check now, restart it; it checks on the way up:',
|
|
443
443
|
' goodvibes-daemon restart-service',
|
|
444
444
|
]
|
|
445
445
|
: [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// webui-command.ts
|
|
2
|
+
// webui-command.ts, `goodvibes-daemon webui enable|disable|status`.
|
|
3
3
|
//
|
|
4
4
|
// WHAT THIS IS FOR
|
|
5
5
|
//
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// serve such a directory: with `controlPlane.webui.serve` on, its own HTTP
|
|
9
9
|
// router answers `/` from `controlPlane.webui.bundleDir` and falls back to
|
|
10
10
|
// index.html for app routes. So installing the web UI is two config writes and
|
|
11
|
-
// a directory on disk
|
|
11
|
+
// a directory on disk, and this is the command that makes those writes, so the
|
|
12
12
|
// curl installer does not have to know the key names, the file format, or which
|
|
13
13
|
// of the three settings tiers a daemon-owned key belongs in. Same reason
|
|
14
14
|
// `provision-wake-model` exists: the installer runs the binary it just placed
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
// WHERE IT IS SERVED, AND WHY THE URL SAYS WHAT IT SAYS
|
|
18
18
|
//
|
|
19
19
|
// The bundle is served BY THE CONTROL-PLANE LISTENER, same origin as the API
|
|
20
|
-
// (that is the whole point
|
|
20
|
+
// (that is the whole point, a same-origin bundle makes the browser's
|
|
21
21
|
// same-origin policy a non-issue and needs no CORS allowlist). So the URL that
|
|
22
22
|
// opens the web UI is the control-plane origin: `http://<host>:<controlPlane.port>`,
|
|
23
23
|
// not `web.port`. `web.port` is the surface's DECLARED endpoint, used for links
|
|
24
|
-
// and for `tailscale serve`; nothing binds it. This command therefore reports
|
|
25
|
-
// and, when it is still sitting on the shipped placeholder, writes
|
|
24
|
+
// and for `tailscale serve`; nothing binds it. This command therefore reports,
|
|
25
|
+
// and, when it is still sitting on the shipped placeholder, writes,
|
|
26
26
|
// `web.publicBaseUrl` as the origin that actually answers, so the printed URL,
|
|
27
27
|
// the pairing deep link and the running server all agree.
|
|
28
28
|
//
|
|
@@ -41,7 +41,7 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
|
41
41
|
import { resolveRuntimeEndpointBinding } from '@pellux/goodvibes-terminal-shell';
|
|
42
42
|
import { probeStableHostInputs, stableUrlHostForBindHost, type StableHostInputs } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
43
43
|
|
|
44
|
-
/** The shipped `web.publicBaseUrl` placeholder
|
|
44
|
+
/** The shipped `web.publicBaseUrl` placeholder, a port nothing binds. */
|
|
45
45
|
const SHIPPED_PUBLIC_BASE_URL = 'http://127.0.0.1:3423';
|
|
46
46
|
|
|
47
47
|
export interface WebuiCommandResult {
|
|
@@ -175,13 +175,13 @@ function postureLines(origin: string, loopback: boolean): string[] {
|
|
|
175
175
|
];
|
|
176
176
|
}
|
|
177
177
|
return [
|
|
178
|
-
` reachable from your network at ${origin}
|
|
178
|
+
` reachable from your network at ${origin}; the control-plane listener is bound to all interfaces.`,
|
|
179
179
|
' To take it back to this machine only: goodvibes-daemon webui enable --loopback',
|
|
180
180
|
];
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* Run the command and report it. Never throws for an ordinary refusal
|
|
184
|
+
* Run the command and report it. Never throws for an ordinary refusal, a bad
|
|
185
185
|
* argument, a missing bundle, an unwritable settings file all come back as an
|
|
186
186
|
* exit code and lines, because the caller is often an installer reading both.
|
|
187
187
|
*/
|
|
@@ -212,14 +212,14 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
|
|
|
212
212
|
try {
|
|
213
213
|
config.set('controlPlane.webui.serve', false);
|
|
214
214
|
} catch (error) {
|
|
215
|
-
return { exitCode: 1, lines: [`webui: could not write settings
|
|
215
|
+
return { exitCode: 1, lines: [`webui: could not write settings: ${message(error)}`] };
|
|
216
216
|
}
|
|
217
217
|
const kept = readString(config, 'controlPlane.webui.bundleDir');
|
|
218
218
|
return {
|
|
219
219
|
exitCode: 0,
|
|
220
220
|
lines: [
|
|
221
221
|
'web UI: no longer served by the daemon.',
|
|
222
|
-
...(kept ? [` the bundle is left on disk at ${kept}
|
|
222
|
+
...(kept ? [` the bundle is left on disk at ${kept}; 'goodvibes-daemon webui enable' serves it again`] : []),
|
|
223
223
|
' Restart the daemon for this to take effect on a running process.',
|
|
224
224
|
],
|
|
225
225
|
};
|
|
@@ -242,7 +242,7 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
|
|
|
242
242
|
return {
|
|
243
243
|
exitCode: 1,
|
|
244
244
|
lines: [
|
|
245
|
-
`webui: refusing to serve ${bundleDir}
|
|
245
|
+
`webui: refusing to serve ${bundleDir}: ${problem}.`,
|
|
246
246
|
' Nothing was changed.',
|
|
247
247
|
],
|
|
248
248
|
};
|
|
@@ -260,7 +260,7 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
|
|
|
260
260
|
config.set('web.hostMode', 'local');
|
|
261
261
|
}
|
|
262
262
|
} catch (error) {
|
|
263
|
-
return { exitCode: 1, lines: [`webui: could not write settings
|
|
263
|
+
return { exitCode: 1, lines: [`webui: could not write settings: ${message(error)}`] };
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
const serving = servingOrigin(config, probe);
|
|
@@ -284,7 +284,7 @@ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps)
|
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
if (!serving.recognized) {
|
|
287
|
-
lines.push(' note: controlPlane.hostMode is not one of local|network|custom
|
|
287
|
+
lines.push(' note: controlPlane.hostMode is not one of local|network|custom; the daemon cannot bind until that is corrected.');
|
|
288
288
|
}
|
|
289
289
|
lines.push(...postureLines(serving.origin, serving.loopback));
|
|
290
290
|
lines.push(' Restart the daemon for this to take effect on a running process.');
|
|
@@ -324,7 +324,7 @@ function renderStatus(
|
|
|
324
324
|
} else {
|
|
325
325
|
const absoluteDir = deps.absolute(directory);
|
|
326
326
|
const problem = describeBundleProblem(absoluteDir, deps.directoryExists, deps.fileExists);
|
|
327
|
-
lines.push(problem ? ` bundle (${source}): ${absoluteDir}
|
|
327
|
+
lines.push(problem ? ` bundle (${source}): ${absoluteDir} (UNUSABLE: ${problem})` : ` bundle (${source}): ${absoluteDir}`);
|
|
328
328
|
}
|
|
329
329
|
const currentPublic = readString(config, 'web.publicBaseUrl');
|
|
330
330
|
if (currentPublic && currentPublic !== serving.origin) {
|
|
@@ -98,7 +98,7 @@ export async function runDaemonBootTasks(services: RuntimeServices): Promise<voi
|
|
|
98
98
|
|
|
99
99
|
// Load the plugins this host can serve. The manager was constructed by the
|
|
100
100
|
// graph and never initialised, so it could list a plugin directory and never
|
|
101
|
-
// load anything out of it
|
|
101
|
+
// load anything out of it, `enable` persisted a flag that turned nothing on.
|
|
102
102
|
// Both hosts read the same directories; each takes the registrations it can
|
|
103
103
|
// serve (plugin-composition.ts). Best-effort like everything else here: a
|
|
104
104
|
// plugin that will not load is not a reason for the daemon not to start.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cluster-composition.ts
|
|
2
|
+
* cluster-composition.ts, this daemon's seat in the LAN leader election.
|
|
3
3
|
*
|
|
4
|
-
* When the same goodvibes install runs more than once on one network
|
|
5
|
-
* laptop and a desktop, or two processes on one machine
|
|
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
6
|
* independently reads the shared inbox, so one message is picked up twice and
|
|
7
7
|
* answered twice. The SDK's cluster coordinator elects exactly one node to be
|
|
8
8
|
* responsible for inbound consumption; everything else stays warm and silent.
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* This file exists because this daemon does NOT get that for free from the
|
|
11
11
|
* SDK facade. The facade gates the consumers IT owns (Telegram
|
|
12
12
|
* ingress, the ntfy/Slack/Discord provider runtime), but this repository
|
|
13
|
-
* composes an inbound consumer of its own
|
|
14
|
-
* poller in daemon/handlers/inbox
|
|
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
15
|
* A fix that only landed in the facade would leave the poller double-reading
|
|
16
16
|
* on exactly the machine this product runs on.
|
|
17
17
|
*
|
|
@@ -51,7 +51,7 @@ export interface GatedPollerControl {
|
|
|
51
51
|
* Constructing it is inert: no socket is opened and no state is written until
|
|
52
52
|
* `start()` runs, so composing a runtime in a test never joins a network.
|
|
53
53
|
*
|
|
54
|
-
* The version is THIS BINARY's version, not the SDK package's
|
|
54
|
+
* The version is THIS BINARY's version, not the SDK package's, the same
|
|
55
55
|
* distinction daemon/cli.ts already makes for the self-update artifact. It is
|
|
56
56
|
* the first ranking tier, so getting it wrong would let a stale build hold the
|
|
57
57
|
* role through an update.
|
|
@@ -62,7 +62,7 @@ export function createClusterComposition(options: {
|
|
|
62
62
|
* `readClusterSettings` asks for. The daemon passes a whole ConfigManager;
|
|
63
63
|
* the handler context carries only this slice, and a test standing in a
|
|
64
64
|
* minimal config object should not have to fabricate the full fifty-odd
|
|
65
|
-
* members to compose a coordinator
|
|
65
|
+
* members to compose a coordinator, a cast there would hide a real shape
|
|
66
66
|
* mismatch rather than document one.
|
|
67
67
|
*/
|
|
68
68
|
readonly configManager: Pick<ConfigManager, 'getCategory'>;
|
|
@@ -75,7 +75,7 @@ export function createClusterComposition(options: {
|
|
|
75
75
|
* the current group key. That is what stops a daemon belonging to somebody
|
|
76
76
|
* else on the same network from taking part in this election at all.
|
|
77
77
|
*
|
|
78
|
-
* Absent
|
|
78
|
+
* Absent, the plain default, means the coordinator opens its own socket and
|
|
79
79
|
* coordinates with anything that answers on the configured port.
|
|
80
80
|
*/
|
|
81
81
|
readonly transport?: ClusterTransport | undefined;
|
|
@@ -96,7 +96,7 @@ export function createClusterComposition(options: {
|
|
|
96
96
|
*
|
|
97
97
|
* One gate per account, not one for the poller. Each inbox account is its own
|
|
98
98
|
* surface in the election, so this laptop can hold the work Slack account
|
|
99
|
-
* while the desktop holds the mailbox
|
|
99
|
+
* while the desktop holds the mailbox, and losing one machine moves only the
|
|
100
100
|
* accounts it was reading. A single gate covering the whole poller could not
|
|
101
101
|
* express that: it would hand every account over together, and a node with a
|
|
102
102
|
* credential for only one of them could never take part at all.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cluster-group-composition.ts
|
|
2
|
+
* cluster-group-composition.ts, this machine's membership of a LAN group.
|
|
3
3
|
*
|
|
4
4
|
* Leader election answers "which of us reads the inbox". This answers the
|
|
5
5
|
* question underneath it: "which machines are US". Without a group, any daemon
|
|
6
|
-
* that happened to be on the same network
|
|
7
|
-
* container someone left running
|
|
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
8
|
* you would silently stop receiving messages.
|
|
9
9
|
*
|
|
10
10
|
* The group layer owns the socket and the election rides on it: every
|
|
@@ -77,7 +77,7 @@ export function createClusterGroupComposition(options: {
|
|
|
77
77
|
* The leader election's own answer to "am I the master".
|
|
78
78
|
*
|
|
79
79
|
* Config replication needs exactly one machine issuing revisions, and it must
|
|
80
|
-
* be the SAME machine leadership already picked
|
|
80
|
+
* be the SAME machine leadership already picked, two notions of master in
|
|
81
81
|
* one process would disagree the moment one of them changed.
|
|
82
82
|
*
|
|
83
83
|
* Late-bound because the coordinator is built from this composition's
|
|
@@ -91,7 +91,7 @@ export function createClusterGroupComposition(options: {
|
|
|
91
91
|
* in `cluster status` and in /status. Late-bound for the same reason
|
|
92
92
|
* `isMaster` is. Absent means the group layer reports that the information is
|
|
93
93
|
* unavailable rather than reporting an empty list as though this machine held
|
|
94
|
-
* nothing
|
|
94
|
+
* nothing, a distinction an operator diagnosing a silent inbox depends on.
|
|
95
95
|
*/
|
|
96
96
|
readonly surfaceHoldings?: (() => readonly ClusterSurfaceHolding[]) | undefined;
|
|
97
97
|
}): ClusterGroupComposition {
|
|
@@ -181,7 +181,7 @@ export function createClusterGroupComposition(options: {
|
|
|
181
181
|
* through a closure because the coordinator does not exist yet when the group
|
|
182
182
|
* layer is constructed.
|
|
183
183
|
*
|
|
184
|
-
* Constructing either is inert
|
|
184
|
+
* Constructing either is inert, no socket, no key material read, until
|
|
185
185
|
* `startCluster` runs.
|
|
186
186
|
*/
|
|
187
187
|
export function createClusterServices(options: {
|
|
@@ -228,7 +228,7 @@ export async function startClusterServices(services: {
|
|
|
228
228
|
* Ask the group to take this machine back, on start.
|
|
229
229
|
*
|
|
230
230
|
* This is the zero-touch return. A machine that has been switched off for
|
|
231
|
-
* months
|
|
231
|
+
* months, through many group-key rotations and possibly a join-key change,
|
|
232
232
|
* comes up holding stale keys, says "it is still me" with a key that never
|
|
233
233
|
* rotates, and is re-keyed to the current generation by whichever member
|
|
234
234
|
* answers. The operator does nothing.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// conversation-rewind-port.ts
|
|
2
|
+
// conversation-rewind-port.ts, the daemon's RewindConversationPort for the
|
|
3
3
|
// SDK's unified rewind service.
|
|
4
4
|
//
|
|
5
5
|
// The SDK's UnifiedRewindService (platform/rewind) joins files rewind (workspace
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// truncation and captures the pre-/post-truncation snapshots so the reversal can
|
|
10
10
|
// be undone and re-applied. The truncation boundary is the message count recorded
|
|
11
11
|
// for the anchor's turnId at TURN_COMPLETED (the SDK's platform/rewind turn
|
|
12
|
-
// anchors)
|
|
12
|
+
// anchors), the same
|
|
13
13
|
// join key files rewind uses against the workspace checkpoint.
|
|
14
14
|
//
|
|
15
15
|
// This module is the daemon's implementation of that port, for sessions THIS
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// anchor.sessionId from the registry below.
|
|
18
18
|
//
|
|
19
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"
|
|
20
|
+
// it used to answer an empty resolution with "0 messages to drop", the same
|
|
21
21
|
// answer a conversation already at the anchor gives, which is a confident wrong
|
|
22
22
|
// answer rather than a missing one. It now reports the anchor as UNAVAILABLE
|
|
23
23
|
// with the reason, which the SDK's rewind service turns into a plan warning.
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// The surfaces reach conversation rewind a different way now: they offer their
|
|
26
26
|
// live conversation over the control plane (rewind.conversation.*), and the
|
|
27
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
|
|
28
|
+
// falls through to for sessions no surface has offered, which is exactly the
|
|
29
29
|
// case where the daemon hosts the conversation itself. Files-scope rewind,
|
|
30
30
|
// entirely daemon-owned, was never affected either way.
|
|
31
31
|
//
|
|
@@ -65,7 +65,7 @@ export interface ConversationRewindPort extends RewindConversationPort {
|
|
|
65
65
|
restoreAfter(undoSnapshotId: string): boolean;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** One truncation's captured state
|
|
68
|
+
/** One truncation's captured state, the target conversation and its snapshots. */
|
|
69
69
|
interface SnapshotPair {
|
|
70
70
|
readonly conv: RewindableConversation;
|
|
71
71
|
readonly before: ConversationJson;
|
|
@@ -80,7 +80,7 @@ const NO_LIVE_CONVERSATION =
|
|
|
80
80
|
* Build a conversation rewind port. `resolveConversation` maps an anchor's
|
|
81
81
|
* sessionId to the live conversation: the daemon looks the session up in the
|
|
82
82
|
* registry below. A null resolution is reported as unavailable with the reason
|
|
83
|
-
|
|
83
|
+
*, never as a count, because "nobody here is holding those messages" and
|
|
84
84
|
* "there are no messages to drop" are different facts and only one of them is
|
|
85
85
|
* true.
|
|
86
86
|
*/
|
|
@@ -139,7 +139,7 @@ export function createConversationRewindPort(
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// ---------------------------------------------------------------------------
|
|
142
|
-
// Live per-session conversation registry
|
|
142
|
+
// Live per-session conversation registry, the daemon-hosted mutable store the
|
|
143
143
|
// composed daemon's rewind.plan/apply verbs fall back to. A process INSIDE this
|
|
144
144
|
// daemon that runs a conversation registers it here; a surface in another
|
|
145
145
|
// process offers its conversation over the control plane instead
|
|
@@ -162,7 +162,7 @@ export function unregisterSessionConversation(sessionId: string): void {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* The conversation rewind port the composed daemon threads into
|
|
165
|
-
* registerGatewayVerbGroups
|
|
165
|
+
* registerGatewayVerbGroups, it resolves each anchor's live conversation from
|
|
166
166
|
* the registry above, so the daemon's own rewind verbs serve conversation scope
|
|
167
167
|
* live in this process.
|
|
168
168
|
*/
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* These are grouped because they share one fact, and it is the fact this round
|
|
8
8
|
* had to fix: WHERE the daemon's own credentials live. `daemonHome` is threaded
|
|
9
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
|
|
10
|
+
* tree moved its identity directory and nothing else, the credential store
|
|
11
11
|
* stayed in the real home, so an "isolated" test daemon held live credentials
|
|
12
12
|
* and long-polled a real account. A default here would silently restore that.
|
|
13
13
|
*
|
|
@@ -25,7 +25,7 @@ interface CredentialCompositionInput {
|
|
|
25
25
|
readonly homeDirectory: string;
|
|
26
26
|
/**
|
|
27
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
|
|
28
|
+
* overridden" and must stay absent rather than being defaulted, see above.
|
|
29
29
|
*/
|
|
30
30
|
readonly daemonHomeDirectory?: string | undefined;
|
|
31
31
|
readonly configManager: ConstructorParameters<typeof SecretsManager>[0]['configManager'];
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* daemon-handler-composition.ts
|
|
2
|
+
* daemon-handler-composition.ts, the daemon's HOST-side handler surfaces.
|
|
3
3
|
*
|
|
4
4
|
* Attaches this repository's handlers to the SDK-auto-registered builtin
|
|
5
5
|
* gateway descriptors (channels.* / email.* / calendar.*) via
|
|
6
|
-
* catalog.register(descriptor, handler, { replace: true })
|
|
6
|
+
* catalog.register(descriptor, handler, { replace: true }), the SDK owns
|
|
7
7
|
* every id, descriptor and schema; only the behaviour is ours. The remote
|
|
8
8
|
* surface reuses the SAME DistributedRuntimeManager the SDK facade injects.
|
|
9
9
|
*
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* The one behavioural decision that lives here: the inbox poller is handed to
|
|
14
14
|
* the cluster coordinator instead of being started eagerly. It is this
|
|
15
|
-
* product's own inbound consumer
|
|
15
|
+
* product's own inbound consumer, the SDK facade does not know it exists,
|
|
16
16
|
* so if it is not gated here it is not gated anywhere, and two goodvibes nodes
|
|
17
17
|
* on one network each read the shared inbox and answer the same message twice.
|
|
18
18
|
*/
|
|
@@ -28,7 +28,9 @@ import { registerInboxMethods } from '../daemon/handlers/inbox/index.ts';
|
|
|
28
28
|
import { registerTriagedInbox } from '../daemon/handlers/triage/index.ts';
|
|
29
29
|
import { registerDraftMethods } from '../daemon/handlers/drafts/index.ts';
|
|
30
30
|
import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
|
|
31
|
+
import { createPaymentsServices } from './payments-composition.ts';
|
|
31
32
|
import { inboxPollerGate } from './cluster-composition.ts';
|
|
33
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
32
34
|
|
|
33
35
|
export interface DaemonHandlerCompositionOptions {
|
|
34
36
|
readonly gatewayMethods: GatewayMethodCatalog;
|
|
@@ -36,6 +38,8 @@ export interface DaemonHandlerCompositionOptions {
|
|
|
36
38
|
readonly configManager: ConfigManager;
|
|
37
39
|
readonly workingDirectory: string;
|
|
38
40
|
readonly homeDirectory: string;
|
|
41
|
+
/** Resolves the surface-scoped control-plane paths the payment stores live at. */
|
|
42
|
+
readonly shellPaths: ShellPathService;
|
|
39
43
|
readonly distributedRuntime: NonNullable<Parameters<typeof registerRemoteSurface>[1]>['manager'];
|
|
40
44
|
/**
|
|
41
45
|
* Decides whether THIS node polls the shared inbox. Always supplied by the
|
|
@@ -65,12 +69,48 @@ export function createDaemonHandlerComposition(
|
|
|
65
69
|
registerInbox: (ctx, routing) =>
|
|
66
70
|
registerTriagedInbox(ctx, (inboxCtx) => registerInboxMethods(inboxCtx, routing, {
|
|
67
71
|
// Hands polling to leadership. The `channels.inbox.list` read stays
|
|
68
|
-
// available on every node
|
|
72
|
+
// available on every node, a standby still SERVES the persisted feed,
|
|
69
73
|
// it just does not FETCH into it.
|
|
70
74
|
gatePolling: (providerId, control) =>
|
|
71
75
|
options.clusterCoordinator.register(inboxPollerGate(providerId, control)),
|
|
72
76
|
})).unregister,
|
|
73
77
|
registerDrafts: (ctx) => registerDraftMethods(ctx),
|
|
78
|
+
// The payment stores need a path resolver and a daemon-scoped secret writer,
|
|
79
|
+
// neither of which is on HandlerContext, so they are built here and the
|
|
80
|
+
// provider only carries the teardown. See payments-composition.ts for which
|
|
81
|
+
// verbs this attaches and which two it deliberately leaves refusing.
|
|
82
|
+
registerPayments: () => createPaymentsServices({
|
|
83
|
+
gatewayMethods: options.gatewayMethods,
|
|
84
|
+
configManager: options.configManager,
|
|
85
|
+
secretsManager: options.secretsManager,
|
|
86
|
+
shellPaths: options.shellPaths,
|
|
87
|
+
// True only on a machine that is not clustered at all, and false on every
|
|
88
|
+
// node of a cluster.
|
|
89
|
+
//
|
|
90
|
+
// `isMaster` was the obvious answer and it is the wrong one: it means
|
|
91
|
+
// "this node holds at least one inbound surface", which two nodes sharing
|
|
92
|
+
// a mailbox and a Slack workspace both satisfy, so a two-node cluster
|
|
93
|
+
// would have answered true twice. The SDK's gates.ts is explicit that
|
|
94
|
+
// exactly one node may act and that the wrong answer here is a
|
|
95
|
+
// double-spend.
|
|
96
|
+
//
|
|
97
|
+
// The alternative was to register a payments surface with the coordinator
|
|
98
|
+
// and read holdsSurface(), which is this repo's per-surface idiom
|
|
99
|
+
// (inboxPollerGate). It is the right answer once there is something to
|
|
100
|
+
// elect over, and today there is not: ClusterConsumerGate.start() is
|
|
101
|
+
// specified to not resolve until consumption has actually begun, and this
|
|
102
|
+
// composition has no payments consumer to start, checkout is unattached.
|
|
103
|
+
// Registering a gate whose start() does nothing would put a fake consumer
|
|
104
|
+
// in the election and in `cluster status`.
|
|
105
|
+
//
|
|
106
|
+
// So the honest reading of the topology: clustering off means this is the
|
|
107
|
+
// only node, and it is trivially the one that would spend; clustering on
|
|
108
|
+
// means no payments election has been held and this node cannot claim to
|
|
109
|
+
// have won it. False on every node is also the safe direction, checkPaymentGates
|
|
110
|
+
// refuses on false. Wiring payments.checkout.* is what should replace this
|
|
111
|
+
// with a real gate and holdsSurface().
|
|
112
|
+
isPaymentsLeader: () => !options.clusterCoordinator.enabled,
|
|
113
|
+
}).unregister,
|
|
74
114
|
registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
|
|
75
115
|
});
|
|
76
116
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* device-posture-composition.ts
|
|
2
|
+
* device-posture-composition.ts, the paired-phone feature inside THIS daemon.
|
|
3
3
|
*
|
|
4
4
|
* A phone pairs with whichever daemon the person runs. Until this module
|
|
5
5
|
* existed this daemon had no device posture at all: `device.nodes.maxPaired`
|
|
6
6
|
* was enforced at the pairing path (SDK-side) and `device.capabilities.mode`
|
|
7
7
|
* was an onboarding toggle, while the other eleven `device.*` keys were
|
|
8
|
-
* recorded, read back, and governed nothing
|
|
8
|
+
* recorded, read back, and governed nothing, the capability service they
|
|
9
9
|
* describe was never built here, so there was nothing for them to govern.
|
|
10
10
|
*
|
|
11
11
|
* The feature itself is platform-owned (`platform/devices`): the settings→policy
|
|
@@ -13,16 +13,16 @@
|
|
|
13
13
|
* confirmation flow, and the `phone` tool. This module supplies the three seams
|
|
14
14
|
* that are actually ours and nothing else:
|
|
15
15
|
*
|
|
16
|
-
* - the peer transport
|
|
16
|
+
* - the peer transport, the same DistributedRuntimeManager the remote surface
|
|
17
17
|
* pairs devices onto, so a phone paired here is a node here,
|
|
18
|
-
* - the approval path
|
|
18
|
+
* - the approval path, the shared approval broker, so the confirmation
|
|
19
19
|
* appears wherever the person is looking (terminal, web app, companion),
|
|
20
20
|
* - the storage root and the live config manager.
|
|
21
21
|
*
|
|
22
22
|
* Constructing this starts nothing. `startHousekeeping()` is called from the
|
|
23
23
|
* bootstrap tail: grants and captures both outlive a restart, so a grant whose
|
|
24
24
|
* phone is gone, or a capture torn by a crash, is reaped BEFORE the first
|
|
25
|
-
* request of this run is served
|
|
25
|
+
* request of this run is served, and the periodic sweep after it is what keeps
|
|
26
26
|
* a long-running daemon from going days without one. A failed sweep is logged
|
|
27
27
|
* and the app still runs: housekeeping failing is a reason to say so, not a
|
|
28
28
|
* reason to refuse to start.
|
|
@@ -52,8 +52,8 @@ export interface DevicePostureCompositionOptions {
|
|
|
52
52
|
/**
|
|
53
53
|
* Binding the catalog turns the whole devices.* family from
|
|
54
54
|
* cataloged-but-unhandled into real handlers: nodes.list, grants.*,
|
|
55
|
-
* housekeeping.run, and
|
|
56
|
-
* could read the grants and never open a camera
|
|
55
|
+
* housekeeping.run, and, since a surface with no device runtime of its own
|
|
56
|
+
* could read the grants and never open a camera, capability.request and
|
|
57
57
|
* artifacts.list/read. That is what makes the paired phone usable from the web
|
|
58
58
|
* app and the companion rather than only through this process's own tool.
|
|
59
59
|
*
|
|
@@ -85,8 +85,8 @@ export function createDevicePostureServices(options: DevicePostureCompositionOpt
|
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
87
|
* Everything a host with a tool registry has to do once it is up: register the
|
|
88
|
-
* `phone` tool
|
|
89
|
-
* the posture keys govern nothing a session can observe
|
|
88
|
+
* `phone` tool, the only path that reaches the capability service, so without it
|
|
89
|
+
* the posture keys govern nothing a session can observe, and start housekeeping.
|
|
90
90
|
*
|
|
91
91
|
* One call, because these two belong to the same feature and a host that did the
|
|
92
92
|
* first and forgot the second would serve requests while never reaping a grant
|
|
@@ -103,7 +103,7 @@ export function installDevicePosture(
|
|
|
103
103
|
/**
|
|
104
104
|
* The recovery sweep plus the periodic timer. Separate from construction so
|
|
105
105
|
* composing a runtime in a test starts no timer and touches no disk, and
|
|
106
|
-
* separate from the call above so this daemon
|
|
106
|
+
* separate from the call above so this daemon, which registers no tools,
|
|
107
107
|
* still sweeps.
|
|
108
108
|
*/
|
|
109
109
|
export function startDeviceHousekeeping(devicePosture: DevicePostureRuntime): void {
|