@pellux/goodvibes-tui 1.20.0 → 1.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 +20 -1
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/doctor.ts +7 -1
- package/src/cli/management.ts +1 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +43 -40
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +298 -17
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/main.ts +31 -38
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +295 -0
- package/src/runtime/recovery-prompt.ts +66 -6
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +334 -16
package/src/core/conversation.ts
CHANGED
|
@@ -5,8 +5,7 @@ import type { ToolCall, ToolResult } from '@pellux/goodvibes-sdk/platform/types'
|
|
|
5
5
|
import type { ProviderMessage, ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
|
|
6
6
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
7
7
|
import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
|
|
8
|
-
// SystemMessageKind imported from runtime directly to avoid cycle:
|
|
9
|
-
// conversation.ts → system-message-router.ts → conversation.ts
|
|
8
|
+
// SystemMessageKind imported from runtime directly to avoid cycle: conversation.ts → system-message-router.ts → conversation.ts
|
|
10
9
|
import type { SystemMessageKind } from '@/runtime/index.ts';
|
|
11
10
|
import {
|
|
12
11
|
ConversationManager as SdkConversationManager,
|
|
@@ -14,6 +13,10 @@ import {
|
|
|
14
13
|
} from '@pellux/goodvibes-sdk/platform/core';
|
|
15
14
|
import type { BlockMeta } from './conversation-types.ts';
|
|
16
15
|
import { MessageLineCache } from './conversation-line-cache.ts';
|
|
16
|
+
import { UserReceiptIndices } from './conversation-user-receipts.ts';
|
|
17
|
+
import { SplashGateState } from './conversation-splash-state.ts';
|
|
18
|
+
import { resolveTranscriptEventLine } from './conversation-event-navigation.ts';
|
|
19
|
+
import { SearchExpansionTracker } from './conversation-search-expansion.ts';
|
|
17
20
|
import {
|
|
18
21
|
addConversationSplashScreen,
|
|
19
22
|
conversationTextToLines,
|
|
@@ -110,6 +113,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
110
113
|
private _configManager: ConfigManager | null = null;
|
|
111
114
|
/** Collapse state: stable key (msg_N) -> collapsed (true = collapsed). */
|
|
112
115
|
private collapseState: Map<string, boolean> = new Map();
|
|
116
|
+
public readonly searchExpansion = new SearchExpansionTracker();
|
|
113
117
|
/** Block registry: track rendered blocks for copy/apply. */
|
|
114
118
|
protected blockRegistry: BlockMeta[] = [];
|
|
115
119
|
/**
|
|
@@ -139,6 +143,8 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
139
143
|
private errorLineRegistry: number[] = [];
|
|
140
144
|
/** Maps message index → SystemMessageKind for system-role messages. */
|
|
141
145
|
private messageKindRegistry: Map<number, SystemMessageKind> = new Map();
|
|
146
|
+
/** See conversation-user-receipts.ts. */
|
|
147
|
+
private userReceiptIndices = new UserReceiptIndices();
|
|
142
148
|
/** Streaming block start line in history buffer (for incremental streaming update). */
|
|
143
149
|
private streamingStartLine = -1;
|
|
144
150
|
/**
|
|
@@ -164,6 +170,8 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
164
170
|
|
|
165
171
|
public suppressSplash: boolean = false;
|
|
166
172
|
public splashOptions: SplashOptions = {};
|
|
173
|
+
/** Run-sticky dismissal + the splash→transcript edge (conversation-splash-state.ts). */
|
|
174
|
+
private readonly splashGate = new SplashGateState();
|
|
167
175
|
|
|
168
176
|
constructor(
|
|
169
177
|
getWidth: () => number = () => process.stdout.columns || 80,
|
|
@@ -221,25 +229,21 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
221
229
|
// stale kind (e.g. 'operational') and silently mis-classify the new message.
|
|
222
230
|
const nextIndex = this.getMessageSnapshot().length;
|
|
223
231
|
this.messageKindRegistry.delete(nextIndex);
|
|
232
|
+
this.userReceiptIndices.delete(nextIndex);
|
|
224
233
|
super.addSystemMessage(content);
|
|
225
234
|
this.markDirty();
|
|
226
235
|
}
|
|
227
236
|
|
|
228
237
|
/**
|
|
229
|
-
* addTypedSystemMessage -
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* Called by SystemMessageRouter.routeTypedSystemMessage when routing to the
|
|
235
|
-
* conversation surface. Falls back to addSystemMessage for callers that do
|
|
236
|
-
* not have kind information.
|
|
238
|
+
* addTypedSystemMessage - System message with an explicit kind tag, stored
|
|
239
|
+
* in messageKindRegistry. `isUserReceipt` (conversation-user-receipts.ts)
|
|
240
|
+
* additionally marks it for rebuildHistory()'s splash check — only for a
|
|
241
|
+
* direct receipt to an explicit user action, never ambient boot chatter.
|
|
237
242
|
*/
|
|
238
|
-
public addTypedSystemMessage(content: string, kind: SystemMessageKind): void {
|
|
239
|
-
// getMessageSnapshot().length is the index this message will receive after
|
|
240
|
-
// addSystemMessage appends it to the messages array.
|
|
243
|
+
public addTypedSystemMessage(content: string, kind: SystemMessageKind, opts?: { isUserReceipt?: boolean }): void {
|
|
241
244
|
const nextIndex = this.getMessageSnapshot().length;
|
|
242
245
|
this.messageKindRegistry.set(nextIndex, kind);
|
|
246
|
+
if (opts?.isUserReceipt) this.userReceiptIndices.add(nextIndex); else this.userReceiptIndices.delete(nextIndex);
|
|
243
247
|
super.addSystemMessage(content);
|
|
244
248
|
this.markDirty();
|
|
245
249
|
}
|
|
@@ -256,6 +260,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
256
260
|
for (const key of this.messageKindRegistry.keys()) {
|
|
257
261
|
if (key >= postUndoCount) this.messageKindRegistry.delete(key);
|
|
258
262
|
}
|
|
263
|
+
this.userReceiptIndices.purgeFrom(postUndoCount);
|
|
259
264
|
this.markDirty();
|
|
260
265
|
}
|
|
261
266
|
return result;
|
|
@@ -357,6 +362,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
357
362
|
this.messageLineRegistry = [];
|
|
358
363
|
this.errorLineRegistry = [];
|
|
359
364
|
this.messageKindRegistry = new Map();
|
|
365
|
+
this.userReceiptIndices.clear();
|
|
360
366
|
this.streamingStartLine = -1;
|
|
361
367
|
this._displayFromMessageIndex = 0; // full reset — show everything on next render
|
|
362
368
|
}
|
|
@@ -478,17 +484,21 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
478
484
|
const displayStart = this._displayFromMessageIndex;
|
|
479
485
|
const visibleSnapshot = displayStart > 0 ? renderSnapshot.slice(displayStart) : renderSnapshot;
|
|
480
486
|
|
|
481
|
-
// Tool messages
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
const displayMessages = visibleSnapshot.filter(
|
|
485
|
-
(m
|
|
486
|
-
|
|
487
|
+
// Tool/system messages aren't visible splash-purposes content, except a
|
|
488
|
+
// system message in userReceiptIndices. visibleSnapshot mirrors the
|
|
489
|
+
// snapshot 1:1 (only front-sliced), so index i is absolute displayStart+i.
|
|
490
|
+
const displayMessages = visibleSnapshot.filter((m, i) => {
|
|
491
|
+
if (m.role === 'tool') return false;
|
|
492
|
+
if (m.role === 'system') return this.userReceiptIndices.has(displayStart + i);
|
|
493
|
+
return true;
|
|
494
|
+
});
|
|
487
495
|
|
|
488
|
-
if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
|
|
496
|
+
if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash && !this.splashGate.dismissed) {
|
|
489
497
|
this.addSplashScreen(width);
|
|
498
|
+
this.splashGate.enter();
|
|
490
499
|
return;
|
|
491
500
|
}
|
|
501
|
+
this.splashGate.leave(); // latches the splash→transcript edge for consumeSplashTransition()
|
|
492
502
|
|
|
493
503
|
// The in-progress streaming placeholder (rendered EMPTY above via
|
|
494
504
|
// renderSnapshot) is left uncached: its content mutates in place per delta and
|
|
@@ -640,6 +650,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
640
650
|
if (!nearest) return -1;
|
|
641
651
|
const current = this.collapseState.get(nearest.collapseKey) ?? false;
|
|
642
652
|
this.collapseState.set(nearest.collapseKey, !current);
|
|
653
|
+
this.searchExpansion.noteUserTouch(nearest.collapseKey);
|
|
643
654
|
this.markDirty();
|
|
644
655
|
return nearest.blockIndex;
|
|
645
656
|
}
|
|
@@ -701,30 +712,12 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
701
712
|
|
|
702
713
|
public nextTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
|
|
703
714
|
this.flushHistory();
|
|
704
|
-
|
|
705
|
-
const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
|
|
706
|
-
if (events.length === 0) return -1;
|
|
707
|
-
const lines = events
|
|
708
|
-
.map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
|
|
709
|
-
.filter((line) => line >= 0)
|
|
710
|
-
.sort((a, b) => a - b);
|
|
711
|
-
if (lines.length === 0) return -1;
|
|
712
|
-
const after = lines.find((line) => line > currentLine);
|
|
713
|
-
return after ?? lines[0]!;
|
|
715
|
+
return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'next');
|
|
714
716
|
}
|
|
715
717
|
|
|
716
718
|
public prevTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
|
|
717
719
|
this.flushHistory();
|
|
718
|
-
|
|
719
|
-
const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
|
|
720
|
-
if (events.length === 0) return -1;
|
|
721
|
-
const lines = events
|
|
722
|
-
.map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
|
|
723
|
-
.filter((line) => line >= 0)
|
|
724
|
-
.sort((a, b) => a - b);
|
|
725
|
-
if (lines.length === 0) return -1;
|
|
726
|
-
const before = [...lines].reverse().find((line) => line < currentLine);
|
|
727
|
-
return before ?? lines[lines.length - 1]!;
|
|
720
|
+
return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'prev');
|
|
728
721
|
}
|
|
729
722
|
|
|
730
723
|
public setSplashSuppressed(suppressed: boolean): void {
|
|
@@ -733,6 +726,16 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
733
726
|
this.markDirty();
|
|
734
727
|
}
|
|
735
728
|
|
|
729
|
+
/** Retire the splash for the run — any submission does it (SplashGateState). */
|
|
730
|
+
public dismissSplash(): void {
|
|
731
|
+
if (this.splashGate.dismiss()) this.markDirty();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** True once, on the frame the splash gives way to transcript content. */
|
|
735
|
+
public consumeSplashTransition(): boolean {
|
|
736
|
+
return this.splashGate.consumeTransition();
|
|
737
|
+
}
|
|
738
|
+
|
|
736
739
|
private addSplashScreen(width: number): void {
|
|
737
740
|
addConversationSplashScreen(this.renderingContext(), width);
|
|
738
741
|
}
|
|
@@ -9,18 +9,22 @@
|
|
|
9
9
|
* narration of their own.
|
|
10
10
|
*
|
|
11
11
|
* Division of labour with the failover path: the optimizer-gated failover in
|
|
12
|
-
* stream-event-wiring.ts
|
|
13
|
-
* (reason)(+cost)` line
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* [Failover] line already narrated that exact change with its real reason.
|
|
17
|
-
* Every other change is surfaced here.
|
|
12
|
+
* stream-event-wiring.ts renders a richer `[Failover] from → to
|
|
13
|
+
* (reason)(+cost)` line for the switches it makes itself, and tells this
|
|
14
|
+
* listener so directly (see the self-narrated guard there) rather than relying
|
|
15
|
+
* on this module to infer it. Those changes never reach buildRoutingChip.
|
|
18
16
|
*
|
|
19
|
-
* Reason honesty
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
17
|
+
* Reason honesty, in three tiers:
|
|
18
|
+
* 1. a change the failover path already narrated → not offered here at all;
|
|
19
|
+
* 2. a change that correlates to a transition in the optimizer's fallback
|
|
20
|
+
* log → the log's own reason is quoted, because the reason IS known. The
|
|
21
|
+
* log is shared: the TUI's failover records provider ids ("abacusai"),
|
|
22
|
+
* the SDK's agent-orchestrator fallback records registry keys
|
|
23
|
+
* ("abacusai:route-llm"), and a match on either form counts. Reading only
|
|
24
|
+
* one form is what made a real, explained failover print
|
|
25
|
+
* "(reason unknown)";
|
|
26
|
+
* 3. no correlation → "(reason unknown)", because a change with no
|
|
27
|
+
* explanation is still reported, just honestly marked as unexplained.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
/** The subset of a FallbackTransition this module reads (structural, SDK-agnostic). */
|
|
@@ -44,9 +48,22 @@ export const FALLBACK_CORRELATION_WINDOW_MS = 4_000;
|
|
|
44
48
|
/** The forced conversation prefix so the notice is never gated as noise or routed away. */
|
|
45
49
|
export const ROUTING_CHIP_PREFIX = '[Routing]';
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The provider id of a fallback-log endpoint, which may be written either as a
|
|
53
|
+
* bare provider id or as a `provider:model` registry key depending on which
|
|
54
|
+
* writer recorded it. Everything before the first ':' is the provider in both
|
|
55
|
+
* forms, so one rule reads both.
|
|
56
|
+
*/
|
|
57
|
+
function providerOf(endpoint: string): string {
|
|
58
|
+
const separator = endpoint.indexOf(':');
|
|
59
|
+
return separator === -1 ? endpoint : endpoint.slice(0, separator);
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/**
|
|
48
63
|
* Most recent fallback-log reason for a from→to provider transition within the
|
|
49
64
|
* correlation window, or null. Scans newest-first so the freshest transition wins.
|
|
65
|
+
* Endpoints are compared by provider id, so entries recorded as registry keys
|
|
66
|
+
* correlate exactly like entries recorded as bare provider ids.
|
|
50
67
|
*/
|
|
51
68
|
export function findRecentFallbackReason(
|
|
52
69
|
fallbackLog: readonly FallbackTransitionLike[],
|
|
@@ -57,17 +74,20 @@ export function findRecentFallbackReason(
|
|
|
57
74
|
): string | null {
|
|
58
75
|
for (let i = fallbackLog.length - 1; i >= 0; i--) {
|
|
59
76
|
const t = fallbackLog[i]!;
|
|
60
|
-
if (t.from === fromProvider
|
|
77
|
+
if (providerOf(t.from) === providerOf(fromProvider)
|
|
78
|
+
&& providerOf(t.to) === providerOf(toProvider)
|
|
79
|
+
&& now - t.ts <= windowMs) return t.reason;
|
|
61
80
|
}
|
|
62
81
|
return null;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|
|
66
85
|
* The routing-chip line to emit for a MODEL_CHANGED event, or null to stay
|
|
67
|
-
* silent. Null only when there is no real change (no/same previous model)
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
86
|
+
* silent. Null only when there is no real change (no/same previous model) —
|
|
87
|
+
* changes the failover path narrates itself never reach this function.
|
|
88
|
+
*
|
|
89
|
+
* A change that correlates to a logged fallback transition is reported WITH
|
|
90
|
+
* that transition's reason; anything else is reported as "reason unknown".
|
|
71
91
|
*/
|
|
72
92
|
export function buildRoutingChip(
|
|
73
93
|
change: ModelChangedLike,
|
|
@@ -77,6 +97,6 @@ export function buildRoutingChip(
|
|
|
77
97
|
const prev = change.previous;
|
|
78
98
|
if (!prev || prev.registryKey === change.registryKey) return null;
|
|
79
99
|
const failoverReason = findRecentFallbackReason(fallbackLog, prev.provider, change.provider, now);
|
|
80
|
-
|
|
81
|
-
return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (reason
|
|
100
|
+
const reason = failoverReason === null ? 'reason unknown' : `failover: ${failoverReason}`;
|
|
101
|
+
return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (${reason})`;
|
|
82
102
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* `messageCount` boundaries stay valid because a resume rehydrates the same
|
|
22
22
|
* message history the anchors were recorded against.
|
|
23
23
|
*/
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
27
27
|
|
|
@@ -92,12 +92,14 @@ export function clearTurnAnchors(sessionId: string): void {
|
|
|
92
92
|
const ANCHOR_SIDECAR_VERSION = 1;
|
|
93
93
|
|
|
94
94
|
/** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
|
|
95
|
+
const ANCHOR_SIDECAR_SUFFIX = '.anchors.json';
|
|
96
|
+
|
|
95
97
|
function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
|
|
96
98
|
if (!sessionId || !surface.sessionsDir) return null;
|
|
97
99
|
// A sessionId is a filename component; refuse anything with path separators so
|
|
98
100
|
// a malformed id can never escape the sessions directory.
|
|
99
101
|
if (/[\\/]/.test(sessionId)) return null;
|
|
100
|
-
return join(surface.sessionsDir, `${sessionId}
|
|
102
|
+
return join(surface.sessionsDir, `${sessionId}${ANCHOR_SIDECAR_SUFFIX}`);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function isTurnAnchor(value: unknown): value is TurnAnchor {
|
|
@@ -125,7 +127,7 @@ export function persistTurnAnchors(sessionId: string, surface: SessionSurface):
|
|
|
125
127
|
try {
|
|
126
128
|
mkdirSync(surface.sessionsDir, { recursive: true });
|
|
127
129
|
const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
|
|
128
|
-
const tmp = `${path}
|
|
130
|
+
const tmp = `${path}${ANCHOR_SIDECAR_TMP_MARKER}${process.pid}`;
|
|
129
131
|
writeFileSync(tmp, payload);
|
|
130
132
|
renameSync(tmp, path);
|
|
131
133
|
} catch {
|
|
@@ -157,3 +159,153 @@ export function restoreTurnAnchors(sessionId: string, surface: SessionSurface):
|
|
|
157
159
|
return 0;
|
|
158
160
|
}
|
|
159
161
|
}
|
|
162
|
+
|
|
163
|
+
// ─── Reaping orphaned sidecars ────────────────────────────────────────────────
|
|
164
|
+
//
|
|
165
|
+
// A sidecar's OWNER is the session JSONL it sits beside. Deleting a session
|
|
166
|
+
// removes the JSONL but nothing ever removed the sidecar, so anchors for
|
|
167
|
+
// sessions that no longer exist accumulate in the sessions directory forever.
|
|
168
|
+
// The sweep below reclaims them, plus the `.tmp-<pid>` staging files a crash
|
|
169
|
+
// between write and rename leaves behind.
|
|
170
|
+
|
|
171
|
+
/** Infix for the atomic-write staging file, before the writing process's pid. */
|
|
172
|
+
const ANCHOR_SIDECAR_TMP_MARKER = '.tmp-';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* How long a staging file is tolerated before it is treated as crash residue:
|
|
176
|
+
* 1 hour. `persistTurnAnchors` renames within microseconds of writing, so
|
|
177
|
+
* anything this old was interrupted, and the completed sidecar (if the write
|
|
178
|
+
* ever finished) is a separate file.
|
|
179
|
+
*/
|
|
180
|
+
export const ANCHOR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* How settled a sidecar must be before this sweep will delete it: 1 hour.
|
|
184
|
+
*
|
|
185
|
+
* Another instance can be persisting anchors for a session this process knows
|
|
186
|
+
* nothing about, and the window between reading a sidecar's content and
|
|
187
|
+
* unlinking it is not atomic. Requiring the file to have been untouched for an
|
|
188
|
+
* hour means a sidecar that some other instance is actively rewriting is never
|
|
189
|
+
* a candidate, while genuine residue — whose writer is long gone — always is.
|
|
190
|
+
* The sweep repeats, so the delay costs nothing.
|
|
191
|
+
*/
|
|
192
|
+
export const ANCHOR_SIDECAR_SETTLE_MS = 60 * 60 * 1000;
|
|
193
|
+
|
|
194
|
+
export interface AnchorSidecarReapResult {
|
|
195
|
+
/** Sidecar and staging files examined this sweep. */
|
|
196
|
+
readonly scanned: number;
|
|
197
|
+
/** Files deleted this sweep. */
|
|
198
|
+
readonly reaped: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface AnchorSidecarReapOptions {
|
|
202
|
+
/** The session this process is using right now; its sidecar is never reaped. */
|
|
203
|
+
readonly currentSessionId?: string | null;
|
|
204
|
+
readonly now?: () => number;
|
|
205
|
+
/** Override the staging-file age window (tests). */
|
|
206
|
+
readonly tmpMaxAgeMs?: number;
|
|
207
|
+
/** Override how long a sidecar must be untouched before it can be reaped (tests). */
|
|
208
|
+
readonly settleMs?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Delete anchor sidecars whose owning session file is gone, sidecars that hold
|
|
213
|
+
* nothing readable, and abandoned staging files.
|
|
214
|
+
*
|
|
215
|
+
* A sidecar survives when `<sessionsDir>/<sessionId>.jsonl` still exists AND
|
|
216
|
+
* the sidecar itself parses into at least one usable anchor — content, not
|
|
217
|
+
* mere existence, because a sidecar truncated by a crash restores nothing and
|
|
218
|
+
* would otherwise sit there indefinitely looking like valid state. It also
|
|
219
|
+
* survives while it is still fresh (see `ANCHOR_SIDECAR_SETTLE_MS`), which
|
|
220
|
+
* keeps a sidecar another instance is mid-rewrite out of reach.
|
|
221
|
+
*
|
|
222
|
+
* The current session's sidecar is never touched, and an unreadable or absent
|
|
223
|
+
* sessions directory simply reclaims nothing. Idempotent and concurrency-safe:
|
|
224
|
+
* a file another sweeper unlinked first (ENOENT) counts as reaped.
|
|
225
|
+
*/
|
|
226
|
+
export function reapOrphanedAnchorSidecars(
|
|
227
|
+
surface: SessionSurface,
|
|
228
|
+
options: AnchorSidecarReapOptions = {},
|
|
229
|
+
): AnchorSidecarReapResult {
|
|
230
|
+
const dir = surface.sessionsDir;
|
|
231
|
+
if (!dir) return { scanned: 0, reaped: 0 };
|
|
232
|
+
|
|
233
|
+
let names: string[];
|
|
234
|
+
try {
|
|
235
|
+
names = readdirSync(dir).filter(
|
|
236
|
+
(n) => n.endsWith(ANCHOR_SIDECAR_SUFFIX) || n.includes(`${ANCHOR_SIDECAR_SUFFIX}${ANCHOR_SIDECAR_TMP_MARKER}`),
|
|
237
|
+
);
|
|
238
|
+
} catch {
|
|
239
|
+
return { scanned: 0, reaped: 0 };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const now = options.now?.() ?? Date.now();
|
|
243
|
+
const tmpMaxAgeMs = options.tmpMaxAgeMs ?? ANCHOR_TMP_MAX_AGE_MS;
|
|
244
|
+
const settleMs = options.settleMs ?? ANCHOR_SIDECAR_SETTLE_MS;
|
|
245
|
+
let reaped = 0;
|
|
246
|
+
|
|
247
|
+
for (const name of names) {
|
|
248
|
+
const path = join(dir, name);
|
|
249
|
+
|
|
250
|
+
if (!name.endsWith(ANCHOR_SIDECAR_SUFFIX)) {
|
|
251
|
+
// A `<sessionId>.anchors.json.tmp-<pid>` staging file. Age alone decides:
|
|
252
|
+
// a young one may belong to another instance's in-flight write.
|
|
253
|
+
let mtimeMs: number;
|
|
254
|
+
try {
|
|
255
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
256
|
+
} catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (now - mtimeMs > tmpMaxAgeMs && unlinkAnchorFile(path)) reaped++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const sessionId = name.slice(0, name.length - ANCHOR_SIDECAR_SUFFIX.length);
|
|
264
|
+
if (sessionId.length === 0) continue;
|
|
265
|
+
if (options.currentSessionId && sessionId === options.currentSessionId) continue;
|
|
266
|
+
|
|
267
|
+
// A sidecar written moments ago belongs to a writer that is still around —
|
|
268
|
+
// possibly another instance whose session this process cannot see.
|
|
269
|
+
let sidecarMtimeMs: number;
|
|
270
|
+
try {
|
|
271
|
+
sidecarMtimeMs = statSync(path).mtimeMs;
|
|
272
|
+
} catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (now - sidecarMtimeMs <= settleMs) continue;
|
|
276
|
+
|
|
277
|
+
if (!existsSync(join(dir, `${sessionId}.jsonl`))) {
|
|
278
|
+
// The owning session is gone — this sidecar can never be used again.
|
|
279
|
+
if (unlinkAnchorFile(path)) reaped++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (!sidecarHoldsAnchors(path)) {
|
|
283
|
+
// The session survives but the sidecar is empty or torn: it restores
|
|
284
|
+
// nothing, so keeping it only hides the loss.
|
|
285
|
+
if (unlinkAnchorFile(path)) reaped++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { scanned: names.length, reaped };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Content check: does this sidecar parse into at least one usable anchor? */
|
|
293
|
+
function sidecarHoldsAnchors(path: string): boolean {
|
|
294
|
+
try {
|
|
295
|
+
const text = readFileSync(path, 'utf8');
|
|
296
|
+
if (text.trim().length === 0) return false;
|
|
297
|
+
const parsed = JSON.parse(text) as { anchors?: unknown };
|
|
298
|
+
return Array.isArray(parsed?.anchors) && parsed.anchors.some(isTurnAnchor);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function unlinkAnchorFile(path: string): boolean {
|
|
305
|
+
try {
|
|
306
|
+
unlinkSync(path);
|
|
307
|
+
return true;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
|
310
|
+
}
|
|
311
|
+
}
|