@pellux/goodvibes-tui 0.20.3 → 0.22.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 +50 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +4 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +658 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +31 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +14 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/context-auto-compact.ts +77 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +32 -1
- package/src/input/commands/control-room-runtime.ts +10 -10
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -4
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +128 -12
- package/src/input/handler-modal-token-routes.ts +22 -5
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +73 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +6 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +378 -0
- package/src/input/settings-modal-mutations.ts +157 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +236 -232
- package/src/main.ts +93 -85
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +5 -1
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/session-maintenance.ts +66 -15
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +118 -13
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +90 -10
- package/src/renderer/shell-surface.ts +10 -0
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +18 -0
- package/src/runtime/bootstrap-core.ts +145 -13
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +9 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +27 -1
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/panels/knowledge-panel.ts +0 -345
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, statSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
4
|
+
import { readVersioned } from '@/config/read-versioned.ts';
|
|
5
|
+
|
|
3
6
|
import type {
|
|
4
7
|
OnboardingAcknowledgementRuntimeState,
|
|
5
8
|
OnboardingAcknowledgementTarget,
|
|
@@ -10,6 +13,24 @@ import type {
|
|
|
10
13
|
|
|
11
14
|
const ONBOARDING_RUNTIME_STATE_FILE = 'onboarding-state.json';
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Lockfile serialisation for writeOnboardingAcknowledgementState.
|
|
18
|
+
*
|
|
19
|
+
* Mechanism: O_EXCL advisory lockfile in the same directory as the state file.
|
|
20
|
+
* This is the simplest correct approach for two same-host processes (daemon
|
|
21
|
+
* + TUI) that both run this read-modify-write path:
|
|
22
|
+
*
|
|
23
|
+
* - Acquire: open(<statefile>.lock, O_CREAT|O_EXCL|O_WRONLY) — atomic on POSIX.
|
|
24
|
+
* - Stale detection: if the lockfile mtime is >= LOCK_STALE_MS old, force-remove.
|
|
25
|
+
* - Retry: up to LOCK_MAX_RETRIES rapid non-blocking attempts (no sleep — main-thread safe).
|
|
26
|
+
* - Release: unlink the lockfile (best-effort on failure).
|
|
27
|
+
*
|
|
28
|
+
* O_EXCL was chosen over flock(2) because it works on all POSIX targets
|
|
29
|
+
* without requiring an open fd on the guarded file, and is Bun-compatible.
|
|
30
|
+
*/
|
|
31
|
+
const LOCK_MAX_RETRIES = 10;
|
|
32
|
+
const LOCK_STALE_MS = 5_000;
|
|
33
|
+
|
|
13
34
|
export interface OnboardingRuntimeStateRecord {
|
|
14
35
|
readonly scope: OnboardingStateScope;
|
|
15
36
|
readonly path: string;
|
|
@@ -37,30 +58,67 @@ function resolveStatePath(
|
|
|
37
58
|
: shellPaths.resolveUserPath('tui', ONBOARDING_RUNTIME_STATE_FILE);
|
|
38
59
|
}
|
|
39
60
|
|
|
40
|
-
function
|
|
41
|
-
return
|
|
61
|
+
function isAcknowledgementTarget(value: string): value is OnboardingAcknowledgementTarget {
|
|
62
|
+
return value === 'providers' || value === 'subscriptions' || value === 'auth';
|
|
42
63
|
}
|
|
43
64
|
|
|
44
|
-
function
|
|
45
|
-
|
|
65
|
+
function isRuntimeStatePayload(value: unknown): value is OnboardingAcknowledgementRuntimeState {
|
|
66
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
67
|
+
const v = value as Record<string, unknown>;
|
|
68
|
+
if (v['version'] !== 1) return false;
|
|
69
|
+
if (typeof v['updatedAt'] !== 'number' || !Number.isFinite(v['updatedAt'] as number)) return false;
|
|
70
|
+
if (typeof v['source'] !== 'string') return false;
|
|
71
|
+
const mode = v['mode'];
|
|
72
|
+
if (mode !== undefined && mode !== 'new' && mode !== 'edit' && mode !== 'reopen') return false;
|
|
73
|
+
if (v['workspaceRoot'] !== undefined && typeof v['workspaceRoot'] !== 'string') return false;
|
|
74
|
+
if (typeof v['acknowledgements'] !== 'object' || v['acknowledgements'] === null) return false;
|
|
75
|
+
|
|
76
|
+
return Object.entries(v['acknowledgements'] as Record<string, unknown>).every(
|
|
77
|
+
([key, entry]) => isAcknowledgementTarget(key) && typeof entry === 'boolean',
|
|
78
|
+
);
|
|
46
79
|
}
|
|
47
80
|
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
// ─── Lock helpers ──────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function stateLockPath(statePath: string): string {
|
|
84
|
+
return `${statePath}.lock`;
|
|
50
85
|
}
|
|
51
86
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Attempt to acquire an O_EXCL advisory lock. Returns true if acquired.
|
|
89
|
+
* Stale locks (older than LOCK_STALE_MS) are forcibly removed before retry.
|
|
90
|
+
*
|
|
91
|
+
* Retries are non-blocking (no sleep between attempts) so this function is
|
|
92
|
+
* safe to call on the main thread. Each O_EXCL open is a single syscall;
|
|
93
|
+
* 10 rapid retries add negligible latency and are safe for a one-shot path.
|
|
94
|
+
*/
|
|
95
|
+
function acquireLock(lp: string): boolean {
|
|
96
|
+
for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
|
|
97
|
+
// Stale-lock takeover: if the lockfile is old enough, forcibly remove it.
|
|
98
|
+
try {
|
|
99
|
+
const st = statSync(lp);
|
|
100
|
+
if (Date.now() - st.mtimeMs >= LOCK_STALE_MS) {
|
|
101
|
+
try { unlinkSync(lp); } catch { /* another process may have beaten us */ }
|
|
102
|
+
}
|
|
103
|
+
} catch { /* lockfile does not exist — expected happy path */ }
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
// 'wx' ≡ O_CREAT | O_EXCL | O_WRONLY — fails atomically if file exists.
|
|
107
|
+
const fd = openSync(lp, 'wx');
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
return true;
|
|
110
|
+
} catch { /* file exists, held by another process */ }
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Release the advisory lockfile (best-effort). */
|
|
116
|
+
function releaseLock(lp: string): void {
|
|
117
|
+
try { unlinkSync(lp); } catch { /* best-effort */ }
|
|
62
118
|
}
|
|
63
119
|
|
|
120
|
+
// ─── Public API ────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
64
122
|
export function getOnboardingRuntimeStatePath(
|
|
65
123
|
shellPaths: OnboardingShellPaths,
|
|
66
124
|
scope: OnboardingStateScope = 'project',
|
|
@@ -73,42 +131,39 @@ export function readOnboardingRuntimeState(
|
|
|
73
131
|
scope: OnboardingStateScope = 'project',
|
|
74
132
|
): OnboardingRuntimeStateRecord {
|
|
75
133
|
const path = resolveStatePath(shellPaths, scope);
|
|
76
|
-
|
|
134
|
+
|
|
135
|
+
const parsed = readVersioned<OnboardingAcknowledgementRuntimeState & { version: number }>(
|
|
136
|
+
path,
|
|
137
|
+
{ currentVersion: 1, onUnknown: 'quarantine' },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (parsed === null) {
|
|
141
|
+
// readVersioned returns null for: missing file, corrupt JSON, or
|
|
142
|
+
// unrecognised version (in which case it renames to <path>.unrecognized).
|
|
143
|
+
const nowExists = existsSync(path);
|
|
144
|
+
const quarantined = existsSync(`${path}.unrecognized`);
|
|
77
145
|
return {
|
|
78
146
|
scope,
|
|
79
147
|
path,
|
|
80
|
-
exists:
|
|
148
|
+
exists: nowExists || quarantined,
|
|
81
149
|
payload: null,
|
|
150
|
+
...(quarantined
|
|
151
|
+
? { parseError: 'Unrecognised or corrupt onboarding state file; quarantined.' }
|
|
152
|
+
: {}),
|
|
82
153
|
};
|
|
83
154
|
}
|
|
84
155
|
|
|
85
|
-
|
|
86
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
87
|
-
if (!isRuntimeStatePayload(parsed)) {
|
|
88
|
-
return {
|
|
89
|
-
scope,
|
|
90
|
-
path,
|
|
91
|
-
exists: true,
|
|
92
|
-
payload: null,
|
|
93
|
-
parseError: 'Invalid onboarding runtime state payload.',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
scope,
|
|
99
|
-
path,
|
|
100
|
-
exists: true,
|
|
101
|
-
payload: parsed,
|
|
102
|
-
};
|
|
103
|
-
} catch (error) {
|
|
156
|
+
if (!isRuntimeStatePayload(parsed)) {
|
|
104
157
|
return {
|
|
105
158
|
scope,
|
|
106
159
|
path,
|
|
107
160
|
exists: true,
|
|
108
161
|
payload: null,
|
|
109
|
-
parseError:
|
|
162
|
+
parseError: 'Invalid onboarding runtime state payload.',
|
|
110
163
|
};
|
|
111
164
|
}
|
|
165
|
+
|
|
166
|
+
return { scope, path, exists: true, payload: parsed };
|
|
112
167
|
}
|
|
113
168
|
|
|
114
169
|
export function writeOnboardingAcknowledgementState(
|
|
@@ -117,24 +172,47 @@ export function writeOnboardingAcknowledgementState(
|
|
|
117
172
|
): OnboardingRuntimeStateRecord {
|
|
118
173
|
const scope = options.scope ?? 'project';
|
|
119
174
|
const path = resolveStatePath(shellPaths, scope);
|
|
120
|
-
const
|
|
121
|
-
const updatedAt = options.updatedAt ?? Date.now();
|
|
122
|
-
const payload: OnboardingAcknowledgementRuntimeState = {
|
|
123
|
-
version: 1,
|
|
124
|
-
updatedAt,
|
|
125
|
-
source: options.source,
|
|
126
|
-
...(options.mode ? { mode: options.mode } : {}),
|
|
127
|
-
...(options.workspaceRoot ?? shellPaths.workingDirectory
|
|
128
|
-
? { workspaceRoot: options.workspaceRoot ?? shellPaths.workingDirectory }
|
|
129
|
-
: {}),
|
|
130
|
-
acknowledgements: {
|
|
131
|
-
...(existing.payload?.acknowledgements ?? {}),
|
|
132
|
-
[options.target]: options.acknowledged,
|
|
133
|
-
},
|
|
134
|
-
};
|
|
175
|
+
const lp = stateLockPath(path);
|
|
135
176
|
|
|
177
|
+
// Ensure the parent directory exists before we try to create the lockfile.
|
|
136
178
|
mkdirSync(dirname(path), { recursive: true });
|
|
137
|
-
|
|
179
|
+
|
|
180
|
+
const acquired = acquireLock(lp);
|
|
181
|
+
if (!acquired) {
|
|
182
|
+
// Lock exhaustion: another process has held the lock for all LOCK_MAX_RETRIES
|
|
183
|
+
// attempts. Proceeding without the lock — the atomic write (rename) prevents
|
|
184
|
+
// torn files, but under true concurrent contention a concurrent read-modify-write
|
|
185
|
+
// may result in a lost-update (last writer wins). Surfaced here so it is
|
|
186
|
+
// detectable in logs rather than silently discarded.
|
|
187
|
+
console.warn(
|
|
188
|
+
'[goodvibes] onboarding-state: lock exhausted, proceeding without lock.',
|
|
189
|
+
{ path, target: options.target, source: options.source },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Re-read (inside the lock when acquired; best-effort when degraded) to get
|
|
195
|
+
// the freshest acknowledgements state, eliminating the read-modify-write
|
|
196
|
+
// race between daemon and TUI under normal conditions.
|
|
197
|
+
const existing = readOnboardingRuntimeState(shellPaths, scope);
|
|
198
|
+
const updatedAt = options.updatedAt ?? Date.now();
|
|
199
|
+
const ws = options.workspaceRoot ?? shellPaths.workingDirectory;
|
|
200
|
+
const payload: OnboardingAcknowledgementRuntimeState = {
|
|
201
|
+
version: 1,
|
|
202
|
+
updatedAt,
|
|
203
|
+
source: options.source,
|
|
204
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
205
|
+
...(ws ? { workspaceRoot: ws } : {}),
|
|
206
|
+
acknowledgements: {
|
|
207
|
+
...(existing.payload?.acknowledgements ?? {}),
|
|
208
|
+
[options.target]: options.acknowledged,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
213
|
+
} finally {
|
|
214
|
+
if (acquired) releaseLock(lp);
|
|
215
|
+
}
|
|
138
216
|
|
|
139
217
|
return readOnboardingRuntimeState(shellPaths, scope);
|
|
140
218
|
}
|
|
@@ -340,6 +340,26 @@ export interface OnboardingCheckMarkersState {
|
|
|
340
340
|
readonly effective: OnboardingCheckMarkerState | null;
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
export interface WizardProgressPayload {
|
|
344
|
+
readonly version: 1;
|
|
345
|
+
readonly savedAt: number;
|
|
346
|
+
readonly mode: OnboardingMode;
|
|
347
|
+
readonly stepIndex: number;
|
|
348
|
+
/** Serialised Map<fieldId, boolean> entries */
|
|
349
|
+
readonly toggleState: ReadonlyArray<readonly [string, boolean]>;
|
|
350
|
+
/** Serialised Map<fieldId, string> entries */
|
|
351
|
+
readonly radioState: ReadonlyArray<readonly [string, string]>;
|
|
352
|
+
/** Serialised Map<fieldId, string> entries */
|
|
353
|
+
readonly textState: ReadonlyArray<readonly [string, string]>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface WizardProgressState {
|
|
357
|
+
readonly path: string;
|
|
358
|
+
readonly exists: boolean;
|
|
359
|
+
readonly payload: WizardProgressPayload | null;
|
|
360
|
+
readonly parseError?: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
343
363
|
export interface WriteOnboardingCheckMarkerOptions {
|
|
344
364
|
readonly scope?: OnboardingStateScope;
|
|
345
365
|
readonly checkedAt?: number;
|
package/src/runtime/services.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { WatcherRegistry } from '@pellux/goodvibes-sdk/platform/watchers';
|
|
|
11
11
|
import { ArtifactStore } from '@pellux/goodvibes-sdk/platform/artifacts';
|
|
12
12
|
import {
|
|
13
13
|
HomeGraphService,
|
|
14
|
+
GOODVIBES_AGENT_KNOWLEDGE_DB_FILE,
|
|
14
15
|
HOME_GRAPH_KNOWLEDGE_EXTENSION,
|
|
15
16
|
KnowledgeService,
|
|
16
17
|
KnowledgeSemanticService,
|
|
@@ -77,6 +78,7 @@ import { ComponentHealthMonitor } from '@/runtime/index.ts';
|
|
|
77
78
|
import { WorktreeRegistry } from '@/runtime/index.ts';
|
|
78
79
|
import { SandboxSessionRegistry } from '@/runtime/index.ts';
|
|
79
80
|
import { createShellPathService, type ShellPathService } from '@/runtime/index.ts';
|
|
81
|
+
import { isFeatureFlagEnabled } from './surface-feature-flags.ts';
|
|
80
82
|
import type { FeatureFlagManager } from '@/runtime/index.ts';
|
|
81
83
|
import { createFeatureFlagManager } from '@/runtime/index.ts';
|
|
82
84
|
import { PolicyRuntimeState } from '@/runtime/index.ts';
|
|
@@ -169,6 +171,7 @@ export interface RuntimeServices {
|
|
|
169
171
|
readonly gatewayMethods: GatewayMethodCatalog;
|
|
170
172
|
readonly artifactStore: ArtifactStore;
|
|
171
173
|
readonly knowledgeService: KnowledgeService;
|
|
174
|
+
readonly agentKnowledgeService: KnowledgeService;
|
|
172
175
|
readonly homeGraphService: HomeGraphService;
|
|
173
176
|
readonly projectPlanningService: ProjectPlanningService;
|
|
174
177
|
readonly projectPlanningProjectId: string;
|
|
@@ -419,6 +422,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
419
422
|
configManager,
|
|
420
423
|
dbFileName: REGULAR_KNOWLEDGE_DB_FILE,
|
|
421
424
|
});
|
|
425
|
+
const agentKnowledgeStore = new KnowledgeStore({
|
|
426
|
+
configManager,
|
|
427
|
+
dbFileName: GOODVIBES_AGENT_KNOWLEDGE_DB_FILE,
|
|
428
|
+
});
|
|
422
429
|
const homeGraphKnowledgeStore = new KnowledgeStore({
|
|
423
430
|
configManager,
|
|
424
431
|
dbFileName: HOME_GRAPH_KNOWLEDGE_DB_FILE,
|
|
@@ -436,12 +443,22 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
436
443
|
maxLlmSourcesPerReindex: 3,
|
|
437
444
|
objectProfiles: HOME_GRAPH_KNOWLEDGE_EXTENSION.objectProfiles,
|
|
438
445
|
});
|
|
446
|
+
const agentKnowledgeSemanticService = new KnowledgeSemanticService(agentKnowledgeStore, {
|
|
447
|
+
llm: knowledgeSemanticLlm,
|
|
448
|
+
maxLlmSourcesPerReindex: 3,
|
|
449
|
+
});
|
|
439
450
|
const knowledgeService = new KnowledgeService(knowledgeStore, artifactStore, undefined, {
|
|
440
451
|
memoryRegistry,
|
|
441
452
|
runtimeBus: options.runtimeBus,
|
|
442
453
|
semanticService: knowledgeSemanticService,
|
|
443
454
|
});
|
|
444
455
|
knowledgeService.attachRuntimeBus(options.runtimeBus);
|
|
456
|
+
const agentKnowledgeService = new KnowledgeService(agentKnowledgeStore, artifactStore, undefined, {
|
|
457
|
+
memoryRegistry,
|
|
458
|
+
runtimeBus: options.runtimeBus,
|
|
459
|
+
semanticService: agentKnowledgeSemanticService,
|
|
460
|
+
});
|
|
461
|
+
agentKnowledgeService.attachRuntimeBus(options.runtimeBus);
|
|
445
462
|
const homeGraphService = new HomeGraphService(homeGraphKnowledgeStore, artifactStore, {
|
|
446
463
|
semanticService: homeGraphSemanticService,
|
|
447
464
|
});
|
|
@@ -469,6 +486,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
469
486
|
searchService: webSearchService,
|
|
470
487
|
ingestService: knowledgeService,
|
|
471
488
|
}));
|
|
489
|
+
agentKnowledgeSemanticService.setGapRepairer(createWebKnowledgeGapRepairer({
|
|
490
|
+
searchService: webSearchService,
|
|
491
|
+
ingestService: agentKnowledgeService,
|
|
492
|
+
}));
|
|
472
493
|
homeGraphSemanticService.setGapRepairer(createWebKnowledgeGapRepairer({
|
|
473
494
|
searchService: webSearchService,
|
|
474
495
|
ingestService: homeGraphService,
|
|
@@ -510,7 +531,11 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
510
531
|
const worktreeRegistry = new WorktreeRegistry(workingDirectory);
|
|
511
532
|
const webhookNotifier = new WebhookNotifier();
|
|
512
533
|
const replayEngine = new DeterministicReplayEngine(workingDirectory);
|
|
513
|
-
const providerOptimizer = new ProviderOptimizer(
|
|
534
|
+
const providerOptimizer = new ProviderOptimizer(
|
|
535
|
+
providerRegistry,
|
|
536
|
+
providerCapabilityRegistry,
|
|
537
|
+
isFeatureFlagEnabled(configManager, 'provider-optimizer'),
|
|
538
|
+
);
|
|
514
539
|
const sessionMemoryStore = new SessionMemoryStore();
|
|
515
540
|
const sessionLineageTracker = new SessionLineageTracker();
|
|
516
541
|
const sessionChangeTracker = new SessionChangeTracker();
|
|
@@ -600,6 +625,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
600
625
|
gatewayMethods,
|
|
601
626
|
artifactStore,
|
|
602
627
|
knowledgeService,
|
|
628
|
+
agentKnowledgeService,
|
|
603
629
|
homeGraphService,
|
|
604
630
|
projectPlanningService,
|
|
605
631
|
projectPlanningProjectId,
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* Architecture
|
|
6
|
+
* ─────────────
|
|
7
|
+
* 1. `createWrfcPersistence` subscribes to all 7 WORKFLOW_* events on the
|
|
8
|
+
* runtimeBus. Each event schedules a trailing-debounced snapshot (250 ms)
|
|
9
|
+
* so event bursts from a single state transition don't thrash disk.
|
|
10
|
+
*
|
|
11
|
+
* 2. `WrfcPersistence.rehydrate(router)` is called once on boot (after the
|
|
12
|
+
* SystemMessageRouter is available). It reads the snapshot, identifies
|
|
13
|
+
* chains that were in a non-terminal state at last write, and emits a
|
|
14
|
+
* high-priority 'wrfc' system message per interrupted chain. The
|
|
15
|
+
* `interruptedChains` accessor makes the data available for panel reads
|
|
16
|
+
* without coupling this module to wrfc-panel.ts.
|
|
17
|
+
*
|
|
18
|
+
* 3. Snapshot lifecycle:
|
|
19
|
+
* - Terminal chains ('passed' | 'failed') are pruned from the snapshot
|
|
20
|
+
* after rehydration surfaces them.
|
|
21
|
+
* - A corrupt or version-mismatched snapshot is quarantined by renaming it
|
|
22
|
+
* to `<path>.unrecognized` — never a hard crash.
|
|
23
|
+
*
|
|
24
|
+
* Snapshot path: `.goodvibes/tui/wrfc-chains.json`
|
|
25
|
+
* Snapshot schema: `{ version: 1, writtenAt: number, chains: WrfcChain[] }`
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { WrfcChain, WrfcState } from '@pellux/goodvibes-sdk/platform/agents';
|
|
29
|
+
import type { RuntimeEventBus, WorkflowEvent } from '@/runtime/index.ts';
|
|
30
|
+
import { atomicWriteFileSync } from '../config/atomic-write.ts';
|
|
31
|
+
import { readVersioned } from '../config/read-versioned.ts';
|
|
32
|
+
import type { SystemMessageRouter } from '../core/system-message-router.ts';
|
|
33
|
+
|
|
34
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const SNAPSHOT_VERSION = 1;
|
|
37
|
+
const DEBOUNCE_MS = 250;
|
|
38
|
+
|
|
39
|
+
/** Terminal states — chains in these states will not be surfaced as interrupted. */
|
|
40
|
+
const TERMINAL_STATES = new Set<WrfcState>(['passed', 'failed']);
|
|
41
|
+
|
|
42
|
+
/** Non-terminal (interruptible) states. */
|
|
43
|
+
function isNonTerminal(state: WrfcState): boolean {
|
|
44
|
+
return !TERMINAL_STATES.has(state);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Snapshot schema ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
interface WrfcSnapshot {
|
|
50
|
+
readonly version: number;
|
|
51
|
+
readonly writtenAt: number;
|
|
52
|
+
readonly chains: WrfcChain[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Subset of WrfcController needed by this module. */
|
|
58
|
+
export interface WrfcControllerReader {
|
|
59
|
+
listChains(): WrfcChain[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WrfcPersistenceOptions {
|
|
63
|
+
/** Absolute path to the snapshot file, e.g. `.goodvibes/tui/wrfc-chains.json`. */
|
|
64
|
+
readonly snapshotPath: string;
|
|
65
|
+
/** Factory for the current SystemMessageRouter — may return null before it is wired. */
|
|
66
|
+
readonly getSystemMessageRouter: () => SystemMessageRouter | null;
|
|
67
|
+
/** WrfcController reader — only listChains() is needed. */
|
|
68
|
+
readonly controller: WrfcControllerReader;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WrfcPersistence {
|
|
72
|
+
/**
|
|
73
|
+
* Chains from the previous process that were in a non-terminal state.
|
|
74
|
+
* Populated only after `rehydrate()` is called.
|
|
75
|
+
*/
|
|
76
|
+
readonly interruptedChains: readonly WrfcChain[];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Subscribe to runtimeBus workflow events and start persisting snapshots.
|
|
80
|
+
* Returns an array of unsubscribe functions to be added to `runtimeUnsubs`.
|
|
81
|
+
*/
|
|
82
|
+
attach(runtimeBus: RuntimeEventBus): Array<() => void>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the snapshot from a previous process, surface any interrupted chains
|
|
86
|
+
* as system messages, and prune terminal chains from the snapshot on disk.
|
|
87
|
+
*
|
|
88
|
+
* Must be called after the SystemMessageRouter is available.
|
|
89
|
+
*/
|
|
90
|
+
rehydrate(): void;
|
|
91
|
+
|
|
92
|
+
/** Flush any pending debounced snapshot immediately (used in tests). */
|
|
93
|
+
flush(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Implementation ──────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
class WrfcPersistenceImpl implements WrfcPersistence {
|
|
99
|
+
private readonly snapshotPath: string;
|
|
100
|
+
private readonly getSystemMessageRouter: () => SystemMessageRouter | null;
|
|
101
|
+
private readonly controller: WrfcControllerReader;
|
|
102
|
+
|
|
103
|
+
private _interruptedChains: WrfcChain[] = [];
|
|
104
|
+
private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
105
|
+
|
|
106
|
+
constructor(options: WrfcPersistenceOptions) {
|
|
107
|
+
this.snapshotPath = options.snapshotPath;
|
|
108
|
+
this.getSystemMessageRouter = options.getSystemMessageRouter;
|
|
109
|
+
this.controller = options.controller;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get interruptedChains(): readonly WrfcChain[] {
|
|
113
|
+
return this._interruptedChains;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
attach(runtimeBus: RuntimeEventBus): Array<() => void> {
|
|
117
|
+
const schedule = (): void => this._scheduleSnapshot();
|
|
118
|
+
|
|
119
|
+
const events: WorkflowEvent['type'][] = [
|
|
120
|
+
'WORKFLOW_CHAIN_CREATED',
|
|
121
|
+
'WORKFLOW_STATE_CHANGED',
|
|
122
|
+
'WORKFLOW_REVIEW_COMPLETED',
|
|
123
|
+
'WORKFLOW_FIX_ATTEMPTED',
|
|
124
|
+
'WORKFLOW_GATE_RESULT',
|
|
125
|
+
'WORKFLOW_CHAIN_PASSED',
|
|
126
|
+
'WORKFLOW_CHAIN_FAILED',
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
return events.map((eventType) =>
|
|
130
|
+
runtimeBus.on<Extract<WorkflowEvent, { type: typeof eventType }>>(eventType, schedule),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
rehydrate(): void {
|
|
135
|
+
const snapshot = this._readSnapshot();
|
|
136
|
+
if (!snapshot) return;
|
|
137
|
+
|
|
138
|
+
const interrupted = snapshot.chains.filter((c) => isNonTerminal(c.state));
|
|
139
|
+
this._interruptedChains = interrupted;
|
|
140
|
+
|
|
141
|
+
const router = this.getSystemMessageRouter();
|
|
142
|
+
for (const chain of interrupted) {
|
|
143
|
+
const msg =
|
|
144
|
+
`[WRFC] Chain ${chain.id.slice(0, 12)} (${chain.task.slice(0, 60).trim()}) ` +
|
|
145
|
+
`was interrupted by a restart — state was '${chain.state}' ` +
|
|
146
|
+
`after ${chain.reviewCycles} review cycle${chain.reviewCycles !== 1 ? 's' : ''}`;
|
|
147
|
+
router?.wrfc(msg, 'high');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Prune terminal chains from the on-disk snapshot after surfacing.
|
|
151
|
+
if (snapshot.chains.length !== interrupted.length) {
|
|
152
|
+
const pruned: WrfcSnapshot = {
|
|
153
|
+
version: SNAPSHOT_VERSION,
|
|
154
|
+
writtenAt: Date.now(),
|
|
155
|
+
chains: interrupted,
|
|
156
|
+
};
|
|
157
|
+
this._writeSnapshot(pruned);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
flush(): void {
|
|
162
|
+
if (this._debounceTimer !== null) {
|
|
163
|
+
clearTimeout(this._debounceTimer);
|
|
164
|
+
this._debounceTimer = null;
|
|
165
|
+
}
|
|
166
|
+
this._writeCurrentSnapshot();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
private _scheduleSnapshot(): void {
|
|
172
|
+
if (this._debounceTimer !== null) {
|
|
173
|
+
clearTimeout(this._debounceTimer);
|
|
174
|
+
}
|
|
175
|
+
this._debounceTimer = setTimeout(() => {
|
|
176
|
+
this._debounceTimer = null;
|
|
177
|
+
this._writeCurrentSnapshot();
|
|
178
|
+
}, DEBOUNCE_MS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private _writeCurrentSnapshot(): void {
|
|
182
|
+
const snapshot: WrfcSnapshot = {
|
|
183
|
+
version: SNAPSHOT_VERSION,
|
|
184
|
+
writtenAt: Date.now(),
|
|
185
|
+
chains: this.controller.listChains(),
|
|
186
|
+
};
|
|
187
|
+
this._writeSnapshot(snapshot);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private _writeSnapshot(snapshot: WrfcSnapshot): void {
|
|
191
|
+
try {
|
|
192
|
+
atomicWriteFileSync(this.snapshotPath, JSON.stringify(snapshot), { mkdirp: true });
|
|
193
|
+
} catch {
|
|
194
|
+
// Best-effort — never crash the TUI over a persistence failure.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private _readSnapshot(): WrfcSnapshot | null {
|
|
199
|
+
// readVersioned handles: missing file → null, corrupt JSON → quarantine to
|
|
200
|
+
// .unrecognized, future/unrecognised version → quarantine, stepwise migration.
|
|
201
|
+
const raw = readVersioned<WrfcSnapshot & { version: number }>(
|
|
202
|
+
this.snapshotPath,
|
|
203
|
+
{
|
|
204
|
+
currentVersion: SNAPSHOT_VERSION,
|
|
205
|
+
// v0 → v1: pass through the data as-is (safety net only).
|
|
206
|
+
// NOTE: readVersioned does NOT coerce missing/non-numeric version fields to 0.
|
|
207
|
+
// Files without a version field are quarantined immediately. This migration
|
|
208
|
+
// only fires for files that explicitly contain `version: 0`.
|
|
209
|
+
migrations: {
|
|
210
|
+
0: (d) => ({ ...d, version: 1 }),
|
|
211
|
+
},
|
|
212
|
+
onUnknown: 'quarantine',
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
if (!raw) return null;
|
|
216
|
+
|
|
217
|
+
// Narrow the application-level fields that readVersioned does not validate.
|
|
218
|
+
if (typeof raw['writtenAt'] !== 'number' || !Array.isArray(raw['chains'])) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return raw as WrfcSnapshot;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create a WrfcPersistence instance.
|
|
230
|
+
*
|
|
231
|
+
* Call `persistence.attach(runtimeBus)` and push the returned unsubs into
|
|
232
|
+
* `runtimeUnsubs`. Call `persistence.rehydrate()` once the SystemMessageRouter
|
|
233
|
+
* is available.
|
|
234
|
+
*/
|
|
235
|
+
export function createWrfcPersistence(options: WrfcPersistenceOptions): WrfcPersistence {
|
|
236
|
+
return new WrfcPersistenceImpl(options);
|
|
237
|
+
}
|
|
@@ -17,6 +17,15 @@ export type BlockingInputHandlerOptions = {
|
|
|
17
17
|
render: () => void;
|
|
18
18
|
loadRecoveryConversation: () => SessionSnapshot | null;
|
|
19
19
|
deleteRecoveryFile: () => void;
|
|
20
|
+
/**
|
|
21
|
+
* Optional callback invoked after Ctrl+R restore to reopen panels captured in
|
|
22
|
+
* the recovery snapshot's returnContext. When provided (as wired in main.ts),
|
|
23
|
+
* the callback iterates snapshot.returnContext.openPanels and calls
|
|
24
|
+
* panelManager.open() for each entry, then panelManager.show() + render() to
|
|
25
|
+
* restore the panel posture from the recovered session. When omitted, panel
|
|
26
|
+
* posture is not restored.
|
|
27
|
+
*/
|
|
28
|
+
reopenPanels?: (snapshot: SessionSnapshot) => void;
|
|
20
29
|
};
|
|
21
30
|
|
|
22
31
|
export type BlockingInputHandlerResult = {
|
|
@@ -38,6 +47,7 @@ export function handleBlockingShellInput(
|
|
|
38
47
|
render,
|
|
39
48
|
loadRecoveryConversation,
|
|
40
49
|
deleteRecoveryFile,
|
|
50
|
+
reopenPanels,
|
|
41
51
|
} = options;
|
|
42
52
|
|
|
43
53
|
if (pendingPermission) {
|
|
@@ -71,12 +81,17 @@ export function handleBlockingShellInput(
|
|
|
71
81
|
if (data === '\x12') {
|
|
72
82
|
const recovery = loadRecoveryConversation();
|
|
73
83
|
if (recovery) {
|
|
74
|
-
conversation.fromJSON({
|
|
84
|
+
conversation.fromJSON({
|
|
85
|
+
messages: recovery.messages as Parameters<typeof conversation.fromJSON>[0]['messages'],
|
|
86
|
+
title: recovery.title,
|
|
87
|
+
titleSource: recovery.titleSource,
|
|
88
|
+
});
|
|
89
|
+
reopenPanels?.(recovery);
|
|
75
90
|
systemMessageRouter.high('[Recovery] Session restored.');
|
|
91
|
+
deleteRecoveryFile();
|
|
76
92
|
} else {
|
|
77
93
|
systemMessageRouter.high('[Recovery] Failed to restore saved data.');
|
|
78
94
|
}
|
|
79
|
-
deleteRecoveryFile();
|
|
80
95
|
render();
|
|
81
96
|
return { handled: true, pendingPermission: null, recoveryPending: false };
|
|
82
97
|
}
|
|
@@ -88,10 +103,10 @@ export function handleBlockingShellInput(
|
|
|
88
103
|
return { handled: true, pendingPermission: null, recoveryPending: false };
|
|
89
104
|
}
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
// Stray key: leave the recovery prompt active so the user can still Ctrl+R or Esc.
|
|
107
|
+
systemMessageRouter.high('[Recovery] Ctrl+R to restore · Esc to discard');
|
|
93
108
|
render();
|
|
94
|
-
return { handled: false, pendingPermission
|
|
109
|
+
return { handled: false, pendingPermission, recoveryPending: true };
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
return { handled: false, pendingPermission, recoveryPending };
|