@pellux/goodvibes-tui 1.23.0 → 1.24.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 +14 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -3
- package/src/cli/bundle-command.ts +4 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/management-utils.ts +4 -0
- package/src/cluster/commands.ts +382 -0
- package/src/cluster/remote-daemon-target.ts +225 -0
- package/src/cluster/render.ts +268 -0
- package/src/core/conversation-line-cache.ts +11 -6
- package/src/core/conversation-render-context.ts +122 -0
- package/src/core/conversation-rendering.ts +86 -89
- package/src/daemon/cli.ts +71 -2
- package/src/daemon/handlers/inbox/index.ts +63 -3
- package/src/daemon/handlers/inbox/poller.ts +54 -8
- package/src/input/command-args-hint.ts +2 -0
- package/src/input/commands/cluster-runtime.ts +53 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-data.ts +6 -0
- package/src/main.ts +3 -0
- package/src/renderer/conversation-surface.ts +23 -6
- package/src/renderer/conversation-tree.ts +142 -93
- package/src/renderer/tool-call.ts +13 -12
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/daemon-handler-composition.ts +80 -0
- package/src/runtime/disposal-wiring.ts +66 -0
- package/src/runtime/durability-services.ts +7 -3
- package/src/runtime/embedded-service-factories.ts +19 -1
- package/src/runtime/process-lifecycle.ts +9 -1
- package/src/runtime/remote-execution-composition.ts +62 -0
- package/src/runtime/services.ts +48 -48
- package/src/verification/verification-ledger.ts +38 -0
- package/src/version.ts +1 -1
|
@@ -15,8 +15,13 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
|
15
15
|
import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
|
|
16
16
|
import { GLYPHS } from '../renderer/ui-primitives.ts';
|
|
17
17
|
import type { BlockMeta } from './conversation-types.ts';
|
|
18
|
+
import {
|
|
19
|
+
collectToolCallOutcomes,
|
|
20
|
+
isTurnCollapsed,
|
|
21
|
+
type ConversationRenderContext,
|
|
22
|
+
} from './conversation-render-context.ts';
|
|
18
23
|
import { renderCompactionContinuationMessage } from './conversation-compaction-render.ts';
|
|
19
|
-
import {
|
|
24
|
+
import { drawTreeRails, treeIndentCols, treeTextCol } from '../renderer/conversation-tree.ts';
|
|
20
25
|
import {
|
|
21
26
|
MAX_NEST_DEPTH,
|
|
22
27
|
buildRenderPlan,
|
|
@@ -52,76 +57,22 @@ const NAVIGABLE_KINDS: ReadonlySet<SystemMessageKind> = new Set(['system', 'wrfc
|
|
|
52
57
|
|
|
53
58
|
type Message = ConversationMessageSnapshot;
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return ids;
|
|
67
|
-
}
|
|
60
|
+
// The render context and its pure derivations live in a type-only leaf module
|
|
61
|
+
// (conversation-render-context.ts) so per-row render modules can depend on the
|
|
62
|
+
// SHAPE of a render without depending on this drawing module. Re-exported here
|
|
63
|
+
// because this file remains the transcript renderer's entry point.
|
|
64
|
+
export {
|
|
65
|
+
collectCompletedToolCallIds,
|
|
66
|
+
collectToolCallOutcomes,
|
|
67
|
+
isTurnCollapsed,
|
|
68
|
+
type ConversationRenderContext,
|
|
69
|
+
type ToolCallOutcome,
|
|
70
|
+
} from './conversation-render-context.ts';
|
|
68
71
|
|
|
69
72
|
function summarizeCallId(callId: string, maxLength = 24): string {
|
|
70
73
|
return callId.length <= maxLength ? callId : `${callId.slice(0, maxLength - 1)}…`;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
export interface ConversationRenderContext {
|
|
74
|
-
readonly history: {
|
|
75
|
-
addLine: (line: Line) => void;
|
|
76
|
-
addLines: (lines: Line[]) => void;
|
|
77
|
-
getLineCount: () => number;
|
|
78
|
-
};
|
|
79
|
-
readonly blockRegistry: BlockMeta[];
|
|
80
|
-
readonly collapseState: Map<string, boolean>;
|
|
81
|
-
readonly errorLineRegistry: number[];
|
|
82
|
-
/** Maps message index → SystemMessageKind for typed system messages. */
|
|
83
|
-
readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
|
|
84
|
-
readonly configManager: ConfigManager | null;
|
|
85
|
-
readonly splashOptions: SplashOptions;
|
|
86
|
-
/**
|
|
87
|
-
* Tool-call ids that have a corresponding tool-result message (i.e. the tool
|
|
88
|
-
* actually ran). An assistant tool call whose id is NOT in this set is still
|
|
89
|
-
* awaiting a decision (e.g. an approval prompt) and renders with a pending
|
|
90
|
-
* glyph instead of the completed ✓. When undefined (single-message callers
|
|
91
|
-
* without sibling context), every tool call renders as done — the prior
|
|
92
|
-
* behaviour. (item 2c.)
|
|
93
|
-
*/
|
|
94
|
-
readonly completedToolCallIds?: ReadonlySet<string>;
|
|
95
|
-
/**
|
|
96
|
-
* Which assistant messages share one `● assistant` header, and what that
|
|
97
|
-
* header must say (see conversation-turn-structure.ts). Keyed by absolute
|
|
98
|
-
* index for every assistant message in a run AND every tool-result message
|
|
99
|
-
* the run's calls produced, so a collapsed turn can hide its results too.
|
|
100
|
-
* A message absent from this map (or an undefined map) renders standalone,
|
|
101
|
-
* exactly as it did before turn merging.
|
|
102
|
-
*/
|
|
103
|
-
readonly assistantTurns?: ReadonlyMap<number, AssistantTurnMembership>;
|
|
104
|
-
/**
|
|
105
|
-
* Live snapshot reader for a spawned agent's own conversation, used to
|
|
106
|
-
* splice that agent's rows in beneath the call that spawned it (see
|
|
107
|
-
* conversation-turn-structure.ts). Undefined disables nesting entirely and
|
|
108
|
-
* the transcript renders exactly as it would without subagents.
|
|
109
|
-
*/
|
|
110
|
-
readonly resolveAgentSnapshot?: (agentId: string) => readonly Message[] | null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Whether a turn's branches are hidden. Turns default to EXPANDED (unlike
|
|
115
|
-
* every other collapsible block here, which defaults to collapsed): a turn
|
|
116
|
-
* collapsed by default would hide the activity the transcript exists to show.
|
|
117
|
-
*/
|
|
118
|
-
export function isTurnCollapsed(
|
|
119
|
-
turn: AssistantTurnMembership | undefined,
|
|
120
|
-
collapseState: ReadonlyMap<string, boolean>,
|
|
121
|
-
): boolean {
|
|
122
|
-
return turn !== undefined && (collapseState.get(turn.turnKey) ?? false);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
76
|
export function renderConversationUserMessage(
|
|
126
77
|
context: ConversationRenderContext,
|
|
127
78
|
message: Extract<Message, { role: 'user' }>,
|
|
@@ -424,12 +375,23 @@ export function renderConversationToolCallNode(
|
|
|
424
375
|
const turn = context.assistantTurns?.get(node.absIdx);
|
|
425
376
|
if (isTurnCollapsed(turn, context.collapseState)) return;
|
|
426
377
|
|
|
378
|
+
// The call row carries the call's OUTCOME, not merely whether it finished:
|
|
379
|
+
// the result row below it no longer repeats a marker, so this glyph is the
|
|
380
|
+
// only place a failed or cancelled tool says so in the status column.
|
|
381
|
+
const outcome = call.id !== undefined ? context.toolCallOutcomes?.get(call.id) : undefined;
|
|
427
382
|
const ran = context.completedToolCallIds === undefined
|
|
428
383
|
|| (call.id !== undefined && context.completedToolCallIds.has(call.id));
|
|
384
|
+
const status: 'done' | 'error' | 'cancelled' | 'pending' = outcome === 'error'
|
|
385
|
+
? 'error'
|
|
386
|
+
: outcome === 'cancelled'
|
|
387
|
+
? 'cancelled'
|
|
388
|
+
: outcome === 'ok'
|
|
389
|
+
? 'done'
|
|
390
|
+
: ran ? 'done' : 'pending';
|
|
429
391
|
const indent = treeIndentCols(node.depth, width);
|
|
430
392
|
const lines = renderToolCallBlock(
|
|
431
393
|
call,
|
|
432
|
-
|
|
394
|
+
status,
|
|
433
395
|
undefined,
|
|
434
396
|
width,
|
|
435
397
|
undefined,
|
|
@@ -438,15 +400,12 @@ export function renderConversationToolCallNode(
|
|
|
438
400
|
undefined,
|
|
439
401
|
{ indentCols: indent, omitToolName: turn?.sharedToolLabel !== undefined },
|
|
440
402
|
);
|
|
441
|
-
const line = lines[0];
|
|
442
|
-
if (line) drawBranchConnector(line, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
|
|
443
|
-
context.history.addLines(lines);
|
|
444
403
|
|
|
445
404
|
// Recursion stopped here — say so rather than silently showing a subtree as
|
|
446
405
|
// if it were a leaf.
|
|
447
406
|
if (node.truncated) {
|
|
448
407
|
const noteIndent = treeIndentCols(node.depth + 1, width);
|
|
449
|
-
|
|
408
|
+
lines.push(renderConversationEventLine(width, {
|
|
450
409
|
marker: GLYPHS.navigation.collapsed,
|
|
451
410
|
markerFg: '244',
|
|
452
411
|
label: '',
|
|
@@ -460,6 +419,28 @@ export function renderConversationToolCallNode(
|
|
|
460
419
|
dim: true,
|
|
461
420
|
}], noteIndent));
|
|
462
421
|
}
|
|
422
|
+
|
|
423
|
+
// Rails last, over every line the row emitted, so the vertical run down to
|
|
424
|
+
// the next sibling has no gap in it.
|
|
425
|
+
drawTreeRails(lines, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
|
|
426
|
+
context.history.addLines(lines);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Move an already-rendered line `cols` columns to the right on a fresh
|
|
431
|
+
* full-width line. Used to place a tree row's expanded body under its own
|
|
432
|
+
* indent without re-rendering it at a different width (which would change the
|
|
433
|
+
* line count the row's badge already committed to).
|
|
434
|
+
*/
|
|
435
|
+
function shiftLineRight(line: Line, cols: number, width: number): Line {
|
|
436
|
+
if (cols <= 0) return line;
|
|
437
|
+
const shifted = createEmptyLine(width);
|
|
438
|
+
for (let i = 0; i < line.length; i++) {
|
|
439
|
+
const target = i + cols;
|
|
440
|
+
if (target >= width) break;
|
|
441
|
+
shifted[target] = line[i]!;
|
|
442
|
+
}
|
|
443
|
+
return shifted;
|
|
463
444
|
}
|
|
464
445
|
|
|
465
446
|
export function renderConversationToolMessage(
|
|
@@ -485,12 +466,25 @@ export function renderConversationToolMessage(
|
|
|
485
466
|
// Parsed once, ahead of the collapse check, so it's available for the
|
|
486
467
|
// block-registry meta merge below regardless of collapsed/expanded state.
|
|
487
468
|
const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
|
|
469
|
+
const inTree = depth > 0 && indent > 0;
|
|
488
470
|
// The EXPANDED render, computed unconditionally (even while collapsed) so
|
|
489
471
|
// the header's "N lines" badge always names what Tab would actually reveal
|
|
490
472
|
// — a raw JSON blob that pretty-prints to 50 lines must say 50, not 1, even
|
|
491
473
|
// while it's still folded. Reused below as the actual body when expanded,
|
|
492
474
|
// so this is never rendered twice.
|
|
493
|
-
|
|
475
|
+
//
|
|
476
|
+
// In the tree it is rendered at the width it will actually OCCUPY (the row's
|
|
477
|
+
// width less its indent) and then shifted into place, so the badge still
|
|
478
|
+
// counts exactly the lines expansion produces. Rendering it full-width and
|
|
479
|
+
// displaying it indented would desynchronise the two — the
|
|
480
|
+
// markdown-table-drops-columns bug class — and rendering it full-width and
|
|
481
|
+
// displaying it flush punches the row's rails out for the whole body.
|
|
482
|
+
// The body is a continuation of its row, so it starts at the row's own text
|
|
483
|
+
// column; the shift is that column expressed relative to the flush left
|
|
484
|
+
// margin the body renders with internally.
|
|
485
|
+
const bodyShift = inTree ? Math.max(0, treeTextCol(indent) - LAYOUT.LEFT_MARGIN) : 0;
|
|
486
|
+
const bodyWidth = Math.max(LAYOUT.LEFT_MARGIN + LAYOUT.RIGHT_MARGIN + 8, width - bodyShift);
|
|
487
|
+
const expandedLines = renderExpandedToolResultLines(message.content, bodyWidth);
|
|
494
488
|
const lineCount = expandedLines.length;
|
|
495
489
|
|
|
496
490
|
// Human one-line summary for tool results (write/read/exec/edit): shown as the
|
|
@@ -528,7 +522,6 @@ export function renderConversationToolMessage(
|
|
|
528
522
|
// parent call row already shows — is exactly the boilerplate this layout
|
|
529
523
|
// removes. The row leads with its size badge instead. `diff` and `cancelled`
|
|
530
524
|
// keep their labels: those carry information the parent row does not.
|
|
531
|
-
const inTree = depth > 0 && indent > 0;
|
|
532
525
|
const label = isCancelled ? 'cancelled' : (blockType === 'diff' ? 'diff' : (inTree ? '' : 'tool result'));
|
|
533
526
|
const nameSegments = inTree
|
|
534
527
|
? []
|
|
@@ -546,19 +539,12 @@ export function renderConversationToolMessage(
|
|
|
546
539
|
...nameSegments,
|
|
547
540
|
{ text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
548
541
|
], indent);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
headerLine,
|
|
556
|
-
isCancelled ? GLYPHS.status.blocked : TOOL_STATUS.SUCCESS_ICON,
|
|
557
|
-
isCancelled ? warnTone : activeUiTones().chrome.good,
|
|
558
|
-
width,
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
context.history.addLine(headerLine);
|
|
542
|
+
// Every line this row emits, gathered before anything is committed, so the
|
|
543
|
+
// rails can be drawn across all of them in one pass (see drawTreeRails). The
|
|
544
|
+
// result row deliberately carries NO status marker of its own: the call row
|
|
545
|
+
// directly above it already states the outcome in the shared bullet column,
|
|
546
|
+
// and a second marker one row down only doubles it.
|
|
547
|
+
const rows: Line[] = [headerLine];
|
|
562
548
|
|
|
563
549
|
if (isCollapsed) {
|
|
564
550
|
const collapseSuffixReserve = 30;
|
|
@@ -584,7 +570,7 @@ export function renderConversationToolMessage(
|
|
|
584
570
|
dim: true,
|
|
585
571
|
indentCols: indent,
|
|
586
572
|
});
|
|
587
|
-
|
|
573
|
+
for (const line of rendered) rows.push(line);
|
|
588
574
|
} else {
|
|
589
575
|
// Diff or plain result — either way this is exactly `expandedLines`,
|
|
590
576
|
// already computed above (and already what the header's line count is
|
|
@@ -595,8 +581,16 @@ export function renderConversationToolMessage(
|
|
|
595
581
|
// reduced width would desynchronise that count from what expansion
|
|
596
582
|
// actually reveals — the markdown-table-drops-columns bug class. The body
|
|
597
583
|
// is already visually bound to its row by the header directly above it.
|
|
598
|
-
|
|
584
|
+
// In the tree it was rendered at `bodyWidth` above and is shifted into the
|
|
585
|
+
// row's indent here, which is what leaves the rail columns to its left free
|
|
586
|
+
// for the rails to run through.
|
|
587
|
+
for (const line of expandedLines) rows.push(shiftLineRight(line, bodyShift, width));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (node && inTree) {
|
|
591
|
+
drawTreeRails(rows, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
|
|
599
592
|
}
|
|
593
|
+
context.history.addLines(rows);
|
|
600
594
|
|
|
601
595
|
const renderedLineCount = context.history.getLineCount() - startLine;
|
|
602
596
|
let meta: BlockMeta = {
|
|
@@ -635,7 +629,10 @@ export function appendConversationMessages(
|
|
|
635
629
|
// unless the caller already supplied the set. (item 2c.)
|
|
636
630
|
const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
|
|
637
631
|
? context
|
|
638
|
-
:
|
|
632
|
+
: (() => {
|
|
633
|
+
const toolCallOutcomes = collectToolCallOutcomes(messages);
|
|
634
|
+
return { ...context, toolCallOutcomes, completedToolCallIds: new Set(toolCallOutcomes.keys()) };
|
|
635
|
+
})();
|
|
639
636
|
const assistantTurns = renderContext.assistantTurns
|
|
640
637
|
?? computeAssistantTurns(messages, msgIndexOffset);
|
|
641
638
|
const turnContext: ConversationRenderContext = renderContext.assistantTurns !== undefined
|
package/src/daemon/cli.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { createRuntimeStore } from '../runtime/store/index.ts';
|
|
|
14
14
|
import { createRuntimeServices } from '../runtime/services.ts';
|
|
15
15
|
import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
16
16
|
import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
|
|
17
|
-
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
17
|
+
import { flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
18
18
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
19
19
|
import {
|
|
20
20
|
getOrCreateCompanionToken,
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from '@pellux/goodvibes-sdk/platform/discovery';
|
|
36
36
|
import { createSafeHostServeFactory } from './safe-serve.ts';
|
|
37
37
|
import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
|
|
38
|
+
import { runClusterCommand } from '../cluster/commands.ts';
|
|
38
39
|
import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
|
|
39
40
|
import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
|
|
40
41
|
import { reconcileRedundantLegacyUnit } from '../runtime/legacy-daemon-reconcile.ts';
|
|
@@ -103,6 +104,39 @@ function readDaemonCliTokens(env: NodeJS.ProcessEnv): DaemonCliTokens {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
async function main(): Promise<void> {
|
|
107
|
+
// `cluster …` is intercepted before the daemon's own flag parser runs, and
|
|
108
|
+
// before any runtime is composed.
|
|
109
|
+
//
|
|
110
|
+
// Before the parser, because the subcommand has its own flag vocabulary
|
|
111
|
+
// (--group, --key, --host, --port, --token) that the daemon parser would
|
|
112
|
+
// reject as unknown. Before the runtime, because this command talks to a
|
|
113
|
+
// daemon that is ALREADY RUNNING — composing a second runtime here would
|
|
114
|
+
// build a competing set of state on a machine that already has one.
|
|
115
|
+
//
|
|
116
|
+
// See remote-daemon-target.ts for the --host/--port/--token convention that
|
|
117
|
+
// every later remote-capable subcommand should follow.
|
|
118
|
+
const rawArgs = process.argv.slice(2);
|
|
119
|
+
if (rawArgs[0] === 'cluster') {
|
|
120
|
+
const ownership = resolveDaemonCliOwnership();
|
|
121
|
+
const clusterConfig = new ConfigManager({
|
|
122
|
+
workingDir: ownership.workingDirectory,
|
|
123
|
+
homeDir: ownership.homeDirectory,
|
|
124
|
+
surfaceRoot: 'tui',
|
|
125
|
+
});
|
|
126
|
+
const result = await runClusterCommand({
|
|
127
|
+
argv: rawArgs.slice(1),
|
|
128
|
+
configManager: clusterConfig,
|
|
129
|
+
daemonHomeDir: ownership.daemonHomeDirectory,
|
|
130
|
+
});
|
|
131
|
+
if (result.rawOutput) process.stdout.write(`\u001b${result.rawOutput}`);
|
|
132
|
+
for (const line of result.lines) {
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
if (result.exitCode === 0) console.log(line);
|
|
135
|
+
else console.error(line);
|
|
136
|
+
}
|
|
137
|
+
process.exit(result.exitCode);
|
|
138
|
+
}
|
|
139
|
+
|
|
106
140
|
// Parse CLI flags first so --daemon-home and --working-dir env vars are set
|
|
107
141
|
// before resolveDaemonCliOwnership() reads them.
|
|
108
142
|
const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
|
|
@@ -327,6 +361,15 @@ async function main(): Promise<void> {
|
|
|
327
361
|
// undefined for a non-binary install, so a dev run stays host-managed and
|
|
328
362
|
// never swaps the interpreter. See lifecycle.ts.
|
|
329
363
|
updateArtifact: resolveDaemonUpdateArtifact({ version: VERSION }),
|
|
364
|
+
// The SAME coordinator this repository's inbox poller registered with (see
|
|
365
|
+
// runtime/cluster-composition.ts). The facade must reuse it rather than
|
|
366
|
+
// compose its own: two coordinators in one process are two nodes in the
|
|
367
|
+
// election, and whichever lost would silence consumers the other owns.
|
|
368
|
+
clusterCoordinator: runtimeServices.clusterCoordinator,
|
|
369
|
+
// The `cluster` verbs, served on /api/cluster/*. The CLI subcommands, the
|
|
370
|
+
// TUI's /cluster command and any web UI all call these, so a command run
|
|
371
|
+
// against a REMOTE daemon behaves exactly like one run on that machine.
|
|
372
|
+
clusterGroupVerbs: runtimeServices.clusterGroup.verbs,
|
|
330
373
|
});
|
|
331
374
|
const listener = new HttpListener({
|
|
332
375
|
hookDispatcher: runtimeServices.hookDispatcher,
|
|
@@ -357,6 +400,11 @@ async function main(): Promise<void> {
|
|
|
357
400
|
daemon.enable({ daemon: true }, effectiveDaemonToken);
|
|
358
401
|
listener.enable({ httpListener: true }, effectiveHttpToken);
|
|
359
402
|
|
|
403
|
+
// Before the daemon: the group layer owns the socket the leader election
|
|
404
|
+
// coordinates over, and it announces this machine's return to the group so a
|
|
405
|
+
// box that has been off for months re-keys itself with no operator action.
|
|
406
|
+
await runtimeServices.startCluster();
|
|
407
|
+
|
|
360
408
|
await Promise.all([
|
|
361
409
|
daemon.start(),
|
|
362
410
|
config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
|
|
@@ -375,12 +423,29 @@ async function main(): Promise<void> {
|
|
|
375
423
|
const timeout = new Promise<'timeout'>((resolve) =>
|
|
376
424
|
setTimeout(() => resolve('timeout'), SHUTDOWN_DEADLINE_MS)
|
|
377
425
|
);
|
|
378
|
-
|
|
426
|
+
// daemon.stop() drives the ordered inbound teardown: it stops every gated
|
|
427
|
+
// consumer and only then broadcasts the resignation, so another node on
|
|
428
|
+
// this network takes over in about a second instead of waiting out the
|
|
429
|
+
// crash timeout. Handler surfaces (inbox store, catalog handlers) are
|
|
430
|
+
// released afterwards — before this, a SIGTERM left the inbox cursor store
|
|
431
|
+
// open and relied on process exit to clear the poll timers.
|
|
432
|
+
const stop = Promise.allSettled([listener.stop(), daemon.stop()])
|
|
433
|
+
.then(() => runtimeServices.daemonHandlers.unregister())
|
|
434
|
+
// This process built the runtime graph and handed it to DaemonServer, so
|
|
435
|
+
// by the SDK's ownership rule the facade leaves it alone — nothing else
|
|
436
|
+
// stops these pollers. Without this the config watch, fleet tick, memory
|
|
437
|
+
// governor, watcher registry and six more kept ticking until process exit.
|
|
438
|
+
.then(() => { runtimeServices.dispose(); })
|
|
439
|
+
.then(() => 'done' as const);
|
|
379
440
|
const result = await Promise.race([stop, timeout]);
|
|
380
441
|
if (result === 'timeout') {
|
|
381
442
|
logger.warn('shutdown deadline exceeded — forcing exit');
|
|
443
|
+
// A forced exit is exactly the case where the log matters most and is
|
|
444
|
+
// least likely to have drained on its own.
|
|
445
|
+
flushActivityLogSync();
|
|
382
446
|
process.exit(1);
|
|
383
447
|
}
|
|
448
|
+
flushActivityLogSync();
|
|
384
449
|
process.exit(0);
|
|
385
450
|
};
|
|
386
451
|
|
|
@@ -474,5 +539,9 @@ void main().catch(async (error) => {
|
|
|
474
539
|
logger.error('goodvibes daemon host failed', {
|
|
475
540
|
error: summarizeError(error),
|
|
476
541
|
});
|
|
542
|
+
// The daemon never came up. Without this the reason is buffered in a process
|
|
543
|
+
// that is about to stop existing, and the failure reads as a daemon that
|
|
544
|
+
// never logged anything at all.
|
|
545
|
+
flushActivityLogSync();
|
|
477
546
|
process.exit(1);
|
|
478
547
|
});
|
|
@@ -75,6 +75,14 @@ export interface InboxListOutput {
|
|
|
75
75
|
cursor?: string;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Start/stop control over the poll loops, handed to a leadership gate. */
|
|
79
|
+
export interface InboxPollingControl {
|
|
80
|
+
/** Seed one poll and arm the loops. Resolves once polling has begun. */
|
|
81
|
+
start(): Promise<void>;
|
|
82
|
+
/** Disarm the loops. Resolves once no further poll can run. */
|
|
83
|
+
stop(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
export interface RegisterInboxOptions {
|
|
79
87
|
/** Override the cursor-store filename (tests). */
|
|
80
88
|
storeFileName?: string;
|
|
@@ -82,6 +90,26 @@ export interface RegisterInboxOptions {
|
|
|
82
90
|
skipInitialPoll?: boolean;
|
|
83
91
|
/** Register the built-in slack/discord/email adapters (default true). */
|
|
84
92
|
registerBuiltins?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Hand polling to a leadership gate instead of starting it here.
|
|
95
|
+
*
|
|
96
|
+
* When supplied, register() prepares the store and the handler but does NOT
|
|
97
|
+
* poll: the callback receives start/stop control and something else decides
|
|
98
|
+
* when this node is the one that should be fetching. When absent the loops
|
|
99
|
+
* start immediately, which is the behaviour every existing caller and test
|
|
100
|
+
* relies on.
|
|
101
|
+
*
|
|
102
|
+
* The READ path is never gated. `channels.inbox.list` serves the persisted
|
|
103
|
+
* feed on every node — a node that is not fetching still answers questions
|
|
104
|
+
* about what has already arrived.
|
|
105
|
+
*
|
|
106
|
+
* Called once PER PROVIDER, with that provider's id and a control that
|
|
107
|
+
* starts and stops only its loop. Each inbox account is its own surface in
|
|
108
|
+
* the LAN election, so the machine reading the work Slack account need not
|
|
109
|
+
* be the machine reading the mailbox — and handing one account over must not
|
|
110
|
+
* take the others down with it.
|
|
111
|
+
*/
|
|
112
|
+
gatePolling?: (providerId: string, control: InboxPollingControl) => void;
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
function registerBuiltinAdapters(): void {
|
|
@@ -176,11 +204,14 @@ export function registerInboxMethods(
|
|
|
176
204
|
});
|
|
177
205
|
const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
|
|
178
206
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// (
|
|
207
|
+
const gated = options.gatePolling !== undefined;
|
|
208
|
+
|
|
209
|
+
// Async bootstrap: init store, and (ungated) seed one poll and start loops.
|
|
210
|
+
// Failures are logged but never thrown out of register() — the handler still
|
|
211
|
+
// serves the (possibly empty) persisted feed.
|
|
182
212
|
const ready: Promise<void> = (async () => {
|
|
183
213
|
await store.init();
|
|
214
|
+
if (gated) return;
|
|
184
215
|
if (!options.skipInitialPoll) {
|
|
185
216
|
await poller.pollOnce();
|
|
186
217
|
}
|
|
@@ -191,6 +222,35 @@ export function registerInboxMethods(
|
|
|
191
222
|
});
|
|
192
223
|
});
|
|
193
224
|
|
|
225
|
+
const gate = options.gatePolling;
|
|
226
|
+
if (gate) {
|
|
227
|
+
// One gate per provider, not one for the poller. Leadership is decided per
|
|
228
|
+
// inbox account, so each account's loop has to be startable and stoppable
|
|
229
|
+
// on its own — otherwise handing one account to another machine would stop
|
|
230
|
+
// fetching for every account this node reads.
|
|
231
|
+
for (const providerId of poller.providerIds()) {
|
|
232
|
+
gate(providerId, {
|
|
233
|
+
start: async () => {
|
|
234
|
+
// The store must be ready before the first fetch, or the seed poll
|
|
235
|
+
// would write cursors into an uninitialised store.
|
|
236
|
+
await ready;
|
|
237
|
+
if (!options.skipInitialPoll) {
|
|
238
|
+
await poller.pollProviderOnce(providerId);
|
|
239
|
+
}
|
|
240
|
+
poller.startProvider(providerId);
|
|
241
|
+
},
|
|
242
|
+
// Synchronous underneath: it clears that provider's interval, so once
|
|
243
|
+
// it returns no further poll of that account can be scheduled.
|
|
244
|
+
// Declared async because the gate contract promises "resolves when
|
|
245
|
+
// consumption has ceased", and a future adapter with an in-flight
|
|
246
|
+
// request would need to await it.
|
|
247
|
+
stop: async () => {
|
|
248
|
+
poller.stopProvider(providerId);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
194
254
|
const unregisterMethod = registerCatalogHandler<InboxListInput, InboxListOutput>(
|
|
195
255
|
ctx.catalog,
|
|
196
256
|
INBOX_LIST_METHOD_ID,
|
|
@@ -68,14 +68,53 @@ export class InboundPoller {
|
|
|
68
68
|
start(): void {
|
|
69
69
|
if (this.started) return;
|
|
70
70
|
this.started = true;
|
|
71
|
-
for (const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
for (const id of this.adapters.keys()) this.startProvider(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Begin ONE provider's interval loop. Idempotent.
|
|
76
|
+
*
|
|
77
|
+
* Each inbox account is its own surface in the LAN election, so the machine
|
|
78
|
+
* that reads the work Slack account may not be the machine that reads the
|
|
79
|
+
* mailbox. A blanket start/stop cannot express that: it would take every
|
|
80
|
+
* account down to hand one of them over. Everything below is therefore
|
|
81
|
+
* addressable per provider, and the blanket calls are fan-outs over it.
|
|
82
|
+
*/
|
|
83
|
+
startProvider(id: string): void {
|
|
84
|
+
if (this.timers.has(id)) return;
|
|
85
|
+
const adapter = this.adapters.get(id);
|
|
86
|
+
if (!adapter) return;
|
|
87
|
+
const handle = this.setIntervalImpl(() => {
|
|
88
|
+
void this.pollProvider(id, adapter);
|
|
89
|
+
}, adapter.pollIntervalMs);
|
|
90
|
+
// Do not keep the event loop alive solely for polling (Bun/Node unref).
|
|
91
|
+
(handle as unknown as { unref?: () => void }).unref?.();
|
|
92
|
+
this.timers.set(id, handle);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stop ONE provider's interval loop. Idempotent.
|
|
97
|
+
*
|
|
98
|
+
* Synchronous underneath — clearing the interval means no further tick can
|
|
99
|
+
* be scheduled — which is what lets the RESIGN that follows a handoff be an
|
|
100
|
+
* honest claim that this node has stopped reading that account.
|
|
101
|
+
*/
|
|
102
|
+
stopProvider(id: string): void {
|
|
103
|
+
const handle = this.timers.get(id);
|
|
104
|
+
if (handle === undefined) return;
|
|
105
|
+
this.clearIntervalImpl(handle);
|
|
106
|
+
this.timers.delete(id);
|
|
107
|
+
if (this.timers.size === 0) this.started = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when this node is polling the given provider right now. */
|
|
111
|
+
isProviderRunning(id: string): boolean {
|
|
112
|
+
return this.timers.has(id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every provider this node has an adapter for. */
|
|
116
|
+
providerIds(): string[] {
|
|
117
|
+
return [...this.adapters.keys()];
|
|
79
118
|
}
|
|
80
119
|
|
|
81
120
|
/** Run a single poll across all providers now (used on register + tests). */
|
|
@@ -85,6 +124,13 @@ export class InboundPoller {
|
|
|
85
124
|
);
|
|
86
125
|
}
|
|
87
126
|
|
|
127
|
+
/** Run a single poll for ONE provider now. Never throws. */
|
|
128
|
+
async pollProviderOnce(id: string): Promise<void> {
|
|
129
|
+
const adapter = this.adapters.get(id);
|
|
130
|
+
if (!adapter) return;
|
|
131
|
+
await this.pollProvider(id, adapter);
|
|
132
|
+
}
|
|
133
|
+
|
|
88
134
|
/** Poll a single provider, dedup + persist, update status. Never throws. */
|
|
89
135
|
async pollProvider(id: string, adapter: InboundProviderAdapter): Promise<void> {
|
|
90
136
|
if (this.inFlight.has(id)) return; // skip overlapping ticks
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CommandRegistry } from './command-registry.ts';
|
|
2
2
|
import { SESSION_SUBCOMMAND_ARG_HINTS } from './commands/session.ts';
|
|
3
|
+
import { CLUSTER_SUBCOMMAND_ARG_HINTS } from './commands/cluster-runtime.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Per-command subcommand → argument-hint maps. `session`'s entries come
|
|
@@ -12,6 +13,7 @@ import { SESSION_SUBCOMMAND_ARG_HINTS } from './commands/session.ts';
|
|
|
12
13
|
*/
|
|
13
14
|
const SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
|
|
14
15
|
session: SESSION_SUBCOMMAND_ARG_HINTS,
|
|
16
|
+
cluster: CLUSTER_SUBCOMMAND_ARG_HINTS,
|
|
15
17
|
template: { save: '<name>', use: '<name> [args]', edit: '<name>', delete: '<name>' },
|
|
16
18
|
secrets: { set: '<KEY> <value>', get: '<KEY>', delete: '<KEY>' },
|
|
17
19
|
permissions: { tool: '<name> allow|prompt|deny' },
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cluster-runtime.ts — `/cluster` in the TUI.
|
|
3
|
+
*
|
|
4
|
+
* The same operations as the `goodvibes-daemon cluster` subcommands, reaching
|
|
5
|
+
* the same daemon verbs through the same caller. There is no cluster logic in
|
|
6
|
+
* this file and there must never be any: if `/cluster status` and
|
|
7
|
+
* `cluster status` could disagree, one of them would be lying to somebody.
|
|
8
|
+
*
|
|
9
|
+
* The one honest difference is `join` without arguments. The CLI can stop and
|
|
10
|
+
* ask which group and for the join key; a slash command has nowhere to ask, so
|
|
11
|
+
* it requires `--group` and `--key` and says so — which is exactly what the
|
|
12
|
+
* shared caller already reports for any non-interactive invocation.
|
|
13
|
+
*/
|
|
14
|
+
import { resolveDaemonHomeDir } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
15
|
+
import { runClusterCommand, CLUSTER_SUBCOMMANDS } from '../../cluster/commands.ts';
|
|
16
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
17
|
+
|
|
18
|
+
/** Subcommand → argument hint, surfaced as the operator types. */
|
|
19
|
+
export const CLUSTER_SUBCOMMAND_ARG_HINTS: Record<string, string> = {
|
|
20
|
+
status: '',
|
|
21
|
+
create: '[--name "<group name>"] [--passphrase "<phrase>"]',
|
|
22
|
+
join: '--group <id> --key <join key>',
|
|
23
|
+
key: '',
|
|
24
|
+
nodes: '',
|
|
25
|
+
forget: '<machine>',
|
|
26
|
+
rotate: '[--now]',
|
|
27
|
+
leave: '',
|
|
28
|
+
rename: '"<new name>"',
|
|
29
|
+
groups: '',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function registerClusterRuntimeCommands(registry: CommandRegistry): void {
|
|
33
|
+
registry.register({
|
|
34
|
+
name: 'cluster',
|
|
35
|
+
description: 'Share inbound channel work with your other goodvibes machines on this network',
|
|
36
|
+
usage: `[${CLUSTER_SUBCOMMANDS.join('|')}] — status is the default; `
|
|
37
|
+
+ 'add --host/--port/--token to reach a daemon on another machine',
|
|
38
|
+
argsHint: `[${CLUSTER_SUBCOMMANDS.join('|')}]`,
|
|
39
|
+
async handler(args, ctx) {
|
|
40
|
+
const argv = args.length === 0 ? ['status'] : args;
|
|
41
|
+
const result = await runClusterCommand({
|
|
42
|
+
argv,
|
|
43
|
+
configManager: ctx.platform.configManager,
|
|
44
|
+
daemonHomeDir: resolveDaemonHomeDir(),
|
|
45
|
+
// A transcript is not a terminal for clipboard purposes: writing an
|
|
46
|
+
// OSC 52 escape into the rendered conversation would put a control
|
|
47
|
+
// sequence in the scrollback rather than on anyone's clipboard.
|
|
48
|
+
isTerminal: false,
|
|
49
|
+
});
|
|
50
|
+
ctx.print(result.lines.join('\n'));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/input/commands.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { registerServicesRuntimeCommands } from './commands/services-runtime.ts'
|
|
|
56
56
|
import { registerTasksRuntimeCommands } from './commands/tasks-runtime.ts';
|
|
57
57
|
import { registerLocalProviderRuntimeCommands } from './commands/local-provider-runtime.ts';
|
|
58
58
|
import { registerHealthRuntimeCommands } from './commands/health-runtime.ts';
|
|
59
|
+
import { registerClusterRuntimeCommands } from './commands/cluster-runtime.ts';
|
|
59
60
|
import { registerSettingsSyncRuntimeCommands } from './commands/settings-sync-runtime.ts';
|
|
60
61
|
import { registerWorktreeRuntimeCommands } from './commands/worktree-runtime.ts';
|
|
61
62
|
import { registerAcpRuntimeCommands } from './commands/acp-runtime.ts';
|
|
@@ -139,6 +140,7 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
|
|
|
139
140
|
{ category: 'Tasks', register: registerTasksRuntimeCommands },
|
|
140
141
|
{ category: 'Local Providers', register: registerLocalProviderRuntimeCommands },
|
|
141
142
|
{ category: 'Health', register: registerHealthRuntimeCommands },
|
|
143
|
+
{ category: 'Cluster', register: registerClusterRuntimeCommands },
|
|
142
144
|
{ category: 'Settings Sync', register: registerSettingsSyncRuntimeCommands },
|
|
143
145
|
{ category: 'Worktrees', register: registerWorktreeRuntimeCommands },
|
|
144
146
|
{ category: 'Fleet', register: registerAcpRuntimeCommands },
|