@pellux/goodvibes-tui 1.1.0 → 1.7.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 +85 -21
- package/README.md +20 -11
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/core/alert-gating.ts +67 -0
- package/src/core/approval-alert.ts +72 -0
- package/src/core/budget-breach-notifier.ts +106 -0
- package/src/core/conversation-rendering.ts +19 -0
- package/src/core/conversation.ts +18 -0
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/long-task-notifier.ts +33 -6
- package/src/core/system-message-router.ts +37 -51
- package/src/core/turn-cancellation.ts +20 -0
- package/src/core/turn-event-wiring.ts +64 -3
- package/src/export/cost-utils.ts +36 -0
- package/src/input/command-registry.ts +38 -1
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +192 -0
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +1 -1
- package/src/input/commands/image-runtime.ts +112 -0
- package/src/input/commands/intelligence-runtime.ts +11 -1
- package/src/input/commands/local-auth-runtime.ts +4 -1
- package/src/input/commands/local-runtime.ts +9 -3
- package/src/input/commands/marketplace-runtime.ts +2 -2
- package/src/input/commands/memory.ts +6 -1
- package/src/input/commands/operator-panel-runtime.ts +40 -24
- package/src/input/commands/planning-runtime.ts +26 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -6
- package/src/input/commands/plugin-runtime.ts +2 -2
- package/src/input/commands/policy-dispatch.ts +21 -0
- package/src/input/commands/provider-accounts-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +3 -3
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime.ts +3 -3
- package/src/input/commands/runtime-services.ts +54 -13
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +16 -1
- package/src/input/commands/settings-sync-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +15 -7
- package/src/input/commands/skills-runtime.ts +2 -8
- package/src/input/commands/subscription-runtime.ts +2 -2
- package/src/input/commands/test-runtime.ts +277 -0
- package/src/input/commands/websearch-runtime.ts +101 -0
- package/src/input/commands/work-plan-runtime.ts +36 -10
- package/src/input/commands/workstream-runtime.ts +338 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +161 -0
- package/src/input/config-modal.ts +377 -0
- package/src/input/feed-context-factory.ts +7 -8
- package/src/input/handler-command-route.ts +27 -17
- package/src/input/handler-feed-routes.ts +61 -23
- package/src/input/handler-feed.ts +47 -23
- package/src/input/handler-interactions.ts +1 -3
- package/src/input/handler-modal-routes.ts +65 -0
- package/src/input/handler-modal-stack.ts +3 -5
- package/src/input/handler-modal-token-routes.ts +16 -49
- package/src/input/handler-picker-routes.ts +11 -94
- package/src/input/handler-shortcuts.ts +24 -14
- package/src/input/handler-types.ts +2 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +6 -28
- package/src/input/keybindings.ts +22 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +58 -0
- package/src/input/panel-integration-actions.ts +9 -170
- package/src/input/settings-modal-data.ts +95 -0
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/main.ts +24 -27
- package/src/panels/agent-inspector-shared.ts +2 -1
- package/src/panels/base-panel.ts +22 -1
- package/src/panels/builtin/agent.ts +21 -107
- package/src/panels/builtin/development.ts +15 -61
- package/src/panels/builtin/knowledge.ts +8 -32
- package/src/panels/builtin/operations.ts +84 -428
- package/src/panels/builtin/session.ts +21 -112
- package/src/panels/builtin/shared.ts +9 -43
- package/src/panels/builtin-modals.ts +218 -0
- package/src/panels/builtin-panels.ts +5 -0
- package/src/panels/cost-tracker-panel.ts +36 -10
- package/src/panels/diff-panel.ts +12 -43
- package/src/panels/eval-registry.ts +60 -0
- package/src/panels/fleet-panel.ts +800 -0
- package/src/panels/fleet-read-model.ts +477 -0
- package/src/panels/fleet-steer.ts +92 -0
- package/src/panels/fleet-stop.ts +125 -0
- package/src/panels/fleet-tabs.ts +230 -0
- package/src/panels/fleet-transcript.ts +356 -0
- package/src/panels/index.ts +7 -31
- package/src/panels/modals/hooks-modal.ts +187 -0
- package/src/panels/modals/keybindings-modal.ts +151 -0
- package/src/panels/modals/knowledge-modal.ts +158 -0
- package/src/panels/modals/local-auth-modal.ts +132 -0
- package/src/panels/modals/marketplace-modal.ts +218 -0
- package/src/panels/modals/memory-modal.ts +180 -0
- package/src/panels/modals/modal-surface-helpers.ts +54 -0
- package/src/panels/modals/modal-theme.ts +36 -0
- package/src/panels/modals/pairing-modal.ts +144 -0
- package/src/panels/modals/planning-modal.ts +282 -0
- package/src/panels/modals/plugins-modal.ts +174 -0
- package/src/panels/modals/policy-modal.ts +256 -0
- package/src/panels/modals/provider-health-modal.ts +136 -0
- package/src/panels/modals/remote-modal.ts +115 -0
- package/src/panels/modals/sandbox-modal.ts +150 -0
- package/src/panels/modals/security-modal.ts +217 -0
- package/src/panels/modals/services-modal.ts +179 -0
- package/src/panels/modals/settings-sync-modal.ts +142 -0
- package/src/panels/modals/skills-modal.ts +189 -0
- package/src/panels/modals/subscription-modal.ts +172 -0
- package/src/panels/modals/work-plan-modal.ts +149 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +108 -5
- package/src/panels/project-planning-answer-actions.ts +4 -2
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/types.ts +13 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +60 -4
- package/src/renderer/compaction-history-modal.ts +19 -3
- package/src/renderer/compaction-preview.ts +13 -2
- package/src/renderer/compaction-quality.ts +100 -0
- package/src/renderer/config-modal.ts +81 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +10 -2
- package/src/renderer/git-status.ts +40 -0
- package/src/renderer/help-overlay.ts +2 -2
- package/src/renderer/model-workspace.ts +44 -1
- package/src/renderer/panel-workspace-bar.ts +8 -2
- package/src/renderer/turn-injection.ts +114 -0
- package/src/runtime/bootstrap-command-context.ts +21 -1
- package/src/runtime/bootstrap-command-parts.ts +34 -7
- package/src/runtime/bootstrap-core.ts +14 -4
- package/src/runtime/bootstrap-shell.ts +29 -16
- package/src/runtime/bootstrap.ts +11 -17
- package/src/runtime/code-index-services.ts +112 -0
- package/src/runtime/orchestrator-core-services.ts +49 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +91 -43
- package/src/runtime/ui-services.ts +8 -0
- package/src/runtime/workstream-services.ts +195 -0
- package/src/shell/blocking-input.ts +22 -1
- package/src/shell/ui-openers.ts +129 -17
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -786
- package/src/panels/approval-panel.ts +0 -252
- package/src/panels/automation-control-panel.ts +0 -479
- package/src/panels/cockpit-panel.ts +0 -481
- package/src/panels/cockpit-read-model.ts +0 -238
- package/src/panels/communication-panel.ts +0 -256
- package/src/panels/control-plane-panel.ts +0 -470
- package/src/panels/debug-panel.ts +0 -615
- package/src/panels/docs-panel.ts +0 -384
- package/src/panels/eval-panel.ts +0 -627
- package/src/panels/file-explorer-panel.ts +0 -673
- package/src/panels/file-preview-panel.ts +0 -517
- package/src/panels/hooks-panel.ts +0 -401
- package/src/panels/incident-review-panel.ts +0 -406
- package/src/panels/intelligence-panel.ts +0 -383
- package/src/panels/knowledge-graph-panel.ts +0 -515
- package/src/panels/marketplace-panel.ts +0 -399
- package/src/panels/memory-panel.ts +0 -558
- package/src/panels/ops-control-panel.ts +0 -329
- package/src/panels/ops-strategy-panel.ts +0 -321
- package/src/panels/orchestration-panel.ts +0 -465
- package/src/panels/panel-list-panel.ts +0 -566
- package/src/panels/plan-dashboard-panel.ts +0 -707
- package/src/panels/policy-panel.ts +0 -517
- package/src/panels/project-planning-panel.ts +0 -731
- package/src/panels/provider-health-panel.ts +0 -678
- package/src/panels/provider-health-tracker.ts +0 -310
- package/src/panels/provider-health-views.ts +0 -567
- package/src/panels/qr-panel.ts +0 -280
- package/src/panels/remote-panel.ts +0 -534
- package/src/panels/routes-panel.ts +0 -241
- package/src/panels/sandbox-panel.ts +0 -456
- package/src/panels/security-panel.ts +0 -447
- package/src/panels/services-panel.ts +0 -329
- package/src/panels/session-browser-panel.ts +0 -496
- package/src/panels/settings-sync-panel.ts +0 -398
- package/src/panels/subscription-panel.ts +0 -342
- package/src/panels/symbol-outline-panel.ts +0 -619
- package/src/panels/system-messages-panel.ts +0 -364
- package/src/panels/tasks-panel.ts +0 -608
- package/src/panels/thinking-panel.ts +0 -333
- package/src/panels/tool-inspector-panel.ts +0 -537
- package/src/panels/work-plan-panel.ts +0 -540
- package/src/panels/worktree-panel.ts +0 -360
- package/src/panels/wrfc-panel.ts +0 -790
- package/src/renderer/agent-detail-modal.ts +0 -466
- package/src/renderer/live-tail-modal.ts +0 -156
- package/src/renderer/process-modal.ts +0 -671
- package/src/renderer/qr-renderer.ts +0 -120
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// fleet-read-model.ts
|
|
3
|
+
//
|
|
4
|
+
// W2.2 (Fleet tree panel) — pure, testable read-model over the SDK's live
|
|
5
|
+
// process registry (@pellux/goodvibes-sdk/platform/runtime/fleet, landed on
|
|
6
|
+
// SDK main as W2.1). No BasePanel/rendering dependency: this module owns the
|
|
7
|
+
// flat ProcessNode[] -> tree transformation, the honest cost/token
|
|
8
|
+
// aggregates, and the state->glyph/tone mapping that FleetPanel renders.
|
|
9
|
+
//
|
|
10
|
+
// Design notes:
|
|
11
|
+
// - The W2.2 brief predates the real SDK API and sketched a bespoke
|
|
12
|
+
// FleetRegistryNode/FleetRegistry adapter pair. The real
|
|
13
|
+
// createProcessRegistry() ProcessNode already carries elapsedMs,
|
|
14
|
+
// usage, costUsd, costState, and currentActivity directly, so no
|
|
15
|
+
// adapter layer is needed here — this module consumes ProcessNode
|
|
16
|
+
// straight from the registry and only owns the tree-walk + aggregate
|
|
17
|
+
// + presentation-mapping logic the SDK does not provide.
|
|
18
|
+
// - Tree-walk shape (connectors, cycle guard, leftover pass) is ported
|
|
19
|
+
// from renderer/process-modal.ts's appendAgentSubtree/
|
|
20
|
+
// appendAgentGroupEntries, generalized from AgentRecord to ProcessNode
|
|
21
|
+
// and from WRFC-role ordering to plain startedAt ordering (ProcessNode
|
|
22
|
+
// has no role concept — parentId alone expresses the hierarchy, and the
|
|
23
|
+
// SDK guarantees every parentId either resolves or the node is a root).
|
|
24
|
+
// - Cost/token honesty mirrors agent-inspector-shared.ts's
|
|
25
|
+
// hasReportedUsage convention: an all-zero-but-present usage object is
|
|
26
|
+
// treated as "no data" (never a fabricated 0/$0.00), and aggregates are
|
|
27
|
+
// null only when NO node in the snapshot has real data.
|
|
28
|
+
// - Two-factory shape (live/static) mirrors cockpit-read-model.ts.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import type {
|
|
32
|
+
ProcessCostState,
|
|
33
|
+
ProcessKind,
|
|
34
|
+
ProcessNode,
|
|
35
|
+
ProcessRegistry,
|
|
36
|
+
ProcessState,
|
|
37
|
+
ProcessUsage,
|
|
38
|
+
SteerResult,
|
|
39
|
+
} from '@pellux/goodvibes-sdk/platform/runtime/fleet';
|
|
40
|
+
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Public types
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** One row in the flattened, depth-first fleet tree. */
|
|
47
|
+
export interface FleetTreeRow {
|
|
48
|
+
readonly node: ProcessNode;
|
|
49
|
+
readonly depth: number;
|
|
50
|
+
/** Tree-drawing prefix ('├─ ' / '└─ ' / '│ ' / ' ' segments), '' at depth 0. */
|
|
51
|
+
readonly treePrefix: string;
|
|
52
|
+
readonly isLastChild: boolean;
|
|
53
|
+
readonly hasChildren: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Aggregate snapshot the fleet panel renders from. */
|
|
57
|
+
export interface FleetSnapshot {
|
|
58
|
+
readonly rows: readonly FleetTreeRow[];
|
|
59
|
+
/** Sum of costUsd across nodes with a real (non-unpriced) reading; null when none do. */
|
|
60
|
+
readonly totalCost: number | null;
|
|
61
|
+
/** Sum of reported usage tokens across nodes with real usage; null when none do. */
|
|
62
|
+
readonly totalTokens: number | null;
|
|
63
|
+
/** Count of nodes in an actively-working state (see isRunningProcessState). */
|
|
64
|
+
readonly runningCount: number;
|
|
65
|
+
readonly capturedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// State classification — glyph/tone mapping (pure, unit-testable)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export type FleetStateTone = 'active' | 'success' | 'failure' | 'warn' | 'muted';
|
|
73
|
+
|
|
74
|
+
const STATE_GLYPHS: Record<ProcessState, string> = {
|
|
75
|
+
thinking: '◔',
|
|
76
|
+
'executing-tool': '●',
|
|
77
|
+
'awaiting-approval': '◐',
|
|
78
|
+
streaming: '◕',
|
|
79
|
+
stalled: '◒',
|
|
80
|
+
retrying: '↻',
|
|
81
|
+
done: '✓',
|
|
82
|
+
failed: '✗',
|
|
83
|
+
killed: '⊘',
|
|
84
|
+
// Wave-3 verb formalization: a distinct TERMINAL outcome from 'killed' —
|
|
85
|
+
// both come from AgentManager.cancel(), but a graceful interrupt request
|
|
86
|
+
// ('the operator asked nicely') is display-distinguishable from a hard
|
|
87
|
+
// kill. '◌' verified free against every other glyph in this table.
|
|
88
|
+
interrupted: '◌',
|
|
89
|
+
idle: '·',
|
|
90
|
+
queued: '…',
|
|
91
|
+
// Wave-6 SDK: schedules/triggers/automation jobs report 'paused' when
|
|
92
|
+
// disabled (previously mislabeled 'killed'). NOT terminal — resumable via
|
|
93
|
+
// ProcessRegistry.resume() (W6.2 d2 wires the full pause/resume UI in
|
|
94
|
+
// fleet-stop.ts). '❚' verified free against every other glyph in this table.
|
|
95
|
+
paused: '❚',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const STATE_TONES: Record<ProcessState, FleetStateTone> = {
|
|
99
|
+
thinking: 'active',
|
|
100
|
+
'executing-tool': 'active',
|
|
101
|
+
streaming: 'active',
|
|
102
|
+
'awaiting-approval': 'warn',
|
|
103
|
+
stalled: 'warn',
|
|
104
|
+
retrying: 'warn',
|
|
105
|
+
done: 'success',
|
|
106
|
+
failed: 'failure',
|
|
107
|
+
killed: 'muted',
|
|
108
|
+
// 'warn' (amber), not 'muted' like killed — an interrupt is a deliberate
|
|
109
|
+
// operator action worth noticing, not a passive/neutral outcome.
|
|
110
|
+
interrupted: 'warn',
|
|
111
|
+
idle: 'muted',
|
|
112
|
+
queued: 'muted',
|
|
113
|
+
// Deliberately parked by the operator: neutral, not alarming.
|
|
114
|
+
paused: 'muted',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Terminal states — interrupt/kill are not offered; not counted as running. */
|
|
118
|
+
const TERMINAL_STATES = new Set<ProcessState>(['done', 'failed', 'killed', 'interrupted']);
|
|
119
|
+
|
|
120
|
+
/** States representing actively-working nodes (drives runningCount + follow target). */
|
|
121
|
+
const RUNNING_STATES = new Set<ProcessState>([
|
|
122
|
+
'thinking', 'executing-tool', 'awaiting-approval', 'streaming', 'stalled', 'retrying',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const KIND_TAGS: Record<ProcessKind, string> = {
|
|
126
|
+
agent: 'agent',
|
|
127
|
+
'wrfc-chain': 'wrfc',
|
|
128
|
+
'wrfc-subtask': 'wrfc·sub',
|
|
129
|
+
workflow: 'flow',
|
|
130
|
+
trigger: 'trig',
|
|
131
|
+
schedule: 'sched',
|
|
132
|
+
watcher: 'watch',
|
|
133
|
+
'background-process': 'exec',
|
|
134
|
+
// Wave 4 (wo703): orchestration-engine kinds (adapters/orchestration.ts).
|
|
135
|
+
workstream: 'stream',
|
|
136
|
+
phase: 'phase',
|
|
137
|
+
'work-item': 'item',
|
|
138
|
+
// Wave 5 (wo804): the repo source-tree code index (adapters/code-index.ts).
|
|
139
|
+
// A single leaf node — never a rollup of other flat-list nodes (see
|
|
140
|
+
// ROLLUP_KINDS below) — so no other list in this module needs updating.
|
|
141
|
+
'code-index': 'index',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export function fleetStateGlyph(state: ProcessState): string {
|
|
145
|
+
return STATE_GLYPHS[state] ?? '?';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function fleetStateTone(state: ProcessState): FleetStateTone {
|
|
149
|
+
return STATE_TONES[state] ?? 'muted';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function fleetKindTag(kind: ProcessKind): string {
|
|
153
|
+
return KIND_TAGS[kind] ?? kind;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isTerminalProcessState(state: ProcessState): boolean {
|
|
157
|
+
return TERMINAL_STATES.has(state);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isRunningProcessState(state: ProcessState): boolean {
|
|
161
|
+
return RUNNING_STATES.has(state);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Honest usage/cost helpers (W0.9 convention, ported from agent-inspector-shared.ts)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* True when a ProcessUsage carries real reported token data rather than
|
|
170
|
+
* being present-but-all-zero. Mirrors hasReportedUsage() in
|
|
171
|
+
* agent-inspector-shared.ts, applied to the SDK's ProcessUsage shape.
|
|
172
|
+
*/
|
|
173
|
+
export function hasFleetUsage(usage: ProcessUsage | undefined): usage is ProcessUsage {
|
|
174
|
+
if (!usage) return false;
|
|
175
|
+
return usage.inputTokens > 0 || usage.outputTokens > 0
|
|
176
|
+
|| usage.cacheReadTokens > 0 || usage.cacheWriteTokens > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Total token count (input + output + cache) for a node's usage, or null when no real usage has landed. */
|
|
180
|
+
export function fleetUsageTokens(usage: ProcessUsage | undefined): number | null {
|
|
181
|
+
if (!hasFleetUsage(usage)) return null;
|
|
182
|
+
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether a node's costUsd is a real reading, never a fabricated placeholder. */
|
|
186
|
+
export function hasFleetCost(costUsd: number | null | undefined, costState: ProcessCostState): boolean {
|
|
187
|
+
return costState !== 'unpriced' && typeof costUsd === 'number';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Tree builder — pure, testable (ported tree-walk shape from process-modal.ts)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function compareNodes(a: ProcessNode, b: ProcessNode): number {
|
|
195
|
+
const delta = (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity);
|
|
196
|
+
if (delta !== 0) return delta;
|
|
197
|
+
return a.id.localeCompare(b.id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function appendSubtree(
|
|
201
|
+
rows: FleetTreeRow[],
|
|
202
|
+
node: ProcessNode,
|
|
203
|
+
childrenByParent: Map<string, ProcessNode[]>,
|
|
204
|
+
depth: number,
|
|
205
|
+
ancestorPrefix: string,
|
|
206
|
+
isLast: boolean,
|
|
207
|
+
visited: Set<string>,
|
|
208
|
+
): void {
|
|
209
|
+
if (visited.has(node.id)) return; // cycle guard
|
|
210
|
+
visited.add(node.id);
|
|
211
|
+
|
|
212
|
+
const children = (childrenByParent.get(node.id) ?? []).slice().sort(compareNodes);
|
|
213
|
+
const treePrefix = depth === 0 ? '' : `${ancestorPrefix}${isLast ? '└─ ' : '├─ '}`;
|
|
214
|
+
rows.push({ node, depth, treePrefix, isLastChild: isLast, hasChildren: children.length > 0 });
|
|
215
|
+
|
|
216
|
+
const descendantPrefix = depth === 0 ? '' : `${ancestorPrefix}${isLast ? ' ' : '│ '}`;
|
|
217
|
+
children.forEach((child, index) => {
|
|
218
|
+
appendSubtree(rows, child, childrenByParent, depth + 1, descendantPrefix, index === children.length - 1, visited);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Flatten a ProcessNode[] into a stable depth-first FleetTreeRow[] list.
|
|
224
|
+
* Root-level rows (depth 0) carry an empty treePrefix (multi-root forest —
|
|
225
|
+
* each top-level process family renders without a connector to its
|
|
226
|
+
* unrelated siblings); connectors appear starting at depth 1. Defensive
|
|
227
|
+
* cycle guard: a self-referencing or looping parentId chain is walked once
|
|
228
|
+
* via the leftover pass below rather than recursing forever (the SDK
|
|
229
|
+
* guarantees every parentId resolves or the node is a root, so this is
|
|
230
|
+
* belt-and-suspenders, not a supported shape).
|
|
231
|
+
*/
|
|
232
|
+
export function buildFleetRows(nodes: readonly ProcessNode[]): FleetTreeRow[] {
|
|
233
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
234
|
+
const childrenByParent = new Map<string, ProcessNode[]>();
|
|
235
|
+
const roots: ProcessNode[] = [];
|
|
236
|
+
|
|
237
|
+
for (const node of nodes) {
|
|
238
|
+
if (node.parentId && byId.has(node.parentId)) {
|
|
239
|
+
const siblings = childrenByParent.get(node.parentId) ?? [];
|
|
240
|
+
siblings.push(node);
|
|
241
|
+
childrenByParent.set(node.parentId, siblings);
|
|
242
|
+
} else {
|
|
243
|
+
roots.push(node);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const rows: FleetTreeRow[] = [];
|
|
248
|
+
const visited = new Set<string>();
|
|
249
|
+
|
|
250
|
+
for (const root of roots.slice().sort(compareNodes)) {
|
|
251
|
+
appendSubtree(rows, root, childrenByParent, 0, '', true, visited);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Defensive: nodes never reached because their entire ancestor chain forms
|
|
255
|
+
// a cycle with no true root. Walked as pseudo-roots so they still render.
|
|
256
|
+
const leftovers = nodes.filter((n) => !visited.has(n.id)).sort(compareNodes);
|
|
257
|
+
for (const node of leftovers) {
|
|
258
|
+
appendSubtree(rows, node, childrenByParent, 0, '', true, visited);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return rows;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Kinds whose usage/costUsd (and, for 'running', whose derived state) are the
|
|
266
|
+
* SDK's OWN rollup of *other* nodes that ALSO appear individually in the flat
|
|
267
|
+
* list — summing across every flat node would therefore double-count them.
|
|
268
|
+
* Per registry.ts assemble() + wrfc.ts adaptChain/adaptSubtask:
|
|
269
|
+
* - 'wrfc-chain': usage/costUsd = sum over the chain's member agents
|
|
270
|
+
* (chain.allAgentIds minus the owner) via adaptChain's aggregateCost/
|
|
271
|
+
* sumUsage — those member agents are pushed onto the flat node list as
|
|
272
|
+
* their own 'agent' nodes too. The chain's derived ProcessState ('running'
|
|
273
|
+
* while any phase is active) likewise just mirrors its members' states.
|
|
274
|
+
* - 'wrfc-subtask': never carries usage/cost (adaptSubtask always sets
|
|
275
|
+
* usage: undefined, costUsd: null, costState: 'unpriced') and its
|
|
276
|
+
* 'running' phase state mirrors whichever agent is currently working the
|
|
277
|
+
* subtask. Excluded for the same "grouping construct, not an actor"
|
|
278
|
+
* reason as the chain, even though today it can never contribute a
|
|
279
|
+
* nonzero cost/token reading (defense in depth against a future adapter
|
|
280
|
+
* change, not load-bearing).
|
|
281
|
+
*
|
|
282
|
+
* Wave 4 (wo703) additions — verified against adapters/orchestration.ts:
|
|
283
|
+
* - 'workstream': adaptWorkstream SUMS every work-item's usage/costUsd
|
|
284
|
+
* exactly once (sumWorkItemUsage/aggregateWorkItemCost), the same
|
|
285
|
+
* "rollup of nodes that also appear individually" shape as adaptChain —
|
|
286
|
+
* each work item ALSO appears as its own 'work-item' node in the flat
|
|
287
|
+
* list, so summing over every flat node would double-count. Its derived
|
|
288
|
+
* state likewise mirrors its items' states, so it is excluded from
|
|
289
|
+
* runningCount for the same non-double-counting reason as 'wrfc-chain'.
|
|
290
|
+
* - 'phase': adaptPhase reports NO usage/cost (mirrors adaptSubtask's
|
|
291
|
+
* "report nothing" choice exactly — usage: undefined, costUsd: null,
|
|
292
|
+
* costState: 'unpriced') and its 'running' state mirrors whichever
|
|
293
|
+
* work-item currently occupies it. Same defense-in-depth inclusion as
|
|
294
|
+
* 'wrfc-subtask': never contributes today, excluded anyway in case a
|
|
295
|
+
* future adapter change gives it one.
|
|
296
|
+
* - 'work-item' is deliberately NOT in this set: unlike a phase, a work
|
|
297
|
+
* item carries its OWN direct usage/cost (item.usage, cumulative across
|
|
298
|
+
* every phase it has visited) — it is the leaf contributor, the
|
|
299
|
+
* 'wrfc-subtask'-for-capabilities analogue but the 'agent'-for-usage
|
|
300
|
+
* analogue. Excluding it would silently zero out real cost/token totals.
|
|
301
|
+
*
|
|
302
|
+
* Wave 5 (wo804): 'code-index' is deliberately NOT in this set either —
|
|
303
|
+
* adaptCodeIndex yields exactly one standalone ProcessNode per registry
|
|
304
|
+
* (never a parent whose children ALSO appear individually in the flat
|
|
305
|
+
* list), so it is a leaf like 'agent'/'work-item', not a grouping construct.
|
|
306
|
+
* It reports no usage/cost (an index build has no LLM turn), so it never
|
|
307
|
+
* contributes to totalCost/totalTokens regardless — its running state DOES
|
|
308
|
+
* count toward runningCount while building, which is correct: it is a real,
|
|
309
|
+
* distinct unit of work, not an arithmetic sum of other rows.
|
|
310
|
+
*/
|
|
311
|
+
const ROLLUP_KINDS = new Set<ProcessKind>(['wrfc-chain', 'wrfc-subtask', 'workstream', 'phase']);
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* True for the WRFC owner agent's node specifically: an 'agent'-kind node
|
|
315
|
+
* whose source AgentRecord has wrfcRole === 'owner'. The owner runs no LLM
|
|
316
|
+
* turn itself — at chain completion the SDK backfills owner.usage from
|
|
317
|
+
* aggregateChainUsage(chain) (wrfc-controller.ts completeOwnerAgent), which
|
|
318
|
+
* is the SAME phase-children total already carried by the chain node AND by
|
|
319
|
+
* each phase-child agent's own node. Detected via the opaque `raw` field
|
|
320
|
+
* (the AgentRecord) since ProcessNode itself has no wrfcRole — this is the
|
|
321
|
+
* one place fleet-read-model reaches into `raw` for aggregation honesty
|
|
322
|
+
* rather than drill-down display.
|
|
323
|
+
*/
|
|
324
|
+
function isWrfcOwnerAgentNode(node: ProcessNode): boolean {
|
|
325
|
+
if (node.kind !== 'agent') return false;
|
|
326
|
+
const raw = node.raw;
|
|
327
|
+
return typeof raw === 'object' && raw !== null && (raw as { wrfcRole?: unknown }).wrfcRole === 'owner';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Nodes whose usage/costUsd would double-count against other nodes already
|
|
332
|
+
* in the same flat list (see ROLLUP_KINDS / isWrfcOwnerAgentNode above).
|
|
333
|
+
* Excluded from totalCost/totalTokens. NOT excluded from runningCount by
|
|
334
|
+
* this predicate alone — see isFleetRunningLeaf.
|
|
335
|
+
*/
|
|
336
|
+
function isFleetAggregateRollupNode(node: ProcessNode): boolean {
|
|
337
|
+
return ROLLUP_KINDS.has(node.kind) || isWrfcOwnerAgentNode(node);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Nodes that count toward runningCount. Excludes ROLLUP_KINDS (a running
|
|
342
|
+
* wrfc-chain/wrfc-subtask reflects the very same unit of work as its running
|
|
343
|
+
* member agent, which is already counted) but — unlike the cost/token
|
|
344
|
+
* exclusion above — does NOT exclude the WRFC owner agent: the owner is a
|
|
345
|
+
* real, distinct supervising process (not an arithmetic sum of other rows),
|
|
346
|
+
* so counting it as "running" alongside its phase children is not a double
|
|
347
|
+
* count in the way summing its rolled-up usage/cost would be.
|
|
348
|
+
*/
|
|
349
|
+
function isFleetRunningLeaf(node: ProcessNode): boolean {
|
|
350
|
+
return !ROLLUP_KINDS.has(node.kind) && isRunningProcessState(node.state);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Build the full FleetSnapshot (rows + honest aggregates) from a flat node list. */
|
|
354
|
+
export function buildFleetSnapshot(nodes: readonly ProcessNode[], capturedAt: number = Date.now()): FleetSnapshot {
|
|
355
|
+
const rows = buildFleetRows(nodes);
|
|
356
|
+
|
|
357
|
+
let totalCost: number | null = null;
|
|
358
|
+
let totalTokens: number | null = null;
|
|
359
|
+
let runningCount = 0;
|
|
360
|
+
|
|
361
|
+
for (const node of nodes) {
|
|
362
|
+
if (!isFleetAggregateRollupNode(node)) {
|
|
363
|
+
if (hasFleetCost(node.costUsd, node.costState)) {
|
|
364
|
+
totalCost = (totalCost ?? 0) + (node.costUsd as number);
|
|
365
|
+
}
|
|
366
|
+
const tokens = fleetUsageTokens(node.usage);
|
|
367
|
+
if (tokens !== null) {
|
|
368
|
+
totalTokens = (totalTokens ?? 0) + tokens;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (isFleetRunningLeaf(node)) runningCount++;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { rows, totalCost, totalTokens, runningCount, capturedAt };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Read-model — two-factory shape (live / static), mirrors cockpit-read-model.ts
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/** Payload of a `COMMUNICATION_CONSUMED` runtime-bus event, narrowed for FleetReadModel consumers. */
|
|
382
|
+
export interface SteerConsumedEvent {
|
|
383
|
+
readonly messageId: string;
|
|
384
|
+
readonly agentId: string;
|
|
385
|
+
readonly turn: number;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface FleetReadModel {
|
|
389
|
+
getSnapshot(): FleetSnapshot;
|
|
390
|
+
/** Subscribe to fleet changes. Returns an unsubscribe function. */
|
|
391
|
+
subscribe(listener: () => void): () => void;
|
|
392
|
+
/** Graceful interruption where the source supports one. Returns true when accepted. */
|
|
393
|
+
interrupt(id: string): boolean;
|
|
394
|
+
/**
|
|
395
|
+
* Wave-6 (wo-F item d2): re-arm a `paused` node (a disabled trigger/schedule,
|
|
396
|
+
* or an automation job). The inverse of interrupt()'s pause. Returns true when
|
|
397
|
+
* accepted; false for a node that is not currently `paused` or whose kind has
|
|
398
|
+
* no resume path (honest refusal).
|
|
399
|
+
*/
|
|
400
|
+
resume(id: string): boolean;
|
|
401
|
+
/** Hard stop, optionally cascading to descendants. Returns the node ids acted on. */
|
|
402
|
+
kill(id: string, opts?: { readonly cascade?: boolean }): readonly string[];
|
|
403
|
+
/**
|
|
404
|
+
* Wave-3 (W3.2): queue a human message for a live in-process agent (or a
|
|
405
|
+
* wrfc-subtask's current live member), delivered at its next turn
|
|
406
|
+
* boundary. Honest refusal (`{queued:false,reason}`) for anything that
|
|
407
|
+
* cannot take mid-run input — see ProcessRegistry.steer's doc comment.
|
|
408
|
+
*/
|
|
409
|
+
steer(id: string, text: string): SteerResult;
|
|
410
|
+
/**
|
|
411
|
+
* Wave-3 (W3.2): subscribe to the honest "the agent actually consumed this
|
|
412
|
+
* steer at its turn boundary" signal (COMMUNICATION_CONSUMED on the
|
|
413
|
+
* runtime bus's 'communication' domain). Returns an unsubscribe function.
|
|
414
|
+
* A read-model constructed without a runtimeBus (e.g. the static factory,
|
|
415
|
+
* or a test double) never invokes the listener — graceful no-op, matching
|
|
416
|
+
* the steer()/steerable degrade-without-a-dep convention.
|
|
417
|
+
*/
|
|
418
|
+
subscribeConsumed(listener: (event: SteerConsumedEvent) => void): () => void;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Narrow surface of ProcessRegistry this read-model depends on. */
|
|
422
|
+
export type FleetRegistryLike = Pick<ProcessRegistry, 'query' | 'subscribe' | 'interrupt' | 'resume' | 'kill' | 'steer'>;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create a live FleetReadModel backed by the SDK's ProcessRegistry.
|
|
426
|
+
* `runtimeBus` is optional (narrowed to just `onDomain`) — without it,
|
|
427
|
+
* `subscribeConsumed` degrades to a no-op, same shape as the registry's own
|
|
428
|
+
* messageBus-optional degrade for steer()/steerable.
|
|
429
|
+
*/
|
|
430
|
+
export function createFleetReadModel(
|
|
431
|
+
registry: FleetRegistryLike,
|
|
432
|
+
runtimeBus?: Pick<RuntimeEventBus, 'onDomain'>,
|
|
433
|
+
): FleetReadModel {
|
|
434
|
+
return {
|
|
435
|
+
getSnapshot(): FleetSnapshot {
|
|
436
|
+
const snapshot = registry.query();
|
|
437
|
+
return buildFleetSnapshot(snapshot.nodes, snapshot.capturedAt);
|
|
438
|
+
},
|
|
439
|
+
subscribe(listener: () => void): () => void {
|
|
440
|
+
return registry.subscribe(() => listener());
|
|
441
|
+
},
|
|
442
|
+
interrupt(id: string): boolean {
|
|
443
|
+
return registry.interrupt(id);
|
|
444
|
+
},
|
|
445
|
+
resume(id: string): boolean {
|
|
446
|
+
return registry.resume(id);
|
|
447
|
+
},
|
|
448
|
+
kill(id: string, opts?: { readonly cascade?: boolean }): readonly string[] {
|
|
449
|
+
return registry.kill(id, opts);
|
|
450
|
+
},
|
|
451
|
+
steer(id: string, text: string): SteerResult {
|
|
452
|
+
return registry.steer(id, text);
|
|
453
|
+
},
|
|
454
|
+
subscribeConsumed(listener: (event: SteerConsumedEvent) => void): () => void {
|
|
455
|
+
if (!runtimeBus) return () => {};
|
|
456
|
+
return runtimeBus.onDomain('communication', (envelope) => {
|
|
457
|
+
const event = envelope.payload;
|
|
458
|
+
if (event.type === 'COMMUNICATION_CONSUMED') {
|
|
459
|
+
listener({ messageId: event.messageId, agentId: event.agentId, turn: event.turn });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Create a static FleetReadModel for tests/goldens — no live registry, no timers. */
|
|
467
|
+
export function createStaticFleetReadModel(snapshot: FleetSnapshot): FleetReadModel {
|
|
468
|
+
return {
|
|
469
|
+
getSnapshot: () => snapshot,
|
|
470
|
+
subscribe: () => () => {},
|
|
471
|
+
interrupt: () => false,
|
|
472
|
+
resume: () => false,
|
|
473
|
+
kill: () => [],
|
|
474
|
+
steer: () => ({ queued: false, reason: 'no live registry' }),
|
|
475
|
+
subscribeConsumed: () => () => {},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// fleet-steer.ts
|
|
3
|
+
//
|
|
4
|
+
// Wave-3 (W3.2) — steer-badge rendering helpers and the "dropped inference"
|
|
5
|
+
// reconciliation pass, split out of fleet-panel.ts to keep that file under
|
|
6
|
+
// the architecture line cap (see check-architecture.ts's 800-line gate).
|
|
7
|
+
// Pure functions only; FleetPanel still owns the mutable FleetTab.steerBadge
|
|
8
|
+
// state itself (see fleet-tabs.ts's SteerBadge doc) and calls into this
|
|
9
|
+
// module rather than duplicating the logic inline.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import { STEER_TTL_MS, type ProcessNode } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
|
|
13
|
+
import { isTerminalProcessState } from './fleet-read-model.ts';
|
|
14
|
+
import type { FleetTab, SteerBadgeStatus } from './fleet-tabs.ts';
|
|
15
|
+
import { DEFAULT_PANEL_PALETTE, type PanelPalette } from './polish.ts';
|
|
16
|
+
|
|
17
|
+
/** Linger before an auto-resolved (consumed/dropped) steer badge is cleared from the tab. */
|
|
18
|
+
export const STEER_BADGE_LINGER_MS = 4_000;
|
|
19
|
+
|
|
20
|
+
export function steerBadgeGlyph(status: SteerBadgeStatus): string {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case 'queued': return '⧗';
|
|
23
|
+
case 'consumed': return '✓';
|
|
24
|
+
case 'dropped': return '⚠';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function steerBadgeTone(status: SteerBadgeStatus, palette: PanelPalette): string {
|
|
29
|
+
switch (status) {
|
|
30
|
+
case 'queued': return palette.warn ?? DEFAULT_PANEL_PALETTE.warn;
|
|
31
|
+
case 'consumed': return palette.good ?? DEFAULT_PANEL_PALETTE.good;
|
|
32
|
+
case 'dropped': return palette.bad ?? DEFAULT_PANEL_PALETTE.bad;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wave-3 (W3.2, risk #2 — "dropped inference"): the SDK emits no
|
|
38
|
+
* cancelled/expired signal for a queued steer, so a badge left `queued`
|
|
39
|
+
* after its target node goes terminal (done/failed/killed/interrupted)
|
|
40
|
+
* would hang forever with no honest resolution. Resolves any such badge to
|
|
41
|
+
* `dropped`, and clears any already-resolved (consumed/dropped) badge past
|
|
42
|
+
* its short linger so a tab doesn't accumulate stale indicators.
|
|
43
|
+
*
|
|
44
|
+
* Mutates `tab.steerBadge` in place (same mutable-slot convention as
|
|
45
|
+
* `FleetTab.ledgerEntries` — see fleet-panel.ts's ensureLedgerLoaded).
|
|
46
|
+
* Returns true when anything changed, so the caller knows whether to mark
|
|
47
|
+
* itself dirty.
|
|
48
|
+
*/
|
|
49
|
+
export function reconcileSteerBadges(
|
|
50
|
+
tabs: readonly FleetTab[],
|
|
51
|
+
findLiveNode: (nodeId: string) => ProcessNode | null,
|
|
52
|
+
now: number,
|
|
53
|
+
): boolean {
|
|
54
|
+
let changed = false;
|
|
55
|
+
for (const tab of tabs) {
|
|
56
|
+
const badge = tab.steerBadge;
|
|
57
|
+
if (!badge) continue;
|
|
58
|
+
if (badge.status === 'queued') {
|
|
59
|
+
const node = findLiveNode(tab.nodeId);
|
|
60
|
+
if (!node || isTerminalProcessState(node.state)) {
|
|
61
|
+
tab.steerBadge = {
|
|
62
|
+
...badge,
|
|
63
|
+
status: 'dropped',
|
|
64
|
+
note: node
|
|
65
|
+
? `the ${node.kind} went ${node.state} before the steer was delivered`
|
|
66
|
+
: 'the target is no longer tracked',
|
|
67
|
+
resolvedAt: now,
|
|
68
|
+
};
|
|
69
|
+
changed = true;
|
|
70
|
+
} else if (badge.queuedAt !== undefined && now - badge.queuedAt > STEER_TTL_MS) {
|
|
71
|
+
// Long-tool-call case: the target is still healthy and non-terminal,
|
|
72
|
+
// but the underlying steer message's own TTL (the SDK's MessageBus —
|
|
73
|
+
// see registry.js's steer(), which stamps every steer with
|
|
74
|
+
// STEER_TTL_MS) has lapsed without a COMMUNICATION_CONSUMED ever
|
|
75
|
+
// arriving. The SDK gives no explicit expiry signal, so without this
|
|
76
|
+
// the badge would show 'queued' forever even though the message is
|
|
77
|
+
// provably gone from the bus.
|
|
78
|
+
tab.steerBadge = {
|
|
79
|
+
...badge,
|
|
80
|
+
status: 'dropped',
|
|
81
|
+
note: 'expired undelivered',
|
|
82
|
+
resolvedAt: now,
|
|
83
|
+
};
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
} else if (badge.resolvedAt !== undefined && now - badge.resolvedAt > STEER_BADGE_LINGER_MS) {
|
|
87
|
+
tab.steerBadge = null;
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return changed;
|
|
92
|
+
}
|