@pellux/goodvibes-tui 1.20.1 → 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 +13 -1
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/doctor.ts +7 -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/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.ts +23 -23
- 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 +141 -20
- 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 +12 -20
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- 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/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 +180 -11
- package/src/runtime/recovery-prompt.ts +11 -1
- 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
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* EVERY root the composition knows (working dir, surface root, home,
|
|
19
19
|
* logDir, telemetryDir) — omitting logDir/telemetryDir/home would silently
|
|
20
20
|
* skip the activity-log, telemetry-ledger, and recovery-snapshot stores.
|
|
21
|
+
* - Durability housekeeping: the crash-residue reclaim the retention janitor
|
|
22
|
+
* above does NOT cover — stale liveness markers, orphaned transcript
|
|
23
|
+
* journals, `.unrecognized` quarantine files, and anchor sidecars whose
|
|
24
|
+
* session is gone (see durability-housekeeping.ts). It runs at startup AND
|
|
25
|
+
* on a repeating unref'd timer, because a long-lived process that only
|
|
26
|
+
* sweeps at boot never sweeps; the returned disposer stops that timer.
|
|
21
27
|
* - Live config-file watch: external edits to the settings file apply through
|
|
22
28
|
* the same subscribe() pipeline an in-process set() uses — no restart. The
|
|
23
29
|
* underlying watchers are unref'd, so this never pins the event loop.
|
|
@@ -28,6 +34,7 @@ import { resolveMemoryVectorDbPath } from '@pellux/goodvibes-sdk/platform/state'
|
|
|
28
34
|
import { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
|
|
29
35
|
import { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
30
36
|
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
37
|
+
import { startDurabilityHousekeeping } from './durability-housekeeping.ts';
|
|
31
38
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
32
39
|
|
|
33
40
|
export interface DurabilityServicesInput {
|
|
@@ -48,11 +55,44 @@ export interface DurabilityServicesInput {
|
|
|
48
55
|
*/
|
|
49
56
|
readonly surface: SessionSurface;
|
|
50
57
|
readonly shellPaths: { resolveUserPath(...segments: string[]): string };
|
|
58
|
+
/**
|
|
59
|
+
* The session id this process is currently using, read at each sweep. When
|
|
60
|
+
* omitted the crash-residue reap still protects live artefacts through its
|
|
61
|
+
* age and liveness rules — this is the explicit belt-and-braces guard.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the live session id, read fresh on every crash-residue sweep so
|
|
65
|
+
* the running session's own transcript journal and liveness marker are
|
|
66
|
+
* exempt from reaping.
|
|
67
|
+
*
|
|
68
|
+
* A getter, not a value: the id is reassigned in place when a recovery
|
|
69
|
+
* snapshot is accepted, and this sweep repeats for the life of the process.
|
|
70
|
+
*
|
|
71
|
+
* Omitting it is not merely untidy. The journal reaper's other guard is the
|
|
72
|
+
* liveness marker, and that marker goes stale after 150 seconds — so a host
|
|
73
|
+
* that passes nothing here is trusting a heartbeat that a single long
|
|
74
|
+
* blocking turn can outrun, and an in-process sweep landing in that window
|
|
75
|
+
* would delete the journal of the session currently writing it. Passing this
|
|
76
|
+
* makes the exemption unconditional instead of timing-dependent.
|
|
77
|
+
*/
|
|
78
|
+
readonly currentSessionId?: () => string | null;
|
|
51
79
|
}
|
|
52
80
|
|
|
53
81
|
export interface DurabilityServices {
|
|
54
82
|
readonly storeSnapshotScheduler: StoreSnapshotScheduler;
|
|
83
|
+
/**
|
|
84
|
+
* Re-sweeps every registered append-only store on a cadence. The start-time
|
|
85
|
+
* sweep alone never prunes again in a process that stays up, which is the
|
|
86
|
+
* window in which those stores grow. Unref'd timers; teardown stop()s it.
|
|
87
|
+
*/
|
|
88
|
+
readonly appendOnlyRetentionScheduler: InstanceType<typeof operations.AppendOnlyRetentionScheduler>;
|
|
55
89
|
readonly userPermissionRuleStore: UserPermissionRuleStore;
|
|
90
|
+
/**
|
|
91
|
+
* Stops the repeating crash-residue sweep. The timer is unref'd, so a host
|
|
92
|
+
* that never calls this is not held open by it; teardown calls it to stop
|
|
93
|
+
* the work rather than to release the loop.
|
|
94
|
+
*/
|
|
95
|
+
readonly stopDurabilityHousekeeping: () => void;
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
export function createDurabilityServices(input: DurabilityServicesInput): DurabilityServices {
|
|
@@ -73,16 +113,33 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
|
|
|
73
113
|
// activity-log, telemetry-ledger, and recovery-snapshot stores are all swept.
|
|
74
114
|
// Best-effort — a retention failure never takes startup down (the sweep
|
|
75
115
|
// swallows its own errors).
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
116
|
+
const appendOnlyRetentionRoots = {
|
|
117
|
+
workingDirectory: input.surface.workingDirectory,
|
|
118
|
+
surfaceRoot: input.surface.surfaceRoot,
|
|
119
|
+
homeDirectory: input.surface.homeDirectory,
|
|
120
|
+
logDir: input.shellPaths.resolveUserPath('logs'),
|
|
121
|
+
telemetryDir: input.shellPaths.resolveUserPath('telemetry'),
|
|
122
|
+
};
|
|
123
|
+
const appendOnlyRetentionConfigGet = (k: string): unknown => configManager.get(k as never);
|
|
124
|
+
operations.runStartupAppendOnlySweep(appendOnlyRetentionRoots, appendOnlyRetentionConfigGet);
|
|
125
|
+
// ...and again on a cadence for as long as this process lives. A start-time
|
|
126
|
+
// sweep alone never prunes a long-lived session again after boot, which is
|
|
127
|
+
// exactly the window in which these stores grow. Unref'd timers; the disposer
|
|
128
|
+
// below stops it, same posture as storeSnapshotScheduler.
|
|
129
|
+
const appendOnlyRetentionScheduler = new operations.AppendOnlyRetentionScheduler({
|
|
130
|
+
roots: appendOnlyRetentionRoots,
|
|
131
|
+
configGet: appendOnlyRetentionConfigGet,
|
|
132
|
+
});
|
|
133
|
+
appendOnlyRetentionScheduler.start();
|
|
134
|
+
// Crash-residue reclaim the sweep above does not cover (liveness markers,
|
|
135
|
+
// orphaned transcript journals, .unrecognized quarantine files, anchor
|
|
136
|
+
// sidecars whose session file is gone). Runs once now and every few hours
|
|
137
|
+
// after that; the timer is unref'd and the disposer stops it.
|
|
138
|
+
const stopDurabilityHousekeeping = startDurabilityHousekeeping({
|
|
139
|
+
surface: input.surface,
|
|
140
|
+
currentSessionId: input.currentSessionId,
|
|
141
|
+
extraQuarantineDirs: [configManager.getControlPlaneConfigDir()],
|
|
142
|
+
});
|
|
86
143
|
// External config edits apply LIVE through the same subscribe() pipeline an
|
|
87
144
|
// in-process set() uses; the underlying watchers are unref'd.
|
|
88
145
|
configManager.watchConfigFiles();
|
|
@@ -90,5 +147,5 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
|
|
|
90
147
|
const userPermissionRuleStore = new UserPermissionRuleStore(join(configManager.getControlPlaneConfigDir(), 'permission-rules.json'));
|
|
91
148
|
void userPermissionRuleStore.init().catch((error) => logger.warn('user permission rule store init failed; asks will prompt', { error: summarizeError(error) }));
|
|
92
149
|
|
|
93
|
-
return { storeSnapshotScheduler, userPermissionRuleStore };
|
|
150
|
+
return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping };
|
|
94
151
|
}
|
|
@@ -101,6 +101,14 @@ export interface ReconcileRedundantLegacyUnitInput {
|
|
|
101
101
|
readonly readOwnCgroup?: (() => string) | undefined;
|
|
102
102
|
/** Timeout applied to the DEFAULT systemctl runner only. Defaults to SYSTEMCTL_TIMEOUT_MS. */
|
|
103
103
|
readonly systemctlTimeoutMs?: number | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Program the DEFAULT runner executes. Defaults to `systemctl` (resolved on
|
|
106
|
+
* PATH). Tests that deliberately exercise the default (non-injected) runner
|
|
107
|
+
* point this at an absolute stub path: Bun resolves a spawned program from
|
|
108
|
+
* the PATH captured at process start, so mutating `process.env.PATH` inside
|
|
109
|
+
* a test cannot redirect the call and the host's real systemctl would answer.
|
|
110
|
+
*/
|
|
111
|
+
readonly systemctlCommand?: string | undefined;
|
|
104
112
|
/** Cumulative budget for the whole pass. Defaults to RECONCILE_DEADLINE_MS. */
|
|
105
113
|
readonly deadlineMs?: number | undefined;
|
|
106
114
|
}
|
|
@@ -201,6 +209,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
201
209
|
input.systemctlTimeoutMs ?? SYSTEMCTL_TIMEOUT_MS,
|
|
202
210
|
input.deadlineMs ?? RECONCILE_DEADLINE_MS,
|
|
203
211
|
);
|
|
212
|
+
const systemctl = input.systemctlCommand ?? 'systemctl';
|
|
204
213
|
const canonicalUnit = `${input.trackedServiceName}.service`;
|
|
205
214
|
const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
|
|
206
215
|
const deadlineNote = (): string[] =>
|
|
@@ -210,7 +219,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
210
219
|
|
|
211
220
|
// Guard 1: the canonical unit must report active. (A timed-out, skipped, or
|
|
212
221
|
// failed probe — e.g. a wedged user bus — lands here too and refuses.)
|
|
213
|
-
const probe = run(
|
|
222
|
+
const probe = run(systemctl, ['--user', 'is-active', canonicalUnit]);
|
|
214
223
|
const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
|
|
215
224
|
if (!canonicalActive) {
|
|
216
225
|
return {
|
|
@@ -227,7 +236,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
227
236
|
// Guard 2: 'active' alone does not prove the canonical unit is the daemon
|
|
228
237
|
// actually serving (Type=simple reports active from fork onward). Require
|
|
229
238
|
// its MainPID to resolve and be a live process.
|
|
230
|
-
const canonicalPid = parseMainPid(run(
|
|
239
|
+
const canonicalPid = parseMainPid(run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
|
|
231
240
|
const alive = input.processAlive ?? defaultProcessAlive;
|
|
232
241
|
if (canonicalPid === undefined || !alive(canonicalPid)) {
|
|
233
242
|
return {
|
|
@@ -253,7 +262,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
253
262
|
// it: in the wrong-port state the legacy daemon itself answers the
|
|
254
263
|
// configured-endpoint probe). Unknown refuses.
|
|
255
264
|
const ownPid = input.ownPid ?? process.pid;
|
|
256
|
-
const legacyPidReply = run(
|
|
265
|
+
const legacyPidReply = run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
|
|
257
266
|
const legacyPidKnown = (legacyPidReply.status ?? 1) === 0;
|
|
258
267
|
const legacyPid = parseMainPid(legacyPidReply);
|
|
259
268
|
const ownCgroup = (input.readOwnCgroup ?? defaultReadOwnCgroup)();
|
|
@@ -359,7 +368,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
359
368
|
// unit file if the disable actually succeeded — otherwise the enablement
|
|
360
369
|
// symlink dangles at a deleted file and this tool can never repair it (the
|
|
361
370
|
// next pass no-ops at the file-exists check).
|
|
362
|
-
const disableResult = run(
|
|
371
|
+
const disableResult = run(systemctl, ['--user', 'disable', '--now', legacyUnit]);
|
|
363
372
|
const disableStatus = disableResult.status ?? 1;
|
|
364
373
|
if (disableStatus !== 0) {
|
|
365
374
|
if (disableResult.status === null) {
|
|
@@ -367,7 +376,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
367
376
|
// removes the enablement symlinks synchronously BEFORE waiting on the
|
|
368
377
|
// stop job, so the disable may well have taken effect even though the
|
|
369
378
|
// client was killed. Re-inspect instead of printing a blanket denial.
|
|
370
|
-
const reinspect = run(
|
|
379
|
+
const reinspect = run(systemctl, ['--user', 'is-enabled', legacyUnit]);
|
|
371
380
|
const reinspectOut = (reinspect.stdout ?? '').trim();
|
|
372
381
|
const confirmedDisabled = (reinspect.status !== null)
|
|
373
382
|
&& (reinspectOut === 'disabled' || reinspectOut === 'not-found');
|
|
@@ -381,7 +390,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
381
390
|
} catch (error) {
|
|
382
391
|
removeErrorAfterTimeout = summarizeError(error);
|
|
383
392
|
}
|
|
384
|
-
run(
|
|
393
|
+
run(systemctl, ['--user', 'daemon-reload']);
|
|
385
394
|
const lines = [
|
|
386
395
|
`reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
|
|
387
396
|
`installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` — its unit file was removed (${path})`}. ` +
|
|
@@ -425,7 +434,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
425
434
|
} catch (error) {
|
|
426
435
|
removeError = summarizeError(error);
|
|
427
436
|
}
|
|
428
|
-
run(
|
|
437
|
+
run(systemctl, ['--user', 'daemon-reload']);
|
|
429
438
|
|
|
430
439
|
const lines = [
|
|
431
440
|
`reconciled: ${canonicalUnit} is active and serving, so the redundant installer-managed ${legacyUnit} ` +
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* compaction denominator agree with the post-catalog window.
|
|
7
7
|
*/
|
|
8
8
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
inferFallbackContextWindow,
|
|
11
|
+
resolveReasoningEffortSpec,
|
|
12
|
+
type ModelDefinition,
|
|
13
|
+
type ProviderRegistry,
|
|
14
|
+
} from '@pellux/goodvibes-sdk/platform/providers';
|
|
10
15
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
16
|
|
|
12
17
|
function buildFallbackModelDefinition(provider: string, modelId: string): ModelDefinition {
|
|
@@ -32,7 +37,16 @@ function buildFallbackModelDefinition(provider: string, modelId: string): ModelD
|
|
|
32
37
|
contextWindowProvenance: 'fallback',
|
|
33
38
|
selectable: true,
|
|
34
39
|
tier: 'standard',
|
|
35
|
-
|
|
40
|
+
// Which levels this model accepts is a property of the model, not of the
|
|
41
|
+
// provider it sits behind: the hardcoded four ('instant', 'low', 'medium',
|
|
42
|
+
// 'high') offered 'instant' to models that reject it and hid 'xhigh',
|
|
43
|
+
// 'max' and 'none' from models that accept them. The SDK resolver answers
|
|
44
|
+
// from the curated family table when the catalog has not loaded yet, and
|
|
45
|
+
// otherwise returns its own labelled best guess, which the adapters treat
|
|
46
|
+
// as "send nothing" rather than as verified levels.
|
|
47
|
+
...(isReasoningProvider
|
|
48
|
+
? { reasoningEffort: resolveReasoningEffortSpec({ modelId }) }
|
|
49
|
+
: {}),
|
|
36
50
|
};
|
|
37
51
|
}
|
|
38
52
|
|
|
@@ -30,8 +30,9 @@
|
|
|
30
30
|
* another. Each record still carries the workspace it was made in, so the file
|
|
31
31
|
* says plainly where the decision came from.
|
|
32
32
|
*/
|
|
33
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
34
34
|
import { dirname, join } from 'node:path';
|
|
35
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
35
36
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
36
37
|
|
|
37
38
|
/** One "the user removed this snapshot" decision. */
|
|
@@ -52,6 +53,12 @@ export interface RecoveryRemovalRecord {
|
|
|
52
53
|
* deleted, so that rule would empty the ledger immediately and reinstate the
|
|
53
54
|
* exact defect it was written to fix — the point is precisely to still
|
|
54
55
|
* remember once the file comes back. Age and count are the honest bounds.
|
|
56
|
+
*
|
|
57
|
+
* Both bounds are enforced wherever records actually leave the disk —
|
|
58
|
+
* pruneRecoveryDecisions (once per boot) and recordRecoveryRemoval (every
|
|
59
|
+
* write) — and both DISCLOSE what they discarded. A deletion nobody reports is
|
|
60
|
+
* indistinguishable from data loss, so the counts go in the log with the ledger
|
|
61
|
+
* path, the TTL and the cap alongside them.
|
|
55
62
|
*/
|
|
56
63
|
const MAX_RECORDS = 200;
|
|
57
64
|
const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
@@ -71,22 +78,162 @@ function isRecord(value: unknown): value is RecoveryRemovalRecord {
|
|
|
71
78
|
&& Number.isFinite(candidate.removedAt);
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/** What one load of the ledger found, including everything it discarded. */
|
|
82
|
+
interface LedgerLoad {
|
|
83
|
+
/** Records still in force, oldest first. */
|
|
84
|
+
readonly kept: RecoveryRemovalRecord[];
|
|
85
|
+
/** Records dropped because they aged past the TTL. */
|
|
86
|
+
readonly expired: number;
|
|
87
|
+
/** Entries dropped because they were not shaped like a record at all. */
|
|
88
|
+
readonly malformed: number;
|
|
89
|
+
/** The ledger file was present but could not be read or parsed as an array. */
|
|
90
|
+
readonly unreadable: boolean;
|
|
91
|
+
/** The ledger file exists on disk. */
|
|
92
|
+
readonly present: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Load and filter the ledger. Validation is by CONTENT — the file is parsed and
|
|
97
|
+
* every entry is shape-checked — so a torn, truncated or zero-byte ledger left
|
|
98
|
+
* by a crash reads as "no decisions recorded" instead of being trusted because
|
|
99
|
+
* it exists. Never throws: a corrupt file must not be able to take a boot down,
|
|
100
|
+
* and the worst outcome of losing it is that the user is asked once more.
|
|
101
|
+
*/
|
|
102
|
+
function loadLedger(surface: SessionSurface, nowMs: number): LedgerLoad {
|
|
103
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
104
|
+
let present = false;
|
|
105
|
+
try {
|
|
106
|
+
present = existsSync(path);
|
|
107
|
+
if (!present) return { kept: [], expired: 0, malformed: 0, unreadable: false, present: false };
|
|
108
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
109
|
+
if (!Array.isArray(parsed)) return { kept: [], expired: 0, malformed: 0, unreadable: true, present };
|
|
110
|
+
const wellFormed = parsed.filter(isRecord);
|
|
111
|
+
const kept = wellFormed.filter((record) => nowMs - record.removedAt < RECORD_TTL_MS);
|
|
112
|
+
return {
|
|
113
|
+
kept,
|
|
114
|
+
expired: wellFormed.length - kept.length,
|
|
115
|
+
malformed: parsed.length - wellFormed.length,
|
|
116
|
+
unreadable: false,
|
|
117
|
+
present,
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return { kept: [], expired: 0, malformed: 0, unreadable: true, present };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
74
124
|
/**
|
|
75
125
|
* Every removal decision still in force, oldest first. A missing, unreadable
|
|
76
126
|
* or malformed ledger reads as "no decisions recorded" rather than throwing:
|
|
77
127
|
* a corrupt file must not be able to take a boot down, and the worst outcome
|
|
78
128
|
* of losing it is that the user is asked once more.
|
|
129
|
+
*
|
|
130
|
+
* This is a filtered READ — it discards nothing from disk, so it stays silent.
|
|
131
|
+
* Disclosure belongs to the calls that actually delete: {@link pruneRecoveryDecisions}
|
|
132
|
+
* and {@link recordRecoveryRemoval}.
|
|
79
133
|
*/
|
|
80
134
|
export function readRecoveryRemovals(surface: SessionSurface, nowMs: number = Date.now()): RecoveryRemovalRecord[] {
|
|
135
|
+
return loadLedger(surface, nowMs).kept;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write `records` to the ledger through a temp file and an atomic rename.
|
|
140
|
+
*
|
|
141
|
+
* A plain `writeFileSync` onto the live path truncates first and fills after,
|
|
142
|
+
* so a crash — or a second TUI in another terminal writing the same home-
|
|
143
|
+
* anchored ledger at the same moment — could leave a half-written array that a
|
|
144
|
+
* later boot reads as an empty or corrupt ledger, silently forgetting every
|
|
145
|
+
* decision the user made. `rename` is atomic on the same filesystem: a reader
|
|
146
|
+
* sees either the whole old ledger or the whole new one. The temp name carries
|
|
147
|
+
* the pid so two writers cannot interleave into one temp file.
|
|
148
|
+
*/
|
|
149
|
+
function writeLedgerAtomically(path: string, records: readonly RecoveryRemovalRecord[]): void {
|
|
150
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
151
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
152
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
153
|
+
writeFileSync(temp, JSON.stringify(records, null, 2) + '\n', { mode: 0o600 });
|
|
154
|
+
renameSync(temp, path);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
try {
|
|
157
|
+
rmSync(temp, { force: true });
|
|
158
|
+
} catch {
|
|
159
|
+
// The temp file is inert; a failed cleanup is not worth a second error.
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What a prune pass discarded, returned so callers (and tests) can assert it. */
|
|
166
|
+
export interface RecoveryDecisionsPruneOutcome {
|
|
167
|
+
/** Records dropped because they aged past the 90-day TTL. */
|
|
168
|
+
readonly expired: number;
|
|
169
|
+
/** Entries dropped because they were not shaped like a record. */
|
|
170
|
+
readonly malformed: number;
|
|
171
|
+
/** Records dropped because the ledger was over the 200-record cap. */
|
|
172
|
+
readonly overCap: number;
|
|
173
|
+
/** Records still in force after the prune. */
|
|
174
|
+
readonly kept: number;
|
|
175
|
+
/** The ledger file was present but unreadable or not an array. */
|
|
176
|
+
readonly unreadable: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Apply both bounds to the on-disk ledger and say what was discarded.
|
|
181
|
+
*
|
|
182
|
+
* `readRecoveryRemovals` filters in memory on every call, so the expired
|
|
183
|
+
* records were already inert — but they stayed on disk until the next time the
|
|
184
|
+
* user happened to answer "Remove", which on a machine where that never happens
|
|
185
|
+
* again is never. This makes the reap explicit and, crucially, VISIBLE: a
|
|
186
|
+
* silent deletion is indistinguishable from data loss, so every pass that drops
|
|
187
|
+
* anything logs the counts and the ledger path.
|
|
188
|
+
*
|
|
189
|
+
* Idempotent and safe to run from two processes at once: it is a pure function
|
|
190
|
+
* of the file's content plus `nowMs`, the rewrite is a temp-file-plus-rename,
|
|
191
|
+
* and a second run over an already-pruned ledger drops nothing and writes
|
|
192
|
+
* nothing. Best-effort — a ledger that cannot be pruned is left exactly as it
|
|
193
|
+
* was and never breaks a boot.
|
|
194
|
+
*/
|
|
195
|
+
export function pruneRecoveryDecisions(surface: SessionSurface, nowMs: number = Date.now()): RecoveryDecisionsPruneOutcome {
|
|
196
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
197
|
+
const load = loadLedger(surface, nowMs);
|
|
198
|
+
const overCap = Math.max(0, load.kept.length - MAX_RECORDS);
|
|
199
|
+
const bounded = load.kept.slice(-MAX_RECORDS);
|
|
200
|
+
const outcome: RecoveryDecisionsPruneOutcome = {
|
|
201
|
+
expired: load.expired,
|
|
202
|
+
malformed: load.malformed,
|
|
203
|
+
overCap,
|
|
204
|
+
kept: bounded.length,
|
|
205
|
+
unreadable: load.unreadable,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (load.unreadable) {
|
|
209
|
+
// Do NOT rewrite: an unreadable ledger might be a transient read failure,
|
|
210
|
+
// and replacing it with `[]` would turn "could not read" into "forgot every
|
|
211
|
+
// decision the user made". Say so and leave it alone.
|
|
212
|
+
logger.warn('recovery decisions: ledger present but unreadable — left untouched, decisions may be re-asked', {
|
|
213
|
+
ledger: path,
|
|
214
|
+
});
|
|
215
|
+
return outcome;
|
|
216
|
+
}
|
|
217
|
+
const dropped = load.expired + load.malformed + overCap;
|
|
218
|
+
if (dropped === 0 || !load.present) return outcome;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
writeLedgerAtomically(path, bounded);
|
|
87
222
|
} catch {
|
|
88
|
-
|
|
223
|
+
// The in-memory filter already ignores these records; failing to persist
|
|
224
|
+
// the prune costs disk space, not correctness.
|
|
225
|
+
return outcome;
|
|
89
226
|
}
|
|
227
|
+
logger.info('recovery decisions: pruned the removal ledger', {
|
|
228
|
+
ledger: path,
|
|
229
|
+
expiredRecords: load.expired,
|
|
230
|
+
malformedEntries: load.malformed,
|
|
231
|
+
overCapRecords: overCap,
|
|
232
|
+
keptRecords: bounded.length,
|
|
233
|
+
ttlDays: Math.round(RECORD_TTL_MS / (24 * 60 * 60 * 1000)),
|
|
234
|
+
maxRecords: MAX_RECORDS,
|
|
235
|
+
});
|
|
236
|
+
return outcome;
|
|
90
237
|
}
|
|
91
238
|
|
|
92
239
|
/**
|
|
@@ -111,15 +258,37 @@ export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: st
|
|
|
111
258
|
*/
|
|
112
259
|
export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
|
|
113
260
|
if (!sessionId.trim()) return;
|
|
261
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
114
262
|
try {
|
|
115
|
-
const
|
|
263
|
+
const load = loadLedger(surface, nowMs);
|
|
264
|
+
if (load.unreadable) {
|
|
265
|
+
// Appending onto an unreadable ledger would rewrite it as a one-record
|
|
266
|
+
// array, silently discarding whatever the user decided before. Say so
|
|
267
|
+
// rather than quietly resetting their history.
|
|
268
|
+
logger.warn('recovery decisions: ledger unreadable — recording this removal replaces it', {
|
|
269
|
+
ledger: path,
|
|
270
|
+
sessionId,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const kept = load.kept.filter((record) => record.sessionId !== sessionId);
|
|
116
274
|
kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
|
|
117
275
|
// Oldest decisions fall off first; the newest are the ones a reappearing
|
|
118
276
|
// snapshot is most likely to match.
|
|
277
|
+
const overCap = Math.max(0, kept.length - MAX_RECORDS);
|
|
119
278
|
const bounded = kept.slice(-MAX_RECORDS);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
279
|
+
writeLedgerAtomically(path, bounded);
|
|
280
|
+
// Disclosure: this write is where records actually leave the disk, so it
|
|
281
|
+
// is where the counts get said out loud.
|
|
282
|
+
const dropped = load.expired + load.malformed + overCap;
|
|
283
|
+
if (dropped > 0) {
|
|
284
|
+
logger.info('recovery decisions: dropped records while recording a removal', {
|
|
285
|
+
ledger: path,
|
|
286
|
+
expiredRecords: load.expired,
|
|
287
|
+
malformedEntries: load.malformed,
|
|
288
|
+
overCapRecords: overCap,
|
|
289
|
+
keptRecords: bounded.length,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
123
292
|
} catch {
|
|
124
293
|
// Best-effort by construction — see the doc comment above.
|
|
125
294
|
}
|
|
@@ -46,11 +46,14 @@
|
|
|
46
46
|
* about again. Deleting the file used to be the entire memory of the
|
|
47
47
|
* answer, which is how the same question survived being answered three
|
|
48
48
|
* times.
|
|
49
|
+
* - The offer is also where that ledger gets swept. Every run prunes it to
|
|
50
|
+
* its age and count bounds and logs what it dropped, so the record of past
|
|
51
|
+
* decisions cannot quietly grow forever and nothing is deleted in silence.
|
|
49
52
|
*/
|
|
50
53
|
import { statSync } from 'node:fs';
|
|
51
54
|
import { checkRecoveryFile, checkRecoveryForSession, consumeRecovery, removeRecoveryPoint } from '@/runtime/index.ts';
|
|
52
55
|
import type { RecoveryFileInfo, SessionSurface } from '@/runtime/index.ts';
|
|
53
|
-
import { isRecoveryRemovalRecorded, recordRecoveryRemoval } from './recovery-decisions.ts';
|
|
56
|
+
import { isRecoveryRemovalRecorded, pruneRecoveryDecisions, recordRecoveryRemoval } from './recovery-decisions.ts';
|
|
54
57
|
import { checkSessionLiveness } from './session-liveness-marker.ts';
|
|
55
58
|
import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
|
|
56
59
|
|
|
@@ -272,6 +275,13 @@ function findOfferableSnapshot(deps: RecoveryPromptDeps): RecoveryFileInfo | nul
|
|
|
272
275
|
*/
|
|
273
276
|
export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<RecoveryPromptOutcome> {
|
|
274
277
|
try {
|
|
278
|
+
// Housekeeping first: recovery time is when the removal ledger gets its
|
|
279
|
+
// bounds actually applied to disk and says what it discarded. Without this
|
|
280
|
+
// the expired records were filtered on read but never removed, so on a
|
|
281
|
+
// machine where the user never answers "Remove" again the file only ever
|
|
282
|
+
// grew. Idempotent, so the second call in a run (targeted --continue offer
|
|
283
|
+
// plus the general startup offer) drops nothing and writes nothing.
|
|
284
|
+
pruneRecoveryDecisions(deps.surface, deps.now?.() ?? Date.now());
|
|
275
285
|
const info = findOfferableSnapshot(deps);
|
|
276
286
|
if (!info) return 'none';
|
|
277
287
|
// Already answered about this snapshot earlier in this same run (the
|
package/src/runtime/services.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface RuntimeServicesOptions {
|
|
|
96
96
|
/** Host power seam opt-in. Fork mirrors the SDK: non-spawning unavailable-seam
|
|
97
97
|
* default (idle-power-services.ts); daemon + embedded runtime pass createHostPowerSeam(). */
|
|
98
98
|
readonly powerSeam?: Parameters<typeof wireIdlePowerAndLiveTurn>[0]['powerSeam'];
|
|
99
|
+
/** Live session id, read per crash-residue sweep so the running session is exempt — see durability-services.ts. */
|
|
100
|
+
readonly currentSessionId?: (() => string | null) | undefined;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export interface RuntimeServices {
|
|
@@ -207,6 +209,9 @@ export interface RuntimeServices {
|
|
|
207
209
|
readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
|
|
208
210
|
/** Daily snapshots of every SQLite store this runtime writes, with bounded retention; unref'd timers (mirrors the SDK composition — hosts that tear down a runtime stop() it themselves). */
|
|
209
211
|
readonly storeSnapshotScheduler: StoreSnapshotScheduler;
|
|
212
|
+
readonly appendOnlyRetentionScheduler: ReturnType<typeof createDurabilityServices>['appendOnlyRetentionScheduler']; // periodic append-only sweep; unref'd timers, stop() on teardown
|
|
213
|
+
/** Stops the recurring crash-residue sweep; idempotent, unref'd timer (hosts that tear a runtime down call it). */
|
|
214
|
+
readonly stopDurabilityHousekeeping: () => void;
|
|
210
215
|
readonly memoryConsolidationScheduler: MemoryConsolidationScheduler;
|
|
211
216
|
readonly powerManager: PowerManager;
|
|
212
217
|
/** The daemon's memory governor (default ON). Backs ops.memory.get and defends the daemon's footprint by tier. */
|
|
@@ -566,10 +571,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
566
571
|
// with MemoryStore above. Auto-build is config-gated (default off) — see
|
|
567
572
|
// code-index-services.ts's header doc.
|
|
568
573
|
const { codeIndexStore, codeIndexReindexScheduler } = createCodeIndexServices({ workingDirectory, configManager, memoryEmbeddingRegistry, isReindexPaused: () => pauseController.isPaused('code-index-reindex'), admitExpensiveWork });
|
|
569
|
-
// Store snapshots
|
|
570
|
-
|
|
571
|
-
const { storeSnapshotScheduler, userPermissionRuleStore } = createDurabilityServices({
|
|
574
|
+
// Store snapshots, the periodic append-only sweep, durable remembered-approval rules + the live credential chain — see durability-services.ts.
|
|
575
|
+
const { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping } = createDurabilityServices({
|
|
572
576
|
configManager, secretsManager, providerRegistry, memoryDbPath, codeIndexDbPath: codeIndexDbPath(workingDirectory), surface, shellPaths, // + retention-sweep roots & live config watch (mirrors the SDK)
|
|
577
|
+
...(options.currentSessionId ? { currentSessionId: options.currentSessionId } : {}), // exempts the running session from crash-residue reaping
|
|
573
578
|
});
|
|
574
579
|
const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Code-injection seam (agent here; main via orchestrator-core-services.ts)
|
|
575
580
|
const { processRegistry } = createFleetServices({ // Shared archive-aware fleet registry (+ daemon observed rows) — see fleet-services.ts
|
|
@@ -778,7 +783,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
778
783
|
workstreamCommands,
|
|
779
784
|
codeIndexStore,
|
|
780
785
|
codeIndexReindexScheduler,
|
|
781
|
-
storeSnapshotScheduler,
|
|
786
|
+
storeSnapshotScheduler, appendOnlyRetentionScheduler, stopDurabilityHousekeeping,
|
|
782
787
|
memoryConsolidationScheduler,
|
|
783
788
|
powerManager,
|
|
784
789
|
memoryGovernor,
|