@pellux/goodvibes-daemon 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions-command.ts — `goodvibes-daemon sessions list|kill <id>`.
|
|
3
|
+
*
|
|
4
|
+
* The sessions this daemon HOSTS: conversation loops running inside it, which
|
|
5
|
+
* outlive the client that started them. That is the whole reason they need a
|
|
6
|
+
* command — a terminal's own session dies with the terminal and never needs
|
|
7
|
+
* listing from outside, while a hosted one can be running on a headless box
|
|
8
|
+
* with nothing attached to it at all.
|
|
9
|
+
*
|
|
10
|
+
* The verbs are `sessions.hosted.list` and `sessions.hosted.kill`, and both are
|
|
11
|
+
* declared ws-only in the method catalog — they have no REST binding, so they
|
|
12
|
+
* go through `callDaemonWsVerb` rather than `callDaemonVerb`. Same target, same
|
|
13
|
+
* operator token, same --host/--port/--token convention; only the transport is
|
|
14
|
+
* different. A daemon built without hosted sessions answers "does not know the
|
|
15
|
+
* verb", which is reported as exactly that.
|
|
16
|
+
*
|
|
17
|
+
* This module decides nothing about what a session IS. It parses, calls, and
|
|
18
|
+
* renders.
|
|
19
|
+
*/
|
|
20
|
+
import { callDaemonWsVerb } from '../cluster/daemon-ws-call.ts';
|
|
21
|
+
import {
|
|
22
|
+
resolveTargetOrFailure,
|
|
23
|
+
type DaemonCommandResult,
|
|
24
|
+
type RemoteCommandDeps,
|
|
25
|
+
type RemoteCommandFlags,
|
|
26
|
+
} from './status-command.ts';
|
|
27
|
+
|
|
28
|
+
export const SESSIONS_SUBCOMMANDS = ['list', 'kill'] as const;
|
|
29
|
+
export type SessionsSubcommand = (typeof SESSIONS_SUBCOMMANDS)[number];
|
|
30
|
+
|
|
31
|
+
export function isSessionsSubcommand(value: string | undefined): value is SessionsSubcommand {
|
|
32
|
+
return typeof value === 'string' && (SESSIONS_SUBCOMMANDS as readonly string[]).includes(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Only the fields this command prints; the daemon's record carries more. */
|
|
36
|
+
export interface HostedSessionRecord {
|
|
37
|
+
readonly id?: string;
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
readonly workspaceRoot?: string;
|
|
40
|
+
readonly status?: string;
|
|
41
|
+
readonly detachPolicy?: string;
|
|
42
|
+
readonly effectiveDetachPolicy?: string;
|
|
43
|
+
readonly attachedClients?: number | readonly string[];
|
|
44
|
+
readonly updatedAt?: number;
|
|
45
|
+
readonly turnCount?: number;
|
|
46
|
+
readonly restoredFromDisk?: boolean;
|
|
47
|
+
readonly endedReason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface HostedListPayload {
|
|
51
|
+
readonly sessions?: readonly HostedSessionRecord[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface HostedKillPayload {
|
|
55
|
+
readonly session?: HostedSessionRecord;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SessionsCommandFlags extends RemoteCommandFlags {
|
|
59
|
+
/** `--all`: include sessions that have already ended. */
|
|
60
|
+
readonly all: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunSessionsCommandInput extends RemoteCommandDeps {
|
|
64
|
+
readonly flags: SessionsCommandFlags;
|
|
65
|
+
/** Positional words after `sessions` — the subcommand and its argument. */
|
|
66
|
+
readonly args: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function usage(binary = 'goodvibes-daemon'): string[] {
|
|
70
|
+
return [
|
|
71
|
+
` ${binary} sessions list [--all] [--json]`,
|
|
72
|
+
` ${binary} sessions kill <id> [--json]`,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function refusal(message: string, json: boolean): DaemonCommandResult {
|
|
77
|
+
return {
|
|
78
|
+
exitCode: 2,
|
|
79
|
+
lines: json
|
|
80
|
+
? [JSON.stringify({ ok: false, error: message, fix: usage().join(' | ') }, null, 2)]
|
|
81
|
+
: [`sessions: ${message}`, ...usage()],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function attachedCount(value: HostedSessionRecord['attachedClients']): number | undefined {
|
|
86
|
+
if (typeof value === 'number') return value;
|
|
87
|
+
if (Array.isArray(value)) return value.length;
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function describeAge(at: number | undefined, now: number): string {
|
|
92
|
+
if (at === undefined) return '';
|
|
93
|
+
const seconds = Math.max(0, Math.floor((now - at) / 1000));
|
|
94
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
95
|
+
if (seconds < 3_600) return `${Math.floor(seconds / 60)}m ago`;
|
|
96
|
+
if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h ago`;
|
|
97
|
+
return `${Math.floor(seconds / 86_400)}d ago`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderSession(session: HostedSessionRecord, now: number): string[] {
|
|
101
|
+
const attached = attachedCount(session.attachedClients);
|
|
102
|
+
const head = `${session.id ?? '(no id)'} ${session.status ?? 'unknown'}`;
|
|
103
|
+
const lines = [head];
|
|
104
|
+
if (session.title) lines.push(` ${session.title}`);
|
|
105
|
+
if (session.workspaceRoot) lines.push(` in ${session.workspaceRoot}`);
|
|
106
|
+
const facts: string[] = [];
|
|
107
|
+
if (session.turnCount !== undefined) facts.push(`${session.turnCount} turns`);
|
|
108
|
+
if (attached !== undefined) facts.push(attached === 1 ? '1 client attached' : `${attached} clients attached`);
|
|
109
|
+
if (session.effectiveDetachPolicy) facts.push(`on last detach: ${session.effectiveDetachPolicy}`);
|
|
110
|
+
if (session.updatedAt !== undefined) facts.push(describeAge(session.updatedAt, now));
|
|
111
|
+
if (session.restoredFromDisk === true) facts.push('restored from disk');
|
|
112
|
+
if (session.endedReason) facts.push(`ended: ${session.endedReason}`);
|
|
113
|
+
if (facts.length > 0) lines.push(` ${facts.join(' · ')}`);
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `sessions list` / `sessions kill <id>`.
|
|
119
|
+
*
|
|
120
|
+
* Exit 0 when the daemon answered, 1 when it refused or could not be reached,
|
|
121
|
+
* 2 when the command line was wrong. A `kill` with no id is a usage refusal
|
|
122
|
+
* rather than a "kill everything" — there is no shape of this command that ends
|
|
123
|
+
* more than the one session named.
|
|
124
|
+
*/
|
|
125
|
+
export async function runSessionsCommand(input: RunSessionsCommandInput): Promise<DaemonCommandResult> {
|
|
126
|
+
const { flags, args } = input;
|
|
127
|
+
const subcommand = args[0];
|
|
128
|
+
if (subcommand === undefined) {
|
|
129
|
+
return refusal('name what to do with the sessions.', flags.json);
|
|
130
|
+
}
|
|
131
|
+
if (!isSessionsSubcommand(subcommand)) {
|
|
132
|
+
return refusal(`'${subcommand}' is not a sessions command — try list or kill.`, flags.json);
|
|
133
|
+
}
|
|
134
|
+
const sessionId = args[1];
|
|
135
|
+
if (subcommand === 'kill' && sessionId === undefined) {
|
|
136
|
+
return refusal('kill needs the session to end — run `sessions list` to see them.', flags.json);
|
|
137
|
+
}
|
|
138
|
+
if (args.length > (subcommand === 'kill' ? 2 : 1)) {
|
|
139
|
+
return refusal(`'${args[subcommand === 'kill' ? 2 : 1]}' is one argument too many.`, flags.json);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolved = resolveTargetOrFailure(flags, input);
|
|
143
|
+
if (!resolved.ok) return resolved.result;
|
|
144
|
+
const target = resolved.target;
|
|
145
|
+
const socketOption = input.socketFactory ? { socketFactory: input.socketFactory } : {};
|
|
146
|
+
const now = input.now?.() ?? Date.now();
|
|
147
|
+
|
|
148
|
+
if (subcommand === 'kill') {
|
|
149
|
+
const outcome = await callDaemonWsVerb<HostedKillPayload>(target, 'sessions.hosted.kill', {
|
|
150
|
+
body: { sessionId },
|
|
151
|
+
...socketOption,
|
|
152
|
+
});
|
|
153
|
+
if (!outcome.ok) {
|
|
154
|
+
return {
|
|
155
|
+
exitCode: 1,
|
|
156
|
+
lines: flags.json
|
|
157
|
+
? [JSON.stringify({ ok: false, error: outcome.error, fix: outcome.fix }, null, 2)]
|
|
158
|
+
: [outcome.error, ` ${outcome.fix}`],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (flags.json) {
|
|
162
|
+
return { exitCode: 0, lines: [JSON.stringify({ ok: true, data: outcome.data }, null, 2)] };
|
|
163
|
+
}
|
|
164
|
+
const session = outcome.data.session;
|
|
165
|
+
return {
|
|
166
|
+
exitCode: 0,
|
|
167
|
+
lines: [
|
|
168
|
+
`ended ${session?.id ?? sessionId}`,
|
|
169
|
+
...(session ? renderSession(session, now).slice(1) : []),
|
|
170
|
+
'the record is kept, with the reason it ended, until the retention window retires it.',
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const outcome = await callDaemonWsVerb<HostedListPayload>(target, 'sessions.hosted.list', {
|
|
176
|
+
body: { includeTerminated: flags.all },
|
|
177
|
+
...socketOption,
|
|
178
|
+
});
|
|
179
|
+
if (!outcome.ok) {
|
|
180
|
+
return {
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
lines: flags.json
|
|
183
|
+
? [JSON.stringify({ ok: false, error: outcome.error, fix: outcome.fix }, null, 2)]
|
|
184
|
+
: [outcome.error, ` ${outcome.fix}`],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (flags.json) {
|
|
188
|
+
return { exitCode: 0, lines: [JSON.stringify({ ok: true, data: outcome.data }, null, 2)] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sessions = outcome.data.sessions ?? [];
|
|
192
|
+
const where = target.isLocal ? 'this machine' : target.baseUrl;
|
|
193
|
+
if (sessions.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
exitCode: 0,
|
|
196
|
+
lines: [
|
|
197
|
+
`the daemon on ${where} is hosting no sessions${flags.all ? '' : ' (add --all to include ones that have ended)'}.`,
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
exitCode: 0,
|
|
203
|
+
lines: [
|
|
204
|
+
`${sessions.length} session${sessions.length === 1 ? '' : 's'} hosted by the daemon on ${where}:`,
|
|
205
|
+
'',
|
|
206
|
+
...sessions.flatMap((session) => [...renderSession(session, now), '']),
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-command.ts — `goodvibes-daemon status` and `goodvibes-daemon update`.
|
|
3
|
+
*
|
|
4
|
+
* The question a headless box's operator asks first: is it up, what version, on
|
|
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 — `status` fell
|
|
7
|
+
* through the parser and started a SECOND daemon in the foreground.
|
|
8
|
+
*
|
|
9
|
+
* WHERE EACH LINE COMES FROM
|
|
10
|
+
*
|
|
11
|
+
* Everything about the RUNNING daemon comes from that daemon, over the
|
|
12
|
+
* --host/--port/--token convention @pellux/goodvibes-terminal-shell
|
|
13
|
+
* established: `/status` for identity, `/api/health` for the health roll-up and
|
|
14
|
+
* the address it actually bound, `/api/channels/status` for the channels, and
|
|
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` — same
|
|
17
|
+
* target, same token, different transport.
|
|
18
|
+
*
|
|
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
|
+
* host, because no verb reports them. That makes those lines local-only, and
|
|
22
|
+
* they say so for a remote target instead of being guessed at.
|
|
23
|
+
*
|
|
24
|
+
* NOTHING HERE DECIDES WHAT AN ANSWER MEANS. A sub-question that fails is one
|
|
25
|
+
* line saying it failed; only an unreachable daemon is a failed command.
|
|
26
|
+
*/
|
|
27
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
28
|
+
import {
|
|
29
|
+
resolveRemoteDaemonTarget,
|
|
30
|
+
type DaemonFetch,
|
|
31
|
+
type DaemonVerbOutcome,
|
|
32
|
+
type RemoteDaemonTarget,
|
|
33
|
+
} from '@pellux/goodvibes-terminal-shell';
|
|
34
|
+
import { callDaemonRoute } from '../cluster/raw-reply-route.ts';
|
|
35
|
+
import { callDaemonWsVerb, type DaemonWebSocketFactory } from '../cluster/daemon-ws-call.ts';
|
|
36
|
+
import {
|
|
37
|
+
describeLocalDaemonState,
|
|
38
|
+
formatDuration,
|
|
39
|
+
type LocalDaemonState,
|
|
40
|
+
type LocalStateIo,
|
|
41
|
+
} from './local-daemon-state.ts';
|
|
42
|
+
|
|
43
|
+
export interface DaemonCommandResult {
|
|
44
|
+
readonly lines: readonly string[];
|
|
45
|
+
readonly exitCode: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The target flags every remote-capable subcommand takes. */
|
|
49
|
+
export interface RemoteCommandFlags {
|
|
50
|
+
readonly host: string | undefined;
|
|
51
|
+
readonly port: number | undefined;
|
|
52
|
+
readonly token: string | undefined;
|
|
53
|
+
readonly json: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RemoteCommandDeps {
|
|
57
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
58
|
+
readonly daemonHomeDir: string;
|
|
59
|
+
/** Where the daemon writes its own lifecycle/receipt files, for the local-only lines. */
|
|
60
|
+
readonly controlPlaneConfigDir: string;
|
|
61
|
+
readonly fetchImpl?: DaemonFetch | undefined;
|
|
62
|
+
readonly socketFactory?: DaemonWebSocketFactory | undefined;
|
|
63
|
+
readonly readToken?: ((daemonHomeDir: string) => string | undefined) | undefined;
|
|
64
|
+
readonly now?: (() => number) | undefined;
|
|
65
|
+
readonly localStateIo?: LocalStateIo | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// The shapes this command reads. Only the fields it prints are declared: the
|
|
70
|
+
// daemon's schemas carry a great deal more, and re-declaring all of it here
|
|
71
|
+
// would be a second contract to keep in step with the first.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `/status`. Answers with the payload itself, not a `{ ok, data }` wrapper.
|
|
76
|
+
*
|
|
77
|
+
* It carries a `cluster` block the operator contract does not list, and that
|
|
78
|
+
* block is where this daemon's ROLE in its group comes from. `cluster.uptimeMs`
|
|
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
|
+
*/
|
|
82
|
+
interface ControlStatusPayload {
|
|
83
|
+
readonly status?: string;
|
|
84
|
+
readonly version?: string;
|
|
85
|
+
readonly cluster?: {
|
|
86
|
+
readonly enabled?: boolean;
|
|
87
|
+
readonly role?: string;
|
|
88
|
+
readonly nodeId?: string;
|
|
89
|
+
readonly heldSurfaceCount?: number;
|
|
90
|
+
readonly consumersRunning?: boolean;
|
|
91
|
+
/** The daemon BUILD's version, which is not always `version` above. */
|
|
92
|
+
readonly version?: string;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface HealthNetworkBinding {
|
|
97
|
+
readonly host?: string;
|
|
98
|
+
readonly port?: number;
|
|
99
|
+
readonly scheme?: string;
|
|
100
|
+
readonly ready?: boolean;
|
|
101
|
+
readonly errors?: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface HealthPayload {
|
|
105
|
+
readonly overall?: string;
|
|
106
|
+
readonly degradedDomains?: readonly string[];
|
|
107
|
+
readonly providerProblems?: readonly string[];
|
|
108
|
+
readonly integrationProblems?: readonly string[];
|
|
109
|
+
readonly mcpProblems?: { readonly degraded?: readonly string[]; readonly quarantined?: readonly string[] };
|
|
110
|
+
readonly network?: { readonly controlPlane?: HealthNetworkBinding };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ChannelsPayload {
|
|
114
|
+
readonly channels?: readonly {
|
|
115
|
+
readonly id?: string;
|
|
116
|
+
readonly label?: string;
|
|
117
|
+
readonly state?: string;
|
|
118
|
+
readonly enabled?: boolean;
|
|
119
|
+
}[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `/api/cluster/status`. Wrapped, and the group's own view of membership. */
|
|
123
|
+
interface ClusterStatusPayload {
|
|
124
|
+
readonly membership?: string;
|
|
125
|
+
readonly groupName?: string | null;
|
|
126
|
+
readonly groupId?: string | null;
|
|
127
|
+
readonly nodeName?: string;
|
|
128
|
+
readonly memberCount?: number;
|
|
129
|
+
readonly advice?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface HostedSessionsPayload {
|
|
133
|
+
readonly sessions?: readonly { readonly id?: string; readonly status?: string }[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function failure(error: string, fix: string, json: boolean): DaemonCommandResult {
|
|
139
|
+
return {
|
|
140
|
+
lines: json
|
|
141
|
+
? [JSON.stringify({ ok: false, error, fix }, null, 2)]
|
|
142
|
+
: [error, ` ${fix}`],
|
|
143
|
+
exitCode: 1,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the daemon to talk to, or explain why not.
|
|
149
|
+
*
|
|
150
|
+
* Shared by `status`, `update`, `sessions` and `pair` so all four default to
|
|
151
|
+
* this machine and refuse in the same words when they cannot.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveTargetOrFailure(
|
|
154
|
+
flags: RemoteCommandFlags,
|
|
155
|
+
deps: RemoteCommandDeps,
|
|
156
|
+
): { readonly ok: true; readonly target: RemoteDaemonTarget } | { readonly ok: false; readonly result: DaemonCommandResult } {
|
|
157
|
+
const resolved = resolveRemoteDaemonTarget({
|
|
158
|
+
flags: {
|
|
159
|
+
...(flags.host === undefined ? {} : { host: flags.host }),
|
|
160
|
+
...(flags.port === undefined ? {} : { port: flags.port }),
|
|
161
|
+
...(flags.token === undefined ? {} : { token: flags.token }),
|
|
162
|
+
},
|
|
163
|
+
configManager: deps.configManager,
|
|
164
|
+
daemonHomeDir: deps.daemonHomeDir,
|
|
165
|
+
...(deps.readToken ? { readToken: deps.readToken } : {}),
|
|
166
|
+
});
|
|
167
|
+
if (!resolved.ok) {
|
|
168
|
+
return { ok: false, result: failure(resolved.error, resolved.fix, flags.json) };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, target: resolved.target };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function optionalLine(label: string, value: string | undefined): string[] {
|
|
174
|
+
return value === undefined ? [] : [`${label}${value}`];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The version, and a second line when the daemon states two different ones.
|
|
179
|
+
*
|
|
180
|
+
* `/status` reports `version` from the platform package while the cluster block
|
|
181
|
+
* it carries reports the DAEMON build's version, and against a live daemon
|
|
182
|
+
* those disagreed — 1.21.0 against 1.28.0. Printing one of them silently would
|
|
183
|
+
* put a number on this page that is wrong for whichever question the reader had
|
|
184
|
+
* in mind, so both are printed and labelled until the daemon states one.
|
|
185
|
+
*/
|
|
186
|
+
function versionLines(identity: ControlStatusPayload): string[] {
|
|
187
|
+
const platform = identity.version;
|
|
188
|
+
const build = identity.cluster?.version;
|
|
189
|
+
if (platform === undefined) return optionalLine(' version: ', build);
|
|
190
|
+
if (build === undefined || build === platform) return [` version: ${platform}`];
|
|
191
|
+
return [
|
|
192
|
+
` version: ${build} (the daemon build)`,
|
|
193
|
+
` ${platform} (the platform package it reports on /status)`,
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The uptime / update / rollback block, or the one line saying why it is absent. */
|
|
198
|
+
function localStateLines(state: LocalDaemonState): string[] {
|
|
199
|
+
if (!state.available) return [` history: ${state.unavailableReason}`];
|
|
200
|
+
const lines: string[] = [];
|
|
201
|
+
if (state.uptimeMs !== undefined) {
|
|
202
|
+
lines.push(` uptime: ${formatDuration(state.uptimeMs)}`);
|
|
203
|
+
} else if (state.marker?.state === 'clean-shutdown') {
|
|
204
|
+
lines.push(' uptime: the last daemon on this host shut down cleanly; this is a fresh start or none');
|
|
205
|
+
} else {
|
|
206
|
+
lines.push(' uptime: not recorded yet on this host');
|
|
207
|
+
}
|
|
208
|
+
if (state.marker && state.marker.failedStarts > 0) {
|
|
209
|
+
lines.push(` starts: ${state.marker.failedStarts} consecutive start attempts did not finish starting`);
|
|
210
|
+
}
|
|
211
|
+
if (state.marker?.rejectedVersion !== undefined) {
|
|
212
|
+
lines.push(
|
|
213
|
+
` rejected: ${state.marker.rejectedVersion} crash looped and was rolled back — `
|
|
214
|
+
+ 'the update loop will not install that version again',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (state.rolledBack) {
|
|
218
|
+
lines.push(' rollback: an automatic rollback is in force; no fully-started boot has cleared it yet');
|
|
219
|
+
}
|
|
220
|
+
if (state.receipts.length > 0) {
|
|
221
|
+
lines.push(' receipts:');
|
|
222
|
+
for (const receipt of state.receipts) {
|
|
223
|
+
lines.push(` ${new Date(receipt.at).toISOString()} ${receipt.text}`);
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
lines.push(' receipts: none written');
|
|
227
|
+
}
|
|
228
|
+
return lines;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function healthLines(outcome: DaemonVerbOutcome<HealthPayload>): string[] {
|
|
232
|
+
if (!outcome.ok) return [` health: could not read — ${outcome.error}`];
|
|
233
|
+
const health = outcome.data;
|
|
234
|
+
const lines = [` health: ${health.overall ?? 'unknown'}`];
|
|
235
|
+
const binding = health.network?.controlPlane;
|
|
236
|
+
if (binding?.host !== undefined && binding.port !== undefined) {
|
|
237
|
+
const ready = binding.ready === false ? ' (NOT ready)' : '';
|
|
238
|
+
lines.push(` bound: ${binding.scheme ?? 'http'}://${binding.host}:${binding.port}${ready}`);
|
|
239
|
+
for (const error of binding.errors ?? []) lines.push(` ${error}`);
|
|
240
|
+
}
|
|
241
|
+
for (const domain of health.degradedDomains ?? []) lines.push(` degraded: ${domain}`);
|
|
242
|
+
for (const problem of health.providerProblems ?? []) lines.push(` provider: ${problem}`);
|
|
243
|
+
for (const problem of health.integrationProblems ?? []) lines.push(` integration: ${problem}`);
|
|
244
|
+
for (const server of health.mcpProblems?.quarantined ?? []) lines.push(` mcp quarantined: ${server}`);
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The channel roll-up.
|
|
250
|
+
*
|
|
251
|
+
* Only a channel that is switched ON and not healthy is named. Every channel
|
|
252
|
+
* the daemon knows about appears in this payload, and a daemon with one
|
|
253
|
+
* configured channel ships sixteen more in state `disabled` — listing those as
|
|
254
|
+
* problems produced a seventeen-line wall under a healthy daemon and buried the
|
|
255
|
+
* one line that meant something.
|
|
256
|
+
*/
|
|
257
|
+
function channelLines(outcome: DaemonVerbOutcome<ChannelsPayload>): string[] {
|
|
258
|
+
if (!outcome.ok) return [` channels: could not read — ${outcome.error}`];
|
|
259
|
+
const channels = outcome.data.channels ?? [];
|
|
260
|
+
if (channels.length === 0) return [' channels: none configured'];
|
|
261
|
+
const on = channels.filter((channel) => channel.enabled !== false);
|
|
262
|
+
const unhealthy = on.filter((channel) => channel.state !== undefined
|
|
263
|
+
&& channel.state !== 'ready'
|
|
264
|
+
&& channel.state !== 'healthy'
|
|
265
|
+
&& channel.state !== 'connected');
|
|
266
|
+
const lines = [` channels: ${on.length} of ${channels.length} switched on`];
|
|
267
|
+
for (const channel of unhealthy) {
|
|
268
|
+
lines.push(` ${channel.label ?? channel.id ?? 'a channel'}: ${channel.state}`);
|
|
269
|
+
}
|
|
270
|
+
return lines;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* This machine's place in its group.
|
|
275
|
+
*
|
|
276
|
+
* Two sources, because neither answers the whole question: `/status` carries
|
|
277
|
+
* the ROLE this node currently holds, and `/api/cluster/status` carries the
|
|
278
|
+
* GROUP it holds that role in. A daemon with sharing switched off says so and
|
|
279
|
+
* stops — a role inside no group is not information.
|
|
280
|
+
*/
|
|
281
|
+
function clusterLines(
|
|
282
|
+
identity: ControlStatusPayload,
|
|
283
|
+
outcome: DaemonVerbOutcome<ClusterStatusPayload>,
|
|
284
|
+
): string[] {
|
|
285
|
+
const role = identity.cluster?.role;
|
|
286
|
+
if (identity.cluster?.enabled === false) {
|
|
287
|
+
return [' cluster: off — this machine handles its own inbound work'];
|
|
288
|
+
}
|
|
289
|
+
if (!outcome.ok) {
|
|
290
|
+
return [
|
|
291
|
+
role === undefined
|
|
292
|
+
? ` cluster: could not read — ${outcome.error}`
|
|
293
|
+
: ` cluster: ${role} (the group view could not be read — ${outcome.error})`,
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
const cluster = outcome.data;
|
|
297
|
+
if (cluster.membership === 'no-group') {
|
|
298
|
+
return [' cluster: in no group yet — `goodvibes-daemon cluster create` starts one'];
|
|
299
|
+
}
|
|
300
|
+
const group = cluster.groupName ?? cluster.groupId ?? 'its group';
|
|
301
|
+
const members = cluster.memberCount === undefined ? '' : ` of ${cluster.memberCount}`;
|
|
302
|
+
return [` cluster: ${role ?? cluster.membership ?? 'a member'} in "${group}"${members}`];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function hostedSessionLines(outcome: DaemonVerbOutcome<HostedSessionsPayload>): string[] {
|
|
306
|
+
if (!outcome.ok) return [` sessions: could not read — ${outcome.error}`];
|
|
307
|
+
const sessions = outcome.data.sessions ?? [];
|
|
308
|
+
return [` sessions: ${sessions.length} hosted by this daemon`];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface RunStatusCommandInput extends RemoteCommandDeps {
|
|
312
|
+
readonly flags: RemoteCommandFlags;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `goodvibes-daemon status [--json]`.
|
|
317
|
+
*
|
|
318
|
+
* Exit 0 when the daemon answered its identity call, 1 when it could not be
|
|
319
|
+
* reached. Every other sub-question that fails is one line inside a successful
|
|
320
|
+
* report — a daemon with a broken channel is up, and saying otherwise would be
|
|
321
|
+
* the kind of wrong that makes an operator distrust the whole page.
|
|
322
|
+
*/
|
|
323
|
+
export async function runStatusCommand(input: RunStatusCommandInput): Promise<DaemonCommandResult> {
|
|
324
|
+
const { flags } = input;
|
|
325
|
+
const resolved = resolveTargetOrFailure(flags, input);
|
|
326
|
+
if (!resolved.ok) return resolved.result;
|
|
327
|
+
const target = resolved.target;
|
|
328
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
329
|
+
|
|
330
|
+
// `/status` answers with the payload itself; `/api/cluster/*` wraps it. Each
|
|
331
|
+
// call states which, because reading one as the other turns a healthy 200
|
|
332
|
+
// into "the daemon refused the request".
|
|
333
|
+
const identity = await callDaemonRoute<ControlStatusPayload>(
|
|
334
|
+
target, '/status', { method: 'GET', envelope: 'raw' }, fetchImpl,
|
|
335
|
+
);
|
|
336
|
+
if (!identity.ok) return failure(identity.error, identity.fix, flags.json);
|
|
337
|
+
|
|
338
|
+
const [health, channels, cluster, hosted] = await Promise.all([
|
|
339
|
+
callDaemonRoute<HealthPayload>(target, '/api/health', { method: 'GET', envelope: 'raw' }, fetchImpl),
|
|
340
|
+
callDaemonRoute<ChannelsPayload>(target, '/api/channels/status', { method: 'GET', envelope: 'raw' }, fetchImpl),
|
|
341
|
+
callDaemonRoute<ClusterStatusPayload>(target, '/api/cluster/status', { method: 'GET', envelope: 'wrapped' }, fetchImpl),
|
|
342
|
+
callDaemonWsVerb<HostedSessionsPayload>(target, 'sessions.hosted.list', {
|
|
343
|
+
...(input.socketFactory ? { socketFactory: input.socketFactory } : {}),
|
|
344
|
+
}),
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
const local = describeLocalDaemonState({
|
|
348
|
+
isLocal: target.isLocal,
|
|
349
|
+
controlPlaneConfigDir: input.controlPlaneConfigDir,
|
|
350
|
+
...(input.now ? { now: input.now } : {}),
|
|
351
|
+
...(input.localStateIo ? { io: input.localStateIo } : {}),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (flags.json) {
|
|
355
|
+
return {
|
|
356
|
+
exitCode: 0,
|
|
357
|
+
lines: [JSON.stringify({
|
|
358
|
+
ok: true,
|
|
359
|
+
data: {
|
|
360
|
+
target: target.baseUrl,
|
|
361
|
+
isLocal: target.isLocal,
|
|
362
|
+
identity: identity.data,
|
|
363
|
+
health: health.ok ? health.data : { error: health.error },
|
|
364
|
+
channels: channels.ok ? channels.data : { error: channels.error },
|
|
365
|
+
cluster: cluster.ok ? cluster.data : { error: cluster.error },
|
|
366
|
+
hostedSessions: hosted.ok
|
|
367
|
+
? { count: (hosted.data.sessions ?? []).length, sessions: hosted.data.sessions ?? [] }
|
|
368
|
+
: { error: hosted.error },
|
|
369
|
+
local: local.available
|
|
370
|
+
? {
|
|
371
|
+
uptimeMs: local.uptimeMs,
|
|
372
|
+
marker: local.marker,
|
|
373
|
+
receipts: local.receipts,
|
|
374
|
+
rolledBack: local.rolledBack,
|
|
375
|
+
}
|
|
376
|
+
: { available: false, reason: local.unavailableReason },
|
|
377
|
+
},
|
|
378
|
+
}, null, 2)],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const where = target.isLocal ? 'this machine' : target.baseUrl;
|
|
383
|
+
return {
|
|
384
|
+
exitCode: 0,
|
|
385
|
+
lines: [
|
|
386
|
+
`goodvibes daemon on ${where}`,
|
|
387
|
+
...versionLines(identity.data),
|
|
388
|
+
...optionalLine(' state: ', identity.data.status),
|
|
389
|
+
...healthLines(health),
|
|
390
|
+
...localStateLines(local),
|
|
391
|
+
...channelLines(channels),
|
|
392
|
+
...clusterLines(identity.data, cluster),
|
|
393
|
+
...hostedSessionLines(hosted),
|
|
394
|
+
],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export interface RunUpdateCommandInput extends RemoteCommandDeps {
|
|
399
|
+
readonly flags: RemoteCommandFlags & { readonly check: boolean };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `goodvibes-daemon update [--check]`.
|
|
404
|
+
*
|
|
405
|
+
* What the daemon knows about its own updates: the running version, the
|
|
406
|
+
* receipts it wrote about swaps and restarts, the version an automatic rollback
|
|
407
|
+
* rejected, and whether a rollback is in force.
|
|
408
|
+
*
|
|
409
|
+
* --check is honest about a gap. The daemon runs the whole self-update loop
|
|
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
|
+
* the operator contract this build was written against has no update method of
|
|
413
|
+
* any kind (no `update.*`, no `admin.update`, nothing under `control.` that
|
|
414
|
+
* checks). Rather than invent a verb this daemon does not answer, --check says
|
|
415
|
+
* so and names the two things that do work: waiting for the hourly check, or
|
|
416
|
+
* restarting the service, which checks on the way up.
|
|
417
|
+
*/
|
|
418
|
+
export async function runUpdateCommand(input: RunUpdateCommandInput): Promise<DaemonCommandResult> {
|
|
419
|
+
const { flags } = input;
|
|
420
|
+
const resolved = resolveTargetOrFailure(flags, input);
|
|
421
|
+
if (!resolved.ok) return resolved.result;
|
|
422
|
+
const target = resolved.target;
|
|
423
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
424
|
+
|
|
425
|
+
const identity = await callDaemonRoute<ControlStatusPayload>(
|
|
426
|
+
target, '/status', { method: 'GET', envelope: 'raw' }, fetchImpl,
|
|
427
|
+
);
|
|
428
|
+
if (!identity.ok) return failure(identity.error, identity.fix, flags.json);
|
|
429
|
+
|
|
430
|
+
const local = describeLocalDaemonState({
|
|
431
|
+
isLocal: target.isLocal,
|
|
432
|
+
controlPlaneConfigDir: input.controlPlaneConfigDir,
|
|
433
|
+
...(input.now ? { now: input.now } : {}),
|
|
434
|
+
...(input.localStateIo ? { io: input.localStateIo } : {}),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const checkNote = flags.check
|
|
438
|
+
? [
|
|
439
|
+
'',
|
|
440
|
+
'update --check: this daemon publishes no verb to trigger an update check early.',
|
|
441
|
+
' It checks once an hour on its own and swaps only at an idle moment.',
|
|
442
|
+
' To make it check now, restart it — it checks on the way up:',
|
|
443
|
+
' goodvibes-daemon restart-service',
|
|
444
|
+
]
|
|
445
|
+
: [];
|
|
446
|
+
|
|
447
|
+
if (flags.json) {
|
|
448
|
+
return {
|
|
449
|
+
exitCode: 0,
|
|
450
|
+
lines: [JSON.stringify({
|
|
451
|
+
ok: true,
|
|
452
|
+
data: {
|
|
453
|
+
target: target.baseUrl,
|
|
454
|
+
isLocal: target.isLocal,
|
|
455
|
+
version: identity.data.version,
|
|
456
|
+
checkRequested: flags.check,
|
|
457
|
+
checkVerbAvailable: false,
|
|
458
|
+
local: local.available
|
|
459
|
+
? {
|
|
460
|
+
marker: local.marker,
|
|
461
|
+
receipts: local.receipts,
|
|
462
|
+
rejectedVersion: local.marker?.rejectedVersion,
|
|
463
|
+
rolledBack: local.rolledBack,
|
|
464
|
+
}
|
|
465
|
+
: { available: false, reason: local.unavailableReason },
|
|
466
|
+
},
|
|
467
|
+
}, null, 2)],
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const where = target.isLocal ? 'this machine' : target.baseUrl;
|
|
472
|
+
return {
|
|
473
|
+
exitCode: 0,
|
|
474
|
+
lines: [
|
|
475
|
+
`goodvibes daemon updates on ${where}`,
|
|
476
|
+
...versionLines(identity.data),
|
|
477
|
+
...localStateLines(local),
|
|
478
|
+
...checkNote,
|
|
479
|
+
],
|
|
480
|
+
};
|
|
481
|
+
}
|