@pellux/goodvibes-tui 1.1.0 → 1.9.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 +28 -18
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +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/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +90 -14
- 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/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +104 -51
- package/src/core/turn-cancellation.ts +25 -0
- package/src/core/turn-event-wiring.ts +72 -3
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/export/cost-utils.ts +36 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +84 -5
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +232 -0
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +10 -2
- 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 +73 -35
- package/src/input/commands/operator-panel-runtime.ts +71 -31
- package/src/input/commands/planning-runtime.ts +116 -4
- 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 +26 -6
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- 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-content.ts +20 -9
- package/src/input/commands/session-workflow.ts +16 -1
- package/src/input/commands/settings-sync-runtime.ts +16 -4
- package/src/input/commands/shell-core.ts +25 -8
- 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 +488 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +175 -0
- package/src/input/config-modal.ts +592 -0
- package/src/input/feed-context-factory.ts +10 -8
- package/src/input/handler-command-route.ts +37 -20
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +74 -114
- package/src/input/handler-feed.ts +79 -27
- package/src/input/handler-interactions.ts +1 -3
- package/src/input/handler-modal-routes.ts +136 -6
- package/src/input/handler-modal-stack.ts +14 -7
- package/src/input/handler-modal-token-routes.ts +16 -49
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +26 -102
- package/src/input/handler-shortcuts.ts +74 -14
- package/src/input/handler-types.ts +2 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +12 -29
- package/src/input/keybindings.ts +23 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +77 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-integration-actions.ts +9 -170
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +146 -2
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +49 -51
- 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 +18 -46
- 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-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +758 -0
- package/src/panels/fleet-read-model.ts +520 -0
- package/src/panels/fleet-steer.ts +125 -0
- package/src/panels/fleet-stop.ts +153 -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 +166 -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 +42 -0
- package/src/panels/modals/pairing-modal.ts +163 -0
- package/src/panels/modals/planning-modal.ts +304 -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 +177 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +123 -9
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -11
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/types.ts +37 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +216 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- 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/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +95 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +15 -3
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +43 -1
- package/src/renderer/help-overlay.ts +2 -2
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +62 -4
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/panel-workspace-bar.ts +8 -2
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +133 -0
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +27 -1
- package/src/runtime/bootstrap-command-parts.ts +41 -10
- package/src/runtime/bootstrap-core.ts +47 -15
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +48 -17
- package/src/runtime/bootstrap.ts +129 -22
- package/src/runtime/code-index-services.ts +135 -0
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +26 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +59 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +98 -46
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +27 -3
- package/src/runtime/workstream-services.ts +327 -0
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +68 -4
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +171 -26
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/utils/terminal-width.ts +52 -0
- 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
|
@@ -6,7 +6,7 @@ import { createUiReadModels, type UiReadModels, type UiReadModelOptions } from '
|
|
|
6
6
|
import type { ForensicsRegistry } from '@/runtime/index.ts';
|
|
7
7
|
import type { ControlPlaneRecentEvent } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
8
8
|
import type { ApprovalBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
9
|
-
import type
|
|
9
|
+
import { SessionUnionCache, type SessionReadFacade } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
|
|
10
10
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
11
11
|
import type { HostServiceStatus } from '@/runtime/index.ts';
|
|
12
12
|
import type { SecretsManager } from '../config/secrets.ts';
|
|
@@ -39,7 +39,13 @@ export interface UiProviderServices {
|
|
|
39
39
|
|
|
40
40
|
export interface UiSessionServices {
|
|
41
41
|
readonly sessionManager: RuntimeServices['sessionManager'];
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The panel-facing session READ source is the cross-surface union
|
|
44
|
+
* facade, not the raw local broker — in adopted-daemon ('external') mode the
|
|
45
|
+
* local broker misses sessions hosted for other surfaces. Sync signature
|
|
46
|
+
* preserved; the facade's cache makes that honest (see session-union-cache.ts).
|
|
47
|
+
*/
|
|
48
|
+
readonly sessionBroker: SessionReadFacade;
|
|
43
49
|
readonly sessionOrchestration: RuntimeServices['sessionOrchestration'];
|
|
44
50
|
readonly sessionMemoryStore: RuntimeServices['sessionMemoryStore'];
|
|
45
51
|
}
|
|
@@ -55,6 +61,7 @@ export interface UiPlatformServices {
|
|
|
55
61
|
readonly tokenAuditor: RuntimeServices['tokenAuditor'];
|
|
56
62
|
readonly replayEngine: RuntimeServices['replayEngine'];
|
|
57
63
|
readonly webhookNotifier: RuntimeServices['webhookNotifier'];
|
|
64
|
+
readonly focusTracker: RuntimeServices['focusTracker'];
|
|
58
65
|
readonly policyRuntimeState: RuntimeServices['policyRuntimeState'];
|
|
59
66
|
readonly externalServices?: {
|
|
60
67
|
inspect(): {
|
|
@@ -64,6 +71,10 @@ export interface UiPlatformServices {
|
|
|
64
71
|
readonly httpListenerPortInUse?: boolean;
|
|
65
72
|
readonly daemonStatus?: HostServiceStatus;
|
|
66
73
|
readonly httpListenerStatus?: HostServiceStatus;
|
|
74
|
+
// Honest session-spine posture for the footer segment, driven by
|
|
75
|
+
// the spine client's own live wire attempts (not the one-shot adopt probe).
|
|
76
|
+
readonly sessionSpineActive?: boolean;
|
|
77
|
+
readonly sessionSpineStatus?: 'unknown' | 'online' | 'offline';
|
|
67
78
|
};
|
|
68
79
|
restart(): Promise<{
|
|
69
80
|
readonly daemonRunning: boolean;
|
|
@@ -101,6 +112,10 @@ export interface UiRuntimeSharedServices {
|
|
|
101
112
|
readonly distributedRuntime: RuntimeServices['distributedRuntime'];
|
|
102
113
|
readonly remoteRunnerRegistry: RuntimeServices['remoteRunnerRegistry'] & RemoteRunnerRegistry;
|
|
103
114
|
readonly remoteSupervisor: RuntimeServices['remoteSupervisor'] & RemoteSupervisor;
|
|
115
|
+
/** W2.2: the shared live process registry backing the Fleet panel. */
|
|
116
|
+
readonly processRegistry: RuntimeServices['processRegistry'];
|
|
117
|
+
/** Wave-3 (W3.2): the shared runtime event bus — the Fleet panel subscribes to its 'communication' domain for the honest steer-consumed signal. */
|
|
118
|
+
readonly runtimeBus: RuntimeServices['runtimeBus'];
|
|
104
119
|
};
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -121,6 +136,12 @@ export interface UiRuntimeServices {
|
|
|
121
136
|
export interface UiRuntimeServicesOptions extends UiReadModelOptions {
|
|
122
137
|
readonly forensicsRegistry?: ForensicsRegistry;
|
|
123
138
|
readonly getControlPlaneRecentEvents?: (limit: number) => readonly ControlPlaneRecentEvent[];
|
|
139
|
+
/**
|
|
140
|
+
* The shared session read facade constructed in bootstrap-core (so
|
|
141
|
+
* bootstrap.ts can drive its adopted-external/embedded/local mode). When omitted
|
|
142
|
+
* (test callers), a local-only passthrough facade over the broker is auto-created.
|
|
143
|
+
*/
|
|
144
|
+
readonly sessionUnionCache?: SessionUnionCache;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
export function createUiRuntimeServices(
|
|
@@ -152,7 +173,7 @@ export function createUiRuntimeServices(
|
|
|
152
173
|
},
|
|
153
174
|
sessions: {
|
|
154
175
|
sessionManager: runtimeServices.sessionManager,
|
|
155
|
-
sessionBroker: runtimeServices.sessionBroker,
|
|
176
|
+
sessionBroker: options.sessionUnionCache ?? new SessionUnionCache({ local: runtimeServices.sessionBroker }),
|
|
156
177
|
sessionOrchestration: runtimeServices.sessionOrchestration,
|
|
157
178
|
sessionMemoryStore: runtimeServices.sessionMemoryStore,
|
|
158
179
|
},
|
|
@@ -167,6 +188,7 @@ export function createUiRuntimeServices(
|
|
|
167
188
|
tokenAuditor: runtimeServices.tokenAuditor,
|
|
168
189
|
replayEngine: runtimeServices.replayEngine,
|
|
169
190
|
webhookNotifier: runtimeServices.webhookNotifier,
|
|
191
|
+
focusTracker: runtimeServices.focusTracker,
|
|
170
192
|
policyRuntimeState: runtimeServices.policyRuntimeState,
|
|
171
193
|
},
|
|
172
194
|
planning: {
|
|
@@ -183,6 +205,8 @@ export function createUiRuntimeServices(
|
|
|
183
205
|
distributedRuntime: runtimeServices.distributedRuntime,
|
|
184
206
|
remoteRunnerRegistry: runtimeServices.remoteRunnerRegistry,
|
|
185
207
|
remoteSupervisor: runtimeServices.remoteSupervisor,
|
|
208
|
+
processRegistry: runtimeServices.processRegistry,
|
|
209
|
+
runtimeBus: runtimeServices.runtimeBus,
|
|
186
210
|
},
|
|
187
211
|
events: createUiRuntimeEvents(runtimeServices.runtimeBus),
|
|
188
212
|
readModels: createUiReadModels(runtimeServices, options),
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// workstream-services.ts — Wave 4 (wo703)
|
|
3
|
+
//
|
|
4
|
+
// Constructs the TUI's OrchestrationEngine instance (@pellux/goodvibes-sdk/
|
|
5
|
+
// platform/orchestration, landed on SDK main as wo701/W4.1) and a thin
|
|
6
|
+
// command-facing facade around it. Extracted into its own module rather than
|
|
7
|
+
// built inline in services.ts: services.ts sits at the architecture check's
|
|
8
|
+
// 800-line cap (scripts/check-architecture.ts), so any new service gets its
|
|
9
|
+
// own construction module and a single wiring call there (mirrors
|
|
10
|
+
// createWorkflowServices's one-function-bundle shape).
|
|
11
|
+
//
|
|
12
|
+
// Why a facade, not the bare engine: createOrchestrationEngine() is a pure
|
|
13
|
+
// construction call with no auto-start and NO concept of a not-yet-launched
|
|
14
|
+
// "proposal" a human can review/edit before anything is spent — its only
|
|
15
|
+
// creation entry point, createWorkstream(), immediately materializes a real,
|
|
16
|
+
// ticking-eligible Workstream. The /workstream command module
|
|
17
|
+
// (input/commands/workstream-runtime.ts) needs a create -> propose -> approve
|
|
18
|
+
// -> launch flow (Pillar-3 doctrine: render the plan in the transcript before
|
|
19
|
+
// spending anything, mirroring /plan's approve step). REALITY-WINS DIVERGENCE
|
|
20
|
+
// from the wo703 design brief: the brief recommended an "engine-owned/
|
|
21
|
+
// journal-backed" draft so `/workstream status` could re-render a pending
|
|
22
|
+
// proposal after a restart; the real engine (verified against the linked SDK
|
|
23
|
+
// build) exposes no pre-creation draft concept at all, so WorkstreamDraft
|
|
24
|
+
// below is process-lifetime-only state, owned by this module's facade
|
|
25
|
+
// instance (constructed once, threaded onto CommandContext) — never a
|
|
26
|
+
// module-level ambient global. A draft that existed only in memory is lost on
|
|
27
|
+
// restart, same as an unsent chat draft; that is a real, stated limitation,
|
|
28
|
+
// not a bug.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createOrchestrationEngine,
|
|
33
|
+
fromChainSpec,
|
|
34
|
+
fromPlanProposal,
|
|
35
|
+
type CreateWorkstreamInput,
|
|
36
|
+
type OrchestrationEngine,
|
|
37
|
+
type WorkstreamIsolation,
|
|
38
|
+
} from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
39
|
+
export type { OrchestrationEngine } from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
40
|
+
import {
|
|
41
|
+
AdaptivePlanner,
|
|
42
|
+
decomposeGoal,
|
|
43
|
+
type DecompositionGate,
|
|
44
|
+
type DecompositionServiceConfig,
|
|
45
|
+
type DecomposeGoalResult,
|
|
46
|
+
type PlannerInputs,
|
|
47
|
+
type PlanProposal,
|
|
48
|
+
} from '@pellux/goodvibes-sdk/platform/core';
|
|
49
|
+
import { createAgentManagerDecompositionRunner } from '@pellux/goodvibes-sdk/platform/agents';
|
|
50
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
51
|
+
import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
|
|
52
|
+
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
53
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
54
|
+
|
|
55
|
+
export interface WorkstreamServicesDeps {
|
|
56
|
+
readonly agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel' | 'registerCancellationSignal' | 'releaseCancellationSignal'>;
|
|
57
|
+
readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
58
|
+
readonly adaptivePlanner: AdaptivePlanner;
|
|
59
|
+
readonly runtimeBus: RuntimeEventBus;
|
|
60
|
+
readonly projectRoot: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Honest provenance for how a draft's decomposition was produced. Derived from
|
|
65
|
+
* the SDK decomposition service's outcome so the draft render can state plainly
|
|
66
|
+
* whether a planning agent decomposed the goal, or the heuristic path did (and
|
|
67
|
+
* if so, why).
|
|
68
|
+
*/
|
|
69
|
+
export interface WorkstreamDraftProvenance {
|
|
70
|
+
readonly kind: 'agent' | 'heuristic-configured' | 'gate-declined' | 'fallback';
|
|
71
|
+
readonly itemCount: number;
|
|
72
|
+
readonly agentCostUsd?: number | undefined;
|
|
73
|
+
readonly agentTokens?: number | undefined;
|
|
74
|
+
readonly elapsedMs?: number | undefined;
|
|
75
|
+
readonly fallbackReason?: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A not-yet-launched /workstream proposal. See this file's header doc for why it lives here rather than on the engine. */
|
|
79
|
+
export interface WorkstreamDraft {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
task: string;
|
|
82
|
+
spec: CreateWorkstreamInput;
|
|
83
|
+
readonly gate: DecompositionGate;
|
|
84
|
+
/** The engine-agnostic decomposition proposal (model- or heuristic-produced). */
|
|
85
|
+
proposal: PlanProposal;
|
|
86
|
+
/** How that proposal came to be, for honest rendering. */
|
|
87
|
+
provenance: WorkstreamDraftProvenance;
|
|
88
|
+
approved: boolean;
|
|
89
|
+
readonly createdAt: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `ctx.session.workstreamEngine`'s real shape: the live engine plus the draft-proposal bookkeeping the engine itself has no concept of. */
|
|
93
|
+
export interface WorkstreamCommandService {
|
|
94
|
+
readonly engine: OrchestrationEngine;
|
|
95
|
+
/** Spawn a bounded read-only planning agent to decompose the goal (with automatic heuristic fallback), then hold the draft. Async because the planning agent is real. `isolation` omitted ⇒ the engine's own default ('shared'); see CreateWorkstreamInput.isolation (SDK). */
|
|
96
|
+
proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft>;
|
|
97
|
+
getDraft(id: string): WorkstreamDraft | undefined;
|
|
98
|
+
listDrafts(): WorkstreamDraft[];
|
|
99
|
+
/** Re-derive a held draft's spec + decomposition from a new task string. Clears any prior approval — an edit must be re-approved. `isolation` omitted ⇒ keeps the draft's current choice (an edit that only changes the task text must not silently reset isolation back to shared). */
|
|
100
|
+
editDraft(id: string, task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft | undefined>;
|
|
101
|
+
approveDraft(id: string): WorkstreamDraft | undefined;
|
|
102
|
+
removeDraft(id: string): boolean;
|
|
103
|
+
/** Materialize an approved draft into a real, running Workstream (engine.createWorkstream + start), then drop the draft. Null when the draft is missing or not approved. */
|
|
104
|
+
launchDraft(id: string): { workstreamId: string } | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface WorkstreamServices {
|
|
108
|
+
readonly orchestrationEngine: OrchestrationEngine;
|
|
109
|
+
readonly workstreamCommands: WorkstreamCommandService;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Placeholder planner inputs until a richer risk/latency signal is wired in.
|
|
114
|
+
* `/workstream create` is inherently a multi-step authoring surface, so
|
|
115
|
+
* isMultiStep is always true; the rest are neutral defaults that let
|
|
116
|
+
* AdaptivePlanner's real scoring run rather than short-circuiting it.
|
|
117
|
+
*/
|
|
118
|
+
function buildPlannerInputs(task: string): PlannerInputs {
|
|
119
|
+
return {
|
|
120
|
+
riskScore: 0.3,
|
|
121
|
+
latencyBudgetMs: Number.POSITIVE_INFINITY,
|
|
122
|
+
isMultiStep: true,
|
|
123
|
+
remoteAvailable: false,
|
|
124
|
+
backgroundEligible: false,
|
|
125
|
+
taskDescription: task,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read the planner decomposition config (mode + bounds) from the config
|
|
131
|
+
* manager, defensively defaulting anything missing or invalid. Real config
|
|
132
|
+
* always supplies the DEFAULT_CONFIG values; these fallbacks matter only for a
|
|
133
|
+
* partially-stubbed config manager, and guarantee finite positive bounds so a
|
|
134
|
+
* planning-agent poll can never loop forever on a NaN deadline.
|
|
135
|
+
*/
|
|
136
|
+
function readDecompositionConfig(configManager: Pick<ConfigManager, 'get'>): DecompositionServiceConfig {
|
|
137
|
+
const num = (value: unknown, fallback: number): number =>
|
|
138
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
139
|
+
return {
|
|
140
|
+
mode: configManager.get('planner.decomposition') === 'heuristic' ? 'heuristic' : 'agent',
|
|
141
|
+
bounds: {
|
|
142
|
+
maxTurns: num(configManager.get('planner.maxTurns'), 6),
|
|
143
|
+
tokenCeiling: num(configManager.get('planner.tokenCeiling'), 120_000),
|
|
144
|
+
wallTimeoutMs: num(configManager.get('planner.wallTimeoutMs'), 60_000),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Honest cost estimator: prices the planning agent's tokens only when the
|
|
150
|
+
* session's default model is one we actually have pricing for; otherwise the
|
|
151
|
+
* render falls back to a raw token count. Never throws. */
|
|
152
|
+
function makeCostEstimator(configManager: Pick<ConfigManager, 'get'>): (usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number | undefined; cacheWriteTokens?: number | undefined }) => number | undefined {
|
|
153
|
+
return (usage) => {
|
|
154
|
+
try {
|
|
155
|
+
const model = configManager.get('provider.model') as unknown as string | undefined;
|
|
156
|
+
if (!model || !isModelPriced(model)) return undefined;
|
|
157
|
+
return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens ?? 0, usage.cacheWriteTokens ?? 0, model);
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toProvenance(result: DecomposeGoalResult): WorkstreamDraftProvenance {
|
|
165
|
+
const p = result.proposal;
|
|
166
|
+
const kind: WorkstreamDraftProvenance['kind'] =
|
|
167
|
+
result.outcome.kind === 'agent' ? 'agent'
|
|
168
|
+
: result.outcome.kind === 'heuristic-configured' ? 'heuristic-configured'
|
|
169
|
+
: result.outcome.kind === 'gate-declined' ? 'gate-declined'
|
|
170
|
+
: 'fallback';
|
|
171
|
+
return {
|
|
172
|
+
kind,
|
|
173
|
+
itemCount: p.workItems.length,
|
|
174
|
+
...(p.agentCostUsd !== undefined ? { agentCostUsd: p.agentCostUsd } : {}),
|
|
175
|
+
...(p.agentUsage ? { agentTokens: p.agentUsage.totalTokens } : {}),
|
|
176
|
+
...(p.elapsedMs !== undefined ? { elapsedMs: p.elapsedMs } : {}),
|
|
177
|
+
...(p.fallbackReason ? { fallbackReason: p.fallbackReason } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createWorkstreamCommandService(
|
|
182
|
+
engine: OrchestrationEngine,
|
|
183
|
+
adaptivePlanner: AdaptivePlanner,
|
|
184
|
+
configManager: Pick<ConfigManager, 'get' | 'getCategory'>,
|
|
185
|
+
agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel'>,
|
|
186
|
+
projectRoot: string,
|
|
187
|
+
): WorkstreamCommandService {
|
|
188
|
+
const drafts = new Map<string, WorkstreamDraft>();
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run the SDK decomposition service: it spawns a bounded, read-only planning
|
|
192
|
+
* agent (which surfaces in the fleet like any agent — kill/steer reach it,
|
|
193
|
+
* and a kill lands as a 'cancelled' fallback) and validates its structured
|
|
194
|
+
* output, or falls back to the heuristic single-item path on any failure.
|
|
195
|
+
* The returned proposal is engine-agnostic; the launchable `spec` is still
|
|
196
|
+
* derived by `buildSpec` (see below).
|
|
197
|
+
*/
|
|
198
|
+
async function decompose(task: string): Promise<DecomposeGoalResult> {
|
|
199
|
+
const runner = createAgentManagerDecompositionRunner({ agentManager });
|
|
200
|
+
return decomposeGoal(
|
|
201
|
+
{ goal: task, workingDir: projectRoot, constraints: {} },
|
|
202
|
+
adaptivePlanner,
|
|
203
|
+
buildPlannerInputs(task),
|
|
204
|
+
readDecompositionConfig(configManager),
|
|
205
|
+
runner,
|
|
206
|
+
{ estimateCostUsd: makeCostEstimator(configManager) },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Derive the launchable CreateWorkstreamInput from the decomposition
|
|
212
|
+
* proposal (BIG-3 — the final stage of the WRFC→orchestration migration).
|
|
213
|
+
*
|
|
214
|
+
* THE BOUNDARY, stated honestly (and in the /workstream help + draft render):
|
|
215
|
+
* - A genuinely MULTI-ITEM proposal (the planning agent decomposed the goal
|
|
216
|
+
* into >1 work item) is assembled by the SDK's fromPlanProposal into the
|
|
217
|
+
* REAL multi-item workstream: one engineer→review-phased item per proposal
|
|
218
|
+
* item, inter-item dependencies preserved as scheduling constraints, and
|
|
219
|
+
* workstream-level provenance carried. This is the plan the engine runs —
|
|
220
|
+
* no flattening.
|
|
221
|
+
* - A SINGLE-ITEM proposal (the heuristic single-item path, a gate-decline,
|
|
222
|
+
* or an agent that honestly returned one item) keeps the fromChainSpec
|
|
223
|
+
* COMPAT path: byte-for-byte the same engineer→review chain
|
|
224
|
+
* WrfcController.createChain would start. A single item carries no
|
|
225
|
+
* dependencies and no multi-item structure, so the proposal mapping would
|
|
226
|
+
* add nothing — the compat path is the honest, unchanged choice.
|
|
227
|
+
*
|
|
228
|
+
* Earlier waves always took the compat path because no planning-agent-spawn
|
|
229
|
+
* pipeline existed to produce a real multi-item decomposition; BIG-2 landed
|
|
230
|
+
* that pipeline, so `result.proposal` can now be a real multi-item plan and
|
|
231
|
+
* this maps it faithfully. The rendered draft shows THIS spec, so the
|
|
232
|
+
* proposal preview and the launch are always the same plan.
|
|
233
|
+
*/
|
|
234
|
+
function buildSpec(task: string, proposal: PlanProposal): CreateWorkstreamInput {
|
|
235
|
+
if (proposal.workItems.length > 1) {
|
|
236
|
+
return fromPlanProposal(proposal, configManager);
|
|
237
|
+
}
|
|
238
|
+
return fromChainSpec({ id: `item-${crypto.randomUUID().slice(0, 8)}`, task }, configManager);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
engine,
|
|
243
|
+
async proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft> {
|
|
244
|
+
const result = await decompose(task);
|
|
245
|
+
const spec = buildSpec(task, result.proposal);
|
|
246
|
+
const draft: WorkstreamDraft = {
|
|
247
|
+
id: `wsd_${crypto.randomUUID().slice(0, 8)}`,
|
|
248
|
+
task,
|
|
249
|
+
spec: isolation ? { ...spec, isolation } : spec,
|
|
250
|
+
gate: result.gate,
|
|
251
|
+
proposal: result.proposal,
|
|
252
|
+
provenance: toProvenance(result),
|
|
253
|
+
approved: false,
|
|
254
|
+
createdAt: Date.now(),
|
|
255
|
+
};
|
|
256
|
+
drafts.set(draft.id, draft);
|
|
257
|
+
return draft;
|
|
258
|
+
},
|
|
259
|
+
getDraft: (id) => drafts.get(id),
|
|
260
|
+
listDrafts: () => Array.from(drafts.values()).sort((a, b) => a.createdAt - b.createdAt),
|
|
261
|
+
async editDraft(id, task, isolation) {
|
|
262
|
+
const draft = drafts.get(id);
|
|
263
|
+
if (!draft) return undefined;
|
|
264
|
+
const result = await decompose(task);
|
|
265
|
+
const nextIsolation = isolation ?? draft.spec.isolation;
|
|
266
|
+
draft.task = task;
|
|
267
|
+
draft.spec = { ...buildSpec(task, result.proposal), isolation: nextIsolation };
|
|
268
|
+
draft.proposal = result.proposal;
|
|
269
|
+
draft.provenance = toProvenance(result);
|
|
270
|
+
draft.approved = false;
|
|
271
|
+
return draft;
|
|
272
|
+
},
|
|
273
|
+
approveDraft(id) {
|
|
274
|
+
const draft = drafts.get(id);
|
|
275
|
+
if (!draft) return undefined;
|
|
276
|
+
draft.approved = true;
|
|
277
|
+
return draft;
|
|
278
|
+
},
|
|
279
|
+
removeDraft: (id) => drafts.delete(id),
|
|
280
|
+
launchDraft(id) {
|
|
281
|
+
const draft = drafts.get(id);
|
|
282
|
+
if (!draft || !draft.approved) return null;
|
|
283
|
+
const workstream = engine.createWorkstream(draft.spec);
|
|
284
|
+
engine.start(workstream.id);
|
|
285
|
+
drafts.delete(id);
|
|
286
|
+
return { workstreamId: workstream.id };
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Constructs the TUI's OrchestrationEngine instance and its command-facing
|
|
293
|
+
* facade. `persist` and `createWorktree` are left at the engine's own
|
|
294
|
+
* defaults: journal-backed snapshots under .goodvibes/orchestration/ (so
|
|
295
|
+
* resumeAllFromDisk below has something to resume) and a plain
|
|
296
|
+
* AgentWorktree(projectRoot) for the (default) `shared`-isolation path.
|
|
297
|
+
* A draft's `isolation: 'worktree'` (see /workstream create --isolation
|
|
298
|
+
* worktree, input/commands/workstream-runtime.ts) opts a single workstream
|
|
299
|
+
* into the SDK's per-item git-worktree isolation (WorktreeIsolationManager,
|
|
300
|
+
* engine-side) instead — the engine, not this facade, owns that lifecycle.
|
|
301
|
+
*/
|
|
302
|
+
export function createWorkstreamServices(deps: WorkstreamServicesDeps): WorkstreamServices {
|
|
303
|
+
const orchestrationEngine = createOrchestrationEngine({
|
|
304
|
+
agentManager: deps.agentManager,
|
|
305
|
+
configManager: deps.configManager,
|
|
306
|
+
runtimeBus: deps.runtimeBus,
|
|
307
|
+
projectRoot: deps.projectRoot,
|
|
308
|
+
priceUsage: (model, usage) => {
|
|
309
|
+
const modelId = model ?? 'unknown';
|
|
310
|
+
if (!isModelPriced(modelId)) return null;
|
|
311
|
+
return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens, modelId);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
// Honest resume: a prior process's still-in-flight workstreams pick back up
|
|
315
|
+
// (and reappear in the fleet tree) instead of silently vanishing on
|
|
316
|
+
// restart. Never throws — persistence.ts guards every read/parse and
|
|
317
|
+
// quarantines an unrecognized snapshot rather than propagating.
|
|
318
|
+
orchestrationEngine.resumeAllFromDisk();
|
|
319
|
+
const workstreamCommands = createWorkstreamCommandService(
|
|
320
|
+
orchestrationEngine,
|
|
321
|
+
deps.adaptivePlanner,
|
|
322
|
+
deps.configManager,
|
|
323
|
+
deps.agentManager,
|
|
324
|
+
deps.projectRoot,
|
|
325
|
+
);
|
|
326
|
+
return { orchestrationEngine, workstreamCommands };
|
|
327
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WRFC chain persistence — snapshot active chains to disk on every lifecycle
|
|
3
|
-
* event so that a crash/restart can surface interrupted chains to the operator
|
|
3
|
+
* event so that a crash/restart can surface interrupted chains to the operator,
|
|
4
|
+
* and so that terminal chains (passed/failed/cancelled) remain visible as
|
|
5
|
+
* bounded history rather than vanishing the moment they finish.
|
|
4
6
|
*
|
|
5
7
|
* Architecture
|
|
6
8
|
* ─────────────
|
|
@@ -16,11 +18,27 @@
|
|
|
16
18
|
* row and can be resumed by the operator. The `interruptedChains` accessor
|
|
17
19
|
* additionally exposes the recovered set for inspection.
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* A chain that looked interrupted (non-terminal) in the old snapshot can be
|
|
22
|
+
* reaped to terminal IN PLACE by `WrfcController.importChain` (Wave 6, wo-F:
|
|
23
|
+
* no member agent survived the restart — see wrfc-controller.ts). This
|
|
24
|
+
* module always re-checks `chain.state` AFTER the import call, never the
|
|
25
|
+
* pre-import classification, so a reaped chain is treated as history, not
|
|
26
|
+
* re-surfaced as interrupted and never re-imported again on a later restart.
|
|
27
|
+
*
|
|
28
|
+
* 3. Terminal-history retention (bounded, most-recently-completed first):
|
|
29
|
+
* Previously, every terminal chain was pruned from the snapshot the moment
|
|
30
|
+
* it was written or rehydrated — a killed/finished chain vanished from
|
|
31
|
+
* wrfc-chains.json entirely, including across a restart, so nothing could
|
|
32
|
+
* ever honestly report "last chain: <state>". Terminal chains are now
|
|
33
|
+
* RETAINED, capped at MAX_TERMINAL_HISTORY (20) most-recently-completed,
|
|
34
|
+
* both:
|
|
35
|
+
* - across a live session, via an internal history cache that survives
|
|
36
|
+
* even after WrfcController's own in-memory cleanup (60s after a
|
|
37
|
+
* chain terminates) drops the chain from listChains(); and
|
|
38
|
+
* - across a restart, via rehydrate() seeding that cache from whatever
|
|
39
|
+
* was already terminal in the prior snapshot instead of discarding it.
|
|
40
|
+
* A corrupt or version-mismatched snapshot is quarantined by renaming it
|
|
41
|
+
* to `<path>.unrecognized` — never a hard crash.
|
|
24
42
|
*
|
|
25
43
|
* Snapshot path: `.goodvibes/tui/wrfc-chains.json`
|
|
26
44
|
* Snapshot schema: `{ version: 1, writtenAt: number, chains: WrfcChain[] }`
|
|
@@ -37,6 +55,9 @@ import type { SystemMessageRouter } from '../core/system-message-router.ts';
|
|
|
37
55
|
const SNAPSHOT_VERSION = 1;
|
|
38
56
|
const DEBOUNCE_MS = 250;
|
|
39
57
|
|
|
58
|
+
/** Bounded terminal-history retention: keep the most recent K chains, prune beyond. */
|
|
59
|
+
const MAX_TERMINAL_HISTORY = 20;
|
|
60
|
+
|
|
40
61
|
/** Terminal states — chains in these states will not be surfaced as interrupted. */
|
|
41
62
|
const TERMINAL_STATES = new Set<WrfcState>(['passed', 'failed']);
|
|
42
63
|
|
|
@@ -45,6 +66,13 @@ function isNonTerminal(state: WrfcState): boolean {
|
|
|
45
66
|
return !TERMINAL_STATES.has(state);
|
|
46
67
|
}
|
|
47
68
|
|
|
69
|
+
/** Sort by most-recently-completed first (falls back to createdAt for chains missing completedAt) and keep only the first `limit`. */
|
|
70
|
+
function takeMostRecentTerminal(chains: readonly WrfcChain[], limit: number): WrfcChain[] {
|
|
71
|
+
return [...chains]
|
|
72
|
+
.sort((a, b) => (b.completedAt ?? b.createdAt ?? 0) - (a.completedAt ?? a.createdAt ?? 0))
|
|
73
|
+
.slice(0, limit);
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
// ─── Snapshot schema ─────────────────────────────────────────────────────────
|
|
49
77
|
|
|
50
78
|
interface WrfcSnapshot {
|
|
@@ -90,9 +118,19 @@ export interface WrfcPersistence {
|
|
|
90
118
|
*/
|
|
91
119
|
attach(runtimeBus: RuntimeEventBus): Array<() => void>;
|
|
92
120
|
|
|
121
|
+
/**
|
|
122
|
+
* All chains known after rehydrate(): the (re-checked, post-reap) interrupted
|
|
123
|
+
* set plus the bounded terminal-history cache seeded from the prior snapshot.
|
|
124
|
+
* Empty until rehydrate() is called. Consumers building a post-restart
|
|
125
|
+
* summary (e.g. the boot resume notice) read this instead of re-parsing the
|
|
126
|
+
* snapshot file themselves.
|
|
127
|
+
*/
|
|
128
|
+
readonly knownChains: readonly WrfcChain[];
|
|
129
|
+
|
|
93
130
|
/**
|
|
94
131
|
* Read the snapshot from a previous process, surface any interrupted chains
|
|
95
|
-
* as system messages, and
|
|
132
|
+
* as system messages, and retain terminal chains (bounded, most-recently-
|
|
133
|
+
* completed first) as on-disk history instead of pruning them.
|
|
96
134
|
*
|
|
97
135
|
* Must be called after the SystemMessageRouter is available.
|
|
98
136
|
*/
|
|
@@ -111,6 +149,14 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
111
149
|
|
|
112
150
|
private _interruptedChains: WrfcChain[] = [];
|
|
113
151
|
private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
152
|
+
/**
|
|
153
|
+
* Bounded terminal-history cache (most-recently-completed first once
|
|
154
|
+
* trimmed). Survives past WrfcController's own in-memory cleanup of
|
|
155
|
+
* terminated chains (60s after termination — see wrfc-controller.ts
|
|
156
|
+
* scheduleChainCleanup) so a chain that ages out of listChains() is not
|
|
157
|
+
* silently dropped from the persisted snapshot.
|
|
158
|
+
*/
|
|
159
|
+
private _terminalHistory: WrfcChain[] = [];
|
|
114
160
|
|
|
115
161
|
constructor(options: WrfcPersistenceOptions) {
|
|
116
162
|
this.snapshotPath = options.snapshotPath;
|
|
@@ -122,6 +168,10 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
122
168
|
return this._interruptedChains;
|
|
123
169
|
}
|
|
124
170
|
|
|
171
|
+
get knownChains(): readonly WrfcChain[] {
|
|
172
|
+
return [...this._interruptedChains, ...this._terminalHistory];
|
|
173
|
+
}
|
|
174
|
+
|
|
125
175
|
attach(runtimeBus: RuntimeEventBus): Array<() => void> {
|
|
126
176
|
const schedule = (): void => this._scheduleSnapshot();
|
|
127
177
|
|
|
@@ -144,17 +194,40 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
144
194
|
const snapshot = this._readSnapshot();
|
|
145
195
|
if (!snapshot) return;
|
|
146
196
|
|
|
147
|
-
|
|
148
|
-
this
|
|
197
|
+
// Seed the bounded terminal-history cache from whatever was already
|
|
198
|
+
// terminal in the prior process's snapshot, so it survives this restart
|
|
199
|
+
// instead of being wiped (the old behaviour pruned every terminal chain
|
|
200
|
+
// unconditionally on every rehydrate).
|
|
201
|
+
const alreadyTerminal = snapshot.chains.filter((c) => !isNonTerminal(c.state));
|
|
202
|
+
this._terminalHistory = takeMostRecentTerminal(alreadyTerminal, MAX_TERMINAL_HISTORY);
|
|
149
203
|
|
|
150
|
-
const
|
|
151
|
-
for (const chain of
|
|
204
|
+
const candidateInterrupted = snapshot.chains.filter((c) => isNonTerminal(c.state));
|
|
205
|
+
for (const chain of candidateInterrupted) {
|
|
152
206
|
// Re-import so the chain reappears in the controller's in-memory map and
|
|
153
207
|
// becomes selectable/resumable from the panel. On a fresh process start
|
|
154
208
|
// the map is empty, so importChain (force=false) never clobbers a live
|
|
155
209
|
// chain. The accessor is optional for read-only test doubles.
|
|
210
|
+
//
|
|
211
|
+
// NOTE: importChain may reap this chain to a terminal state IN PLACE
|
|
212
|
+
// (Wave 6, wo-F zombie-reap: no member agent survived the restart) —
|
|
213
|
+
// always read chain.state AFTER this call below, never the pre-import
|
|
214
|
+
// classification captured by candidateInterrupted.
|
|
156
215
|
this.controller.importChain?.(chain);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Re-partition after the reap check: a chain that looked interrupted
|
|
219
|
+
// before import may now be terminal. Only genuinely still-live chains are
|
|
220
|
+
// surfaced to the operator as interrupted; anything reaped just now joins
|
|
221
|
+
// history instead — it is done, not a resurrection candidate, and must
|
|
222
|
+
// never be handed to importChain again on a future restart (a chain only
|
|
223
|
+
// ever lands in candidateInterrupted while its persisted state is
|
|
224
|
+
// non-terminal; once it's history its state is terminal for good).
|
|
225
|
+
this._interruptedChains = candidateInterrupted.filter((c) => isNonTerminal(c.state));
|
|
226
|
+
const reapedJustNow = candidateInterrupted.filter((c) => !isNonTerminal(c.state));
|
|
227
|
+
for (const chain of reapedJustNow) this._upsertHistoryChain(chain);
|
|
157
228
|
|
|
229
|
+
const router = this.getSystemMessageRouter();
|
|
230
|
+
for (const chain of this._interruptedChains) {
|
|
158
231
|
const msg =
|
|
159
232
|
`[WRFC] Chain ${chain.id.slice(0, 12)} (${chain.task.slice(0, 60).trim()}) ` +
|
|
160
233
|
`was interrupted by a restart — state was '${chain.state}' ` +
|
|
@@ -162,14 +235,19 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
162
235
|
router?.wrfc(msg, 'high');
|
|
163
236
|
}
|
|
164
237
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
238
|
+
// Rewrite the snapshot only when something actually changed: a chain was
|
|
239
|
+
// reaped just now, or the history cap pruned an entry that was on disk.
|
|
240
|
+
// Terminal chains are RETAINED (bounded to MAX_TERMINAL_HISTORY, most
|
|
241
|
+
// recently completed first) instead of erased — post-restart surfaces
|
|
242
|
+
// (fleet, the resume notice, /wrfc history) need this to honestly report
|
|
243
|
+
// e.g. "last chain: cancelled".
|
|
244
|
+
if (reapedJustNow.length > 0 || alreadyTerminal.length !== this._terminalHistory.length) {
|
|
245
|
+
const rebuilt: WrfcSnapshot = {
|
|
168
246
|
version: SNAPSHOT_VERSION,
|
|
169
247
|
writtenAt: Date.now(),
|
|
170
|
-
chains:
|
|
248
|
+
chains: [...this._interruptedChains, ...this._terminalHistory],
|
|
171
249
|
};
|
|
172
|
-
this._writeSnapshot(
|
|
250
|
+
this._writeSnapshot(rebuilt);
|
|
173
251
|
}
|
|
174
252
|
}
|
|
175
253
|
|
|
@@ -194,14 +272,43 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
194
272
|
}
|
|
195
273
|
|
|
196
274
|
private _writeCurrentSnapshot(): void {
|
|
275
|
+
const live = this.controller.listChains();
|
|
276
|
+
this._recordTerminalHistory(live);
|
|
197
277
|
const snapshot: WrfcSnapshot = {
|
|
198
278
|
version: SNAPSHOT_VERSION,
|
|
199
279
|
writtenAt: Date.now(),
|
|
200
|
-
chains: this.
|
|
280
|
+
chains: this._mergeWithHistory(live),
|
|
201
281
|
};
|
|
202
282
|
this._writeSnapshot(snapshot);
|
|
203
283
|
}
|
|
204
284
|
|
|
285
|
+
/** Fold any terminal chains in a live listing into the bounded history cache, so they are not lost once WrfcController's own in-memory cleanup drops them from listChains(). */
|
|
286
|
+
private _recordTerminalHistory(chains: readonly WrfcChain[]): void {
|
|
287
|
+
for (const chain of chains) {
|
|
288
|
+
if (!isNonTerminal(chain.state)) this._upsertHistoryChain(chain);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Insert/replace `chain` in the terminal-history cache (by id), then trim to MAX_TERMINAL_HISTORY, most-recently-completed first. */
|
|
293
|
+
private _upsertHistoryChain(chain: WrfcChain): void {
|
|
294
|
+
const next = this._terminalHistory.filter((c) => c.id !== chain.id);
|
|
295
|
+
next.push(chain);
|
|
296
|
+
this._terminalHistory = takeMostRecentTerminal(next, MAX_TERMINAL_HISTORY);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Combine the live chains the controller is currently tracking with any
|
|
301
|
+
* historical terminal chains the live map has already forgotten (e.g. after
|
|
302
|
+
* WrfcController's own 60s in-memory cleanup) — so the persisted file never
|
|
303
|
+
* loses a terminal chain just because the live map stopped carrying it.
|
|
304
|
+
* Live entries win on id collision since they are always the freshest copy.
|
|
305
|
+
*/
|
|
306
|
+
private _mergeWithHistory(live: readonly WrfcChain[]): WrfcChain[] {
|
|
307
|
+
const liveIds = new Set(live.map((c) => c.id));
|
|
308
|
+
const historyOnly = this._terminalHistory.filter((c) => !liveIds.has(c.id));
|
|
309
|
+
return [...live, ...historyOnly];
|
|
310
|
+
}
|
|
311
|
+
|
|
205
312
|
private _writeSnapshot(snapshot: WrfcSnapshot): void {
|
|
206
313
|
try {
|
|
207
314
|
atomicWriteFileSync(this.snapshotPath, JSON.stringify(snapshot), { mkdirp: true });
|