@pellux/goodvibes-tui 0.20.2 → 0.21.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 +33 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +3 -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 +662 -0
- package/src/cli/config-overrides.ts +68 -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 +14 -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 +12 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/read-versioned.ts +115 -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/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +31 -1
- package/src/input/commands/control-room-runtime.ts +5 -5
- 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/platform-sandbox-qemu.ts +60 -16
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- 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 +3 -3
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -2
- 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 +91 -12
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +55 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +5 -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 +4 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -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 +18 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-data.ts +304 -0
- package/src/input/settings-modal-mutations.ts +154 -0
- package/src/input/settings-modal.ts +182 -220
- package/src/main.ts +57 -57
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +4 -1
- 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/knowledge-panel.ts +3 -5
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- 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 +11 -10
- 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/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 +77 -8
- 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 +14 -0
- package/src/runtime/bootstrap-core.ts +121 -13
- package/src/runtime/bootstrap.ts +2 -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/sandbox-qemu-templates.ts +15 -0
- package/src/runtime/services.ts +21 -0
- 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/planning/project-planning-coordinator.ts +0 -543
|
@@ -29,7 +29,59 @@ import { registerBootstrapHookBridge } from '@/runtime/index.ts';
|
|
|
29
29
|
import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
|
|
30
30
|
import { createRuntimeServices, type RuntimeServices } from './services.ts';
|
|
31
31
|
import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
|
|
32
|
+
import { join } from 'node:path';
|
|
32
33
|
import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
|
|
34
|
+
import { createWrfcPersistence, type WrfcPersistence } from './wrfc-persistence.ts';
|
|
35
|
+
import type { SystemMessagePriority } from '../panels/system-messages-panel.ts';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Pre-router buffer
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const PRE_ROUTER_BUFFER_MAX = 100;
|
|
42
|
+
|
|
43
|
+
type BufferedWrfcMessage = {
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly priority: SystemMessagePriority;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Small bounded queue that accumulates WRFC system messages emitted before
|
|
50
|
+
* the SystemMessageRouter is attached. On attach the queue flushes in order.
|
|
51
|
+
* If the queue overflows (> PRE_ROUTER_BUFFER_MAX), the oldest entries are
|
|
52
|
+
* dropped and a summary message is prepended to the first flushed message.
|
|
53
|
+
*/
|
|
54
|
+
export class WrfcPreRouterBuffer {
|
|
55
|
+
private readonly queue: BufferedWrfcMessage[] = [];
|
|
56
|
+
private overflowCount = 0;
|
|
57
|
+
|
|
58
|
+
push(message: string, priority: SystemMessagePriority): void {
|
|
59
|
+
if (this.queue.length >= PRE_ROUTER_BUFFER_MAX) {
|
|
60
|
+
this.queue.shift();
|
|
61
|
+
this.overflowCount++;
|
|
62
|
+
}
|
|
63
|
+
this.queue.push({ message, priority });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
flush(router: import('../core/system-message-router.ts').SystemMessageRouter): void {
|
|
67
|
+
const dropped = this.overflowCount;
|
|
68
|
+
const pending = this.queue.splice(0);
|
|
69
|
+
this.overflowCount = 0;
|
|
70
|
+
if (dropped > 0) {
|
|
71
|
+
router.wrfc(
|
|
72
|
+
`[WRFC] Pre-router buffer overflowed: ${dropped} earliest message${dropped !== 1 ? 's' : ''} were dropped`,
|
|
73
|
+
'low',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
for (const item of pending) {
|
|
77
|
+
router.wrfc(item.message, item.priority);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get size(): number {
|
|
82
|
+
return this.queue.length;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
33
85
|
|
|
34
86
|
export interface BootstrapCoreState {
|
|
35
87
|
readonly userSessionId: string;
|
|
@@ -62,6 +114,11 @@ export interface BootstrapCoreState {
|
|
|
62
114
|
readonly requestRender: () => void;
|
|
63
115
|
readonly setRenderRequest: (fn: () => void) => void;
|
|
64
116
|
readonly runtimeSessionIdRef: { value: string };
|
|
117
|
+
/**
|
|
118
|
+
* WRFC chain persistence — call `rehydrate()` once after the SystemMessageRouter
|
|
119
|
+
* is wired so interrupted chains from a previous process are surfaced to the operator.
|
|
120
|
+
*/
|
|
121
|
+
readonly wrfcPersistence: WrfcPersistence;
|
|
65
122
|
}
|
|
66
123
|
|
|
67
124
|
export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
|
|
@@ -222,9 +279,9 @@ export async function initializeBootstrapCore(
|
|
|
222
279
|
overflowHandler: services.overflowHandler,
|
|
223
280
|
changeTracker: services.sessionChangeTracker,
|
|
224
281
|
});
|
|
225
|
-
installWrfcAgentToolGuard
|
|
226
|
-
|
|
227
|
-
|
|
282
|
+
// Note: installWrfcAgentToolGuard is called after routeOrBuffer is defined
|
|
283
|
+
// (further below) so the onTrace callback can route guard decisions through
|
|
284
|
+
// the pre-router buffer.
|
|
228
285
|
services.agentOrchestrator.setDependencies({
|
|
229
286
|
surfaceRoot: 'tui',
|
|
230
287
|
fileCache,
|
|
@@ -305,7 +362,26 @@ export async function initializeBootstrapCore(
|
|
|
305
362
|
void approvalBroker.start();
|
|
306
363
|
void sharedSessionBroker.start();
|
|
307
364
|
const runtimeSessionIdRef = { value: userSessionId };
|
|
308
|
-
const
|
|
365
|
+
const wrfcBuffer = new WrfcPreRouterBuffer();
|
|
366
|
+
// Smart ref: setting .value auto-flushes the pre-router buffer so events
|
|
367
|
+
// buffered before the SystemMessageRouter attaches are not permanently lost.
|
|
368
|
+
const systemMessageRouterRef = ((): { value: SystemMessageRouter | null } => {
|
|
369
|
+
let _value: SystemMessageRouter | null = null;
|
|
370
|
+
const ref = {} as { value: SystemMessageRouter | null };
|
|
371
|
+
Object.defineProperty(ref, 'value', {
|
|
372
|
+
get(): SystemMessageRouter | null { return _value; },
|
|
373
|
+
set(router: SystemMessageRouter | null): void {
|
|
374
|
+
_value = router;
|
|
375
|
+
if (router && wrfcBuffer.size > 0) {
|
|
376
|
+
wrfcBuffer.flush(router);
|
|
377
|
+
requestRender();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
enumerable: true,
|
|
381
|
+
configurable: true,
|
|
382
|
+
});
|
|
383
|
+
return ref;
|
|
384
|
+
})();
|
|
309
385
|
const conversationFollowUpRef: { value: ((item: ConversationFollowUpItem) => void) | null } = { value: null };
|
|
310
386
|
const { unsubs: runtimeUnsubs, agentStatusIntervalRef } = registerBootstrapRuntimeEvents({
|
|
311
387
|
runtimeBus,
|
|
@@ -318,21 +394,45 @@ export async function initializeBootstrapCore(
|
|
|
318
394
|
wrfcController: services.wrfcController,
|
|
319
395
|
});
|
|
320
396
|
|
|
397
|
+
// ── WRFC chain persistence ──────────────────────────────────────────────────────────
|
|
398
|
+
const wrfcPersistence = createWrfcPersistence({
|
|
399
|
+
snapshotPath: join(workingDir, '.goodvibes', 'tui', 'wrfc-chains.json'),
|
|
400
|
+
getSystemMessageRouter: () => systemMessageRouterRef.value,
|
|
401
|
+
controller: services.wrfcController,
|
|
402
|
+
});
|
|
403
|
+
runtimeUnsubs.push(...wrfcPersistence.attach(runtimeBus));
|
|
404
|
+
// Flush any debounced snapshot on clean shutdown so final chain state is
|
|
405
|
+
// never silently dropped during a SIGINT/teardown (250ms debounce window).
|
|
406
|
+
bootstrapUnsubs.push(() => wrfcPersistence.flush());
|
|
407
|
+
|
|
321
408
|
// ── TUI-specific WRFC constraint-propagation event subscriptions (SDK 0.23.0) ──
|
|
322
409
|
// These supplement the SDK's registerBootstrapRuntimeEvents which handles the
|
|
323
410
|
// core WORKFLOW_REVIEW_COMPLETED / WORKFLOW_CHAIN_CREATED messages.
|
|
324
411
|
// The SDK does not surface constraint-specific system messages; the TUI layer
|
|
325
412
|
// adds them here so operators can observe constraint enumeration and violations
|
|
326
413
|
// in the SystemMessagesPanel and main conversation.
|
|
414
|
+
//
|
|
415
|
+
// Pre-router buffering: events that arrive before the SystemMessageRouter is
|
|
416
|
+
// attached are held in wrfcBuffer (bounded, 100 entries). When the router is
|
|
417
|
+
// set on systemMessageRouterRef, the smart setter flushes the buffer in order.
|
|
418
|
+
// If the buffer overflows, the oldest entries are dropped and a summary message
|
|
419
|
+
// is prepended to the first flushed batch.
|
|
420
|
+
const routeOrBuffer = (message: string, priority: SystemMessagePriority): void => {
|
|
421
|
+
const router = systemMessageRouterRef.value;
|
|
422
|
+
if (router) {
|
|
423
|
+
router.wrfc(message, priority);
|
|
424
|
+
} else {
|
|
425
|
+
wrfcBuffer.push(message, priority);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
327
429
|
runtimeUnsubs.push(
|
|
328
430
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_CONSTRAINTS_ENUMERATED' }>>(
|
|
329
431
|
'WORKFLOW_CONSTRAINTS_ENUMERATED',
|
|
330
432
|
({ payload }) => {
|
|
331
|
-
const router = systemMessageRouterRef.value;
|
|
332
|
-
if (!router) return;
|
|
333
433
|
const count = payload.constraints.length;
|
|
334
434
|
if (count > 0) {
|
|
335
|
-
|
|
435
|
+
routeOrBuffer(
|
|
336
436
|
`[WRFC] Engineer enumerated ${count} constraint${count !== 1 ? 's' : ''} for chain ${payload.chainId.slice(0, 12)}`,
|
|
337
437
|
'low',
|
|
338
438
|
);
|
|
@@ -345,11 +445,9 @@ export async function initializeBootstrapCore(
|
|
|
345
445
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_FIX_ATTEMPTED' }>>(
|
|
346
446
|
'WORKFLOW_FIX_ATTEMPTED',
|
|
347
447
|
({ payload }) => {
|
|
348
|
-
const router = systemMessageRouterRef.value;
|
|
349
|
-
if (!router) return;
|
|
350
448
|
const targetIds = payload.targetConstraintIds;
|
|
351
449
|
if (targetIds && targetIds.length > 0) {
|
|
352
|
-
|
|
450
|
+
routeOrBuffer(
|
|
353
451
|
`[WRFC] Fix #${payload.attempt} targeting ${targetIds.length} constraint${targetIds.length !== 1 ? 's' : ''} on chain ${payload.chainId.slice(0, 12)}`,
|
|
354
452
|
'low',
|
|
355
453
|
);
|
|
@@ -362,11 +460,9 @@ export async function initializeBootstrapCore(
|
|
|
362
460
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_REVIEW_COMPLETED' }>>(
|
|
363
461
|
'WORKFLOW_REVIEW_COMPLETED',
|
|
364
462
|
({ payload }) => {
|
|
365
|
-
const router = systemMessageRouterRef.value;
|
|
366
|
-
if (!router) return;
|
|
367
463
|
const unsatisfied = payload.unsatisfiedConstraintIds;
|
|
368
464
|
if (!payload.passed && unsatisfied && unsatisfied.length > 0) {
|
|
369
|
-
|
|
465
|
+
routeOrBuffer(
|
|
370
466
|
`[WRFC] ✗ Chain ${payload.chainId.slice(0, 12)}: ${unsatisfied.length} constraint violation${unsatisfied.length !== 1 ? 's' : ''} forced failure`,
|
|
371
467
|
'high',
|
|
372
468
|
);
|
|
@@ -376,6 +472,17 @@ export async function initializeBootstrapCore(
|
|
|
376
472
|
),
|
|
377
473
|
);
|
|
378
474
|
|
|
475
|
+
// Wire the WRFC agent-guard with the onTrace callback so routing decisions are
|
|
476
|
+
// observable via the same routeOrBuffer path as WORKFLOW_* events.
|
|
477
|
+
// Placed here (after routeOrBuffer is defined) so the closure is fully wired.
|
|
478
|
+
installWrfcAgentToolGuard(toolRegistry, {
|
|
479
|
+
getLastUserMessage: () => conversation.getLastUserMessage(),
|
|
480
|
+
onTrace: ({ kind, reason, task }) => {
|
|
481
|
+
const shortTask = task.length > 80 ? `${task.slice(0, 77)}...` : task;
|
|
482
|
+
routeOrBuffer(`[WRFC] Guard: ${reason} — task: "${shortTask}" (${kind})`, 'low');
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
|
|
379
486
|
// Subscribe to companion main-chat messages received from the daemon's HTTP layer.
|
|
380
487
|
// The daemon emits COMPANION_MESSAGE_RECEIVED on the runtime bus when a companion
|
|
381
488
|
// POST /api/sessions/:id/messages with kind='message' arrives.
|
|
@@ -535,5 +642,6 @@ export async function initializeBootstrapCore(
|
|
|
535
642
|
renderRequestRef.value = fn;
|
|
536
643
|
},
|
|
537
644
|
runtimeSessionIdRef,
|
|
645
|
+
wrfcPersistence,
|
|
538
646
|
};
|
|
539
647
|
}
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -180,6 +180,7 @@ export async function bootstrapRuntime(
|
|
|
180
180
|
requestRender,
|
|
181
181
|
setRenderRequest,
|
|
182
182
|
runtimeSessionIdRef,
|
|
183
|
+
wrfcPersistence,
|
|
183
184
|
} = await initializeBootstrapCore(stdout, options, (limit) => controlPlaneRecentEventsRef.value(limit));
|
|
184
185
|
const providerRegistry = services.providerRegistry;
|
|
185
186
|
const {
|
|
@@ -285,6 +286,7 @@ export async function bootstrapRuntime(
|
|
|
285
286
|
});
|
|
286
287
|
const systemMessageRouter = shell.systemMessageRouter;
|
|
287
288
|
systemMessageRouterRef.value = systemMessageRouter;
|
|
289
|
+
wrfcPersistence.rehydrate();
|
|
288
290
|
const commandRegistry = shell.commandRegistry;
|
|
289
291
|
const commandContext = shell.commandContext;
|
|
290
292
|
const gitStatusProvider = shell.gitStatusProvider;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteFileSync } from '../../config/atomic-write.ts';
|
|
3
3
|
import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
|
|
4
4
|
import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '../../config/index.ts';
|
|
5
5
|
import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
|
|
@@ -34,8 +34,7 @@ function readJsonObject(path: string): Record<string, unknown> {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function writeJsonObject(path: string, payload: Record<string, unknown>): void {
|
|
37
|
-
|
|
38
|
-
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
37
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
function setNestedValue(root: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
|
|
@@ -76,8 +75,7 @@ function restoreFile(path: string, previous: string | null, reload?: () => void)
|
|
|
76
75
|
if (previous === null) {
|
|
77
76
|
if (existsSync(path)) unlinkSync(path);
|
|
78
77
|
} else {
|
|
79
|
-
|
|
80
|
-
writeFileSync(path, previous, 'utf-8');
|
|
78
|
+
atomicWriteFileSync(path, previous, { mkdirp: true });
|
|
81
79
|
}
|
|
82
80
|
reload?.();
|
|
83
81
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
3
|
+
import { readVersioned } from '@/config/read-versioned.ts';
|
|
4
|
+
|
|
3
5
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
4
6
|
import type {
|
|
5
7
|
OnboardingCheckMarkerPayload,
|
|
@@ -25,50 +27,34 @@ function resolveMarkerPath(
|
|
|
25
27
|
: shellPaths.resolveUserPath('tui', ONBOARDING_CHECK_MARKER_FILE);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
29
|
-
return typeof value === 'object' && value !== null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isOnboardingMode(value: unknown): value is OnboardingCheckMarkerPayload['mode'] {
|
|
33
|
-
return value === 'new' || value === 'edit' || value === 'reopen';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
30
|
function isCheckMarkerPayload(value: unknown): value is OnboardingCheckMarkerPayload {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
&& typeof
|
|
42
|
-
&& Number.isFinite(
|
|
43
|
-
&& typeof
|
|
44
|
-
&& (
|
|
45
|
-
&&
|
|
31
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
32
|
+
const v = value as Record<string, unknown>;
|
|
33
|
+
return (
|
|
34
|
+
v['version'] === 1
|
|
35
|
+
&& typeof v['checkedAt'] === 'number'
|
|
36
|
+
&& Number.isFinite(v['checkedAt'] as number)
|
|
37
|
+
&& typeof v['updatedAt'] === 'number'
|
|
38
|
+
&& Number.isFinite(v['updatedAt'] as number)
|
|
39
|
+
&& typeof v['source'] === 'string'
|
|
40
|
+
&& (v['mode'] === undefined || v['mode'] === 'new' || v['mode'] === 'edit' || v['mode'] === 'reopen')
|
|
41
|
+
&& (v['workspaceRoot'] === undefined || typeof v['workspaceRoot'] === 'string')
|
|
42
|
+
);
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
function buildMissingMarkerState(
|
|
49
46
|
scope: OnboardingStateScope,
|
|
50
47
|
path: string,
|
|
51
48
|
): OnboardingCheckMarkerState {
|
|
52
|
-
return {
|
|
53
|
-
scope,
|
|
54
|
-
path,
|
|
55
|
-
exists: false,
|
|
56
|
-
payload: null,
|
|
57
|
-
};
|
|
49
|
+
return { scope, path, exists: false, payload: null };
|
|
58
50
|
}
|
|
59
51
|
|
|
60
|
-
function
|
|
52
|
+
function buildErrorMarkerState(
|
|
61
53
|
scope: OnboardingStateScope,
|
|
62
54
|
path: string,
|
|
63
55
|
parseError: string,
|
|
64
56
|
): OnboardingCheckMarkerState {
|
|
65
|
-
return {
|
|
66
|
-
scope,
|
|
67
|
-
path,
|
|
68
|
-
exists: true,
|
|
69
|
-
payload: null,
|
|
70
|
-
parseError,
|
|
71
|
-
};
|
|
57
|
+
return { scope, path, exists: true, payload: null, parseError };
|
|
72
58
|
}
|
|
73
59
|
|
|
74
60
|
function pickEffectiveMarker(
|
|
@@ -91,24 +77,32 @@ export function readOnboardingCheckMarker(
|
|
|
91
77
|
scope: OnboardingStateScope = 'user',
|
|
92
78
|
): OnboardingCheckMarkerState {
|
|
93
79
|
const path = resolveMarkerPath(shellPaths, scope);
|
|
94
|
-
if (!existsSync(path)) return buildMissingMarkerState(scope, path);
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
98
|
-
if (!isCheckMarkerPayload(parsed)) {
|
|
99
|
-
return buildParseErrorState(scope, path, 'Invalid onboarding check marker payload.');
|
|
100
|
-
}
|
|
101
80
|
|
|
102
|
-
|
|
81
|
+
const parsed = readVersioned<OnboardingCheckMarkerPayload & { version: number }>(
|
|
82
|
+
path,
|
|
83
|
+
{ currentVersion: 1, onUnknown: 'quarantine' },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (parsed === null) {
|
|
87
|
+
// readVersioned returns null for: missing, corrupt JSON, or unrecognised
|
|
88
|
+
// version (renamed to <path>.unrecognized).
|
|
89
|
+
const nowExists = existsSync(path);
|
|
90
|
+
const quarantined = existsSync(`${path}.unrecognized`);
|
|
91
|
+
if (!nowExists && !quarantined) return buildMissingMarkerState(scope, path);
|
|
92
|
+
return buildErrorMarkerState(
|
|
103
93
|
scope,
|
|
104
94
|
path,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
quarantined
|
|
96
|
+
? 'Unrecognised or corrupt marker file; quarantined.'
|
|
97
|
+
: 'Invalid onboarding check marker payload.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isCheckMarkerPayload(parsed)) {
|
|
102
|
+
return buildErrorMarkerState(scope, path, 'Invalid onboarding check marker payload.');
|
|
111
103
|
}
|
|
104
|
+
|
|
105
|
+
return { scope, path, exists: true, payload: parsed };
|
|
112
106
|
}
|
|
113
107
|
|
|
114
108
|
export function readOnboardingCheckMarkers(
|
|
@@ -140,8 +134,7 @@ export function writeOnboardingCheckMarker(
|
|
|
140
134
|
...(options.workspaceRoot ? { workspaceRoot: options.workspaceRoot } : {}),
|
|
141
135
|
};
|
|
142
136
|
|
|
143
|
-
|
|
144
|
-
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
137
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
145
138
|
|
|
146
139
|
return readOnboardingCheckMarker(shellPaths, scope);
|
|
147
140
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
3
|
+
import { readVersioned } from '@/config/read-versioned.ts';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
OnboardingMode,
|
|
7
|
+
OnboardingShellPaths,
|
|
8
|
+
WizardProgressPayload,
|
|
9
|
+
WizardProgressState,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
const WIZARD_PROGRESS_FILE = 'onboarding-progress.json';
|
|
13
|
+
|
|
14
|
+
function resolveProgressPath(shellPaths: OnboardingShellPaths): string {
|
|
15
|
+
return shellPaths.resolveUserPath('tui', WIZARD_PROGRESS_FILE);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isWizardProgressPayload(value: unknown): value is WizardProgressPayload {
|
|
19
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
20
|
+
const v = value as Record<string, unknown>;
|
|
21
|
+
if (v['version'] !== 1) return false;
|
|
22
|
+
if (typeof v['savedAt'] !== 'number' || !Number.isFinite(v['savedAt'] as number)) return false;
|
|
23
|
+
const mode = v['mode'];
|
|
24
|
+
if (mode !== 'new' && mode !== 'edit' && mode !== 'reopen') return false;
|
|
25
|
+
if (typeof v['stepIndex'] !== 'number' || !Number.isFinite(v['stepIndex'] as number)) return false;
|
|
26
|
+
if (!Array.isArray(v['toggleState'])) return false;
|
|
27
|
+
if (!Array.isArray(v['radioState'])) return false;
|
|
28
|
+
if (!Array.isArray(v['textState'])) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Path to the wizard progress file (user-scoped, in ~/.config/goodvibes/tui/).
|
|
34
|
+
*/
|
|
35
|
+
export function getWizardProgressPath(shellPaths: OnboardingShellPaths): string {
|
|
36
|
+
return resolveProgressPath(shellPaths);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the persisted wizard progress, if any.
|
|
41
|
+
*
|
|
42
|
+
* Returns a WizardProgressState with exists=false when no progress file is
|
|
43
|
+
* present. Returns exists=true, payload=null with a parseError when the file
|
|
44
|
+
* is present but unreadable or schema-mismatched (the bad file is left in
|
|
45
|
+
* place so the caller can decide whether to delete it).
|
|
46
|
+
*/
|
|
47
|
+
export function readWizardProgress(shellPaths: OnboardingShellPaths): WizardProgressState {
|
|
48
|
+
const path = resolveProgressPath(shellPaths);
|
|
49
|
+
|
|
50
|
+
const parsed = readVersioned<WizardProgressPayload & { version: number }>(
|
|
51
|
+
path,
|
|
52
|
+
{ currentVersion: 1, onUnknown: 'quarantine' },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (parsed === null) {
|
|
56
|
+
const nowExists = existsSync(path);
|
|
57
|
+
const quarantined = existsSync(`${path}.unrecognized`);
|
|
58
|
+
if (!nowExists && !quarantined) return { path, exists: false, payload: null };
|
|
59
|
+
return {
|
|
60
|
+
path,
|
|
61
|
+
exists: true,
|
|
62
|
+
payload: null,
|
|
63
|
+
parseError: quarantined
|
|
64
|
+
? 'Unrecognised or corrupt wizard progress file; quarantined.'
|
|
65
|
+
: 'Invalid wizard progress payload.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!isWizardProgressPayload(parsed)) {
|
|
70
|
+
return { path, exists: true, payload: null, parseError: 'Invalid wizard progress payload.' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { path, exists: true, payload: parsed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WriteWizardProgressOptions {
|
|
77
|
+
readonly mode: OnboardingMode;
|
|
78
|
+
readonly stepIndex: number;
|
|
79
|
+
readonly toggleState: ReadonlyArray<readonly [string, boolean]>;
|
|
80
|
+
readonly radioState: ReadonlyArray<readonly [string, string]>;
|
|
81
|
+
readonly textState: ReadonlyArray<readonly [string, string]>;
|
|
82
|
+
readonly clock?: () => number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Atomically persist wizard progress to disk.
|
|
87
|
+
*
|
|
88
|
+
* Uses atomicWriteFileSync (write-to-tmp + rename) so a crash mid-write
|
|
89
|
+
* never leaves a torn file. The file is user-scoped so it survives across
|
|
90
|
+
* project switches and is shared with the resume-prompt check at startup.
|
|
91
|
+
*
|
|
92
|
+
* Masked (password) fields are deliberately excluded from the serialised
|
|
93
|
+
* textState by the caller — this function accepts whatever is passed in and
|
|
94
|
+
* does NOT filter. Callers must strip sensitive fields before calling.
|
|
95
|
+
*/
|
|
96
|
+
export function writeWizardProgress(
|
|
97
|
+
shellPaths: OnboardingShellPaths,
|
|
98
|
+
options: WriteWizardProgressOptions,
|
|
99
|
+
): void {
|
|
100
|
+
const path = resolveProgressPath(shellPaths);
|
|
101
|
+
const payload: WizardProgressPayload = {
|
|
102
|
+
version: 1,
|
|
103
|
+
savedAt: (options.clock ?? Date.now)(),
|
|
104
|
+
mode: options.mode,
|
|
105
|
+
stepIndex: options.stepIndex,
|
|
106
|
+
toggleState: options.toggleState,
|
|
107
|
+
radioState: options.radioState,
|
|
108
|
+
textState: options.textState,
|
|
109
|
+
};
|
|
110
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete the wizard progress file (best-effort; ignores ENOENT).
|
|
115
|
+
*
|
|
116
|
+
* Called after a successful apply so the resume prompt is not shown on
|
|
117
|
+
* next startup.
|
|
118
|
+
*/
|
|
119
|
+
export function deleteWizardProgress(shellPaths: OnboardingShellPaths): void {
|
|
120
|
+
const path = resolveProgressPath(shellPaths);
|
|
121
|
+
try { unlinkSync(path); } catch { /* best-effort: file may not exist */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns true when an in-progress wizard session was interrupted and is
|
|
126
|
+
* still recent enough to reopen on startup (the caller reopens the wizard
|
|
127
|
+
* at the saved step so the user can continue or dismiss it).
|
|
128
|
+
*
|
|
129
|
+
* A progress file is considered resumable when:
|
|
130
|
+
* - it exists and can be parsed (payload !== null)
|
|
131
|
+
* - it is less than PROGRESS_MAX_AGE_MS old (default: 7 days)
|
|
132
|
+
*
|
|
133
|
+
* Negative age (future-dated `savedAt`) is treated as non-resumable: it
|
|
134
|
+
* indicates clock skew or a tampered file and is safer to reject than to
|
|
135
|
+
* open a wizard with unknown-age state.
|
|
136
|
+
*/
|
|
137
|
+
const PROGRESS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
138
|
+
|
|
139
|
+
export function hasResumableWizardProgress(
|
|
140
|
+
shellPaths: OnboardingShellPaths,
|
|
141
|
+
options: { now?: number; state?: WizardProgressState } = {},
|
|
142
|
+
): boolean {
|
|
143
|
+
const state = options.state ?? readWizardProgress(shellPaths);
|
|
144
|
+
if (!state.payload) return false;
|
|
145
|
+
const age = (options.now ?? Date.now()) - state.payload.savedAt;
|
|
146
|
+
// age < 0 means savedAt is in the future (clock skew / tampered file) — treat as non-resumable.
|
|
147
|
+
return age >= 0 && age < PROGRESS_MAX_AGE_MS;
|
|
148
|
+
}
|