@pellux/goodvibes-tui 1.20.1 → 1.21.1
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 +22 -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/input/settings-modal-types.ts +2 -1
- 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/renderer/settings-modal-helpers.ts +53 -1
- package/src/renderer/settings-modal.ts +1 -50
- 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
|
@@ -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,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* recovery snapshots they describe — mirrors the transcript-journal.ts
|
|
25
25
|
* convention (homeDirectory-scoped, under the surface's own directory).
|
|
26
26
|
*/
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { dirname, join } from 'node:path';
|
|
29
29
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
30
30
|
|
|
@@ -42,9 +42,14 @@ export interface LivenessMarker {
|
|
|
42
42
|
*/
|
|
43
43
|
export const LIVENESS_STALE_AFTER_MS = 150_000;
|
|
44
44
|
|
|
45
|
+
/** Absolute path to the directory holding every session's liveness marker. */
|
|
46
|
+
export function livenessMarkerDirFor(surface: SessionSurface): string {
|
|
47
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'liveness');
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
/** Absolute path to a session's liveness marker file. */
|
|
46
51
|
export function livenessMarkerPathFor(surface: SessionSurface, sessionId: string): string {
|
|
47
|
-
return join(surface
|
|
52
|
+
return join(livenessMarkerDirFor(surface), `${sessionId}.json`);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** Refresh (creating if needed) the liveness marker for this session/pid. Best-effort — never throws. */
|
|
@@ -69,18 +74,27 @@ export function removeLivenessMarker(surface: SessionSurface, sessionId: string)
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
/** Parse one marker file by absolute path. Returns null for missing, empty, torn, or wrong-shaped content. */
|
|
78
|
+
function parseMarkerFile(path: string): LivenessMarker | null {
|
|
73
79
|
try {
|
|
74
|
-
const path = livenessMarkerPathFor(surface, sessionId);
|
|
75
80
|
if (!existsSync(path)) return null;
|
|
76
|
-
const
|
|
81
|
+
const text = readFileSync(path, 'utf-8');
|
|
82
|
+
// A crash between create and write leaves a zero-byte file. Content, not
|
|
83
|
+
// existence, decides whether this marker says anything at all.
|
|
84
|
+
if (text.trim().length === 0) return null;
|
|
85
|
+
const parsed = JSON.parse(text) as Partial<LivenessMarker>;
|
|
77
86
|
if (typeof parsed.sessionId !== 'string' || typeof parsed.pid !== 'number' || typeof parsed.updatedAt !== 'number') return null;
|
|
87
|
+
if (!Number.isFinite(parsed.pid) || !Number.isFinite(parsed.updatedAt)) return null;
|
|
78
88
|
return { sessionId: parsed.sessionId, pid: parsed.pid, updatedAt: parsed.updatedAt };
|
|
79
89
|
} catch {
|
|
80
90
|
return null;
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
function readLivenessMarker(surface: SessionSurface, sessionId: string): LivenessMarker | null {
|
|
95
|
+
return parseMarkerFile(livenessMarkerPathFor(surface, sessionId));
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
/** Best-effort check that a pid still refers to a running process. Injectable for tests. */
|
|
85
99
|
export function isPidAlive(pid: number, kill: (pid: number, signal: 0) => void = process.kill.bind(process)): boolean {
|
|
86
100
|
try {
|
|
@@ -118,3 +132,126 @@ export function checkSessionLiveness(
|
|
|
118
132
|
if (!aliveCheck(marker.pid)) return { live: false, pid: null };
|
|
119
133
|
return { live: true, pid: marker.pid };
|
|
120
134
|
}
|
|
135
|
+
|
|
136
|
+
// ─── Reaping ────────────────────────────────────────────────────────────────
|
|
137
|
+
//
|
|
138
|
+
// `removeLivenessMarker` only runs on a CLEAN exit (process-lifecycle.ts). A
|
|
139
|
+
// SIGKILL, a panic, or a pulled power cord leaves the marker behind forever,
|
|
140
|
+
// and `checkSessionLiveness` treating it as not-live does not remove the file
|
|
141
|
+
// — so the liveness directory grows one file per session id ever opened. The
|
|
142
|
+
// sweep below is the reclaim half of that lifecycle.
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hard ceiling on marker files kept after a sweep, oldest dropped first.
|
|
146
|
+
*
|
|
147
|
+
* The liveness rule below already reclaims ordinary crash residue, but it can
|
|
148
|
+
* be defeated: `isPidAlive` deliberately reports EPERM (a pid this user cannot
|
|
149
|
+
* signal) as alive, so on a busy host a reused pid can keep a dead session's
|
|
150
|
+
* marker "live" indefinitely. This cap is the second, unconditional bound so
|
|
151
|
+
* the directory can never grow without limit whatever the pid probe says.
|
|
152
|
+
*/
|
|
153
|
+
export const LIVENESS_MARKER_MAX_FILES = 200;
|
|
154
|
+
|
|
155
|
+
export interface LivenessReapResult {
|
|
156
|
+
/** Marker files examined this sweep. */
|
|
157
|
+
readonly scanned: number;
|
|
158
|
+
/** Marker files deleted this sweep. */
|
|
159
|
+
readonly reaped: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface LivenessReapOptions {
|
|
163
|
+
readonly now?: () => number;
|
|
164
|
+
readonly isPidAliveFn?: typeof isPidAlive;
|
|
165
|
+
/**
|
|
166
|
+
* Session ids that must never be reaped regardless of what the marker says
|
|
167
|
+
* — normally just the current session's id. Belt-and-braces: the current
|
|
168
|
+
* session refreshes its own marker every 60s, so the liveness rule already
|
|
169
|
+
* keeps it.
|
|
170
|
+
*/
|
|
171
|
+
readonly keepSessionIds?: readonly string[];
|
|
172
|
+
/** Override the count cap (tests). */
|
|
173
|
+
readonly maxFiles?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete liveness markers that no longer describe a running session.
|
|
178
|
+
*
|
|
179
|
+
* A marker is reaped when it is either:
|
|
180
|
+
* - unreadable, empty, or not shaped like a marker (crash residue that can
|
|
181
|
+
* never resolve to a live session again — validated by parsing the
|
|
182
|
+
* content, not by the file merely existing); or
|
|
183
|
+
* - definitively not live: older than `LIVENESS_STALE_AFTER_MS` AND its pid
|
|
184
|
+
* no longer resolves to a running process.
|
|
185
|
+
*
|
|
186
|
+
* Both halves of that second rule are required on purpose. Deleting is
|
|
187
|
+
* destructive while a liveness answer is only advisory, so a marker that is
|
|
188
|
+
* stale-but-pid-alive (a wedged owner still holding the session) or
|
|
189
|
+
* fresh-but-pid-dead (a process that died seconds ago, or a pid probe that
|
|
190
|
+
* failed transiently) is left for a later sweep. A genuinely crashed session
|
|
191
|
+
* satisfies both conditions within `LIVENESS_STALE_AFTER_MS` and is reclaimed
|
|
192
|
+
* on the next sweep after that.
|
|
193
|
+
*
|
|
194
|
+
* Idempotent and safe to run concurrently from several processes: a marker
|
|
195
|
+
* another sweeper unlinked between the directory listing and this unlink
|
|
196
|
+
* (ENOENT) counts as reaped, never as an error.
|
|
197
|
+
*/
|
|
198
|
+
export function reapStaleLivenessMarkers(surface: SessionSurface, opts: LivenessReapOptions = {}): LivenessReapResult {
|
|
199
|
+
const dir = livenessMarkerDirFor(surface);
|
|
200
|
+
let names: string[];
|
|
201
|
+
try {
|
|
202
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.json'));
|
|
203
|
+
} catch {
|
|
204
|
+
// No liveness directory yet, or it is unreadable — nothing to reclaim.
|
|
205
|
+
return { scanned: 0, reaped: 0 };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const now = opts.now?.() ?? Date.now();
|
|
209
|
+
const aliveCheck = opts.isPidAliveFn ?? isPidAlive;
|
|
210
|
+
const keep = new Set(opts.keepSessionIds ?? []);
|
|
211
|
+
const maxFiles = opts.maxFiles ?? LIVENESS_MARKER_MAX_FILES;
|
|
212
|
+
|
|
213
|
+
let reaped = 0;
|
|
214
|
+
/** Survivors, so the count cap below can drop the oldest of them. */
|
|
215
|
+
const survivors: { readonly path: string; readonly updatedAt: number }[] = [];
|
|
216
|
+
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const sessionId = name.slice(0, -'.json'.length);
|
|
219
|
+
if (keep.has(sessionId)) continue;
|
|
220
|
+
const path = join(dir, name);
|
|
221
|
+
const marker = parseMarkerFile(path);
|
|
222
|
+
if (marker === null) {
|
|
223
|
+
if (unlinkIfPresent(path)) reaped++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const stale = now - marker.updatedAt > LIVENESS_STALE_AFTER_MS;
|
|
227
|
+
if (stale && !aliveCheck(marker.pid)) {
|
|
228
|
+
if (unlinkIfPresent(path)) reaped++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
survivors.push({ path, updatedAt: marker.updatedAt });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (survivors.length > maxFiles) {
|
|
235
|
+
survivors.sort((a, b) => a.updatedAt - b.updatedAt); // oldest first
|
|
236
|
+
for (const victim of survivors.slice(0, survivors.length - maxFiles)) {
|
|
237
|
+
if (unlinkIfPresent(victim.path)) reaped++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { scanned: names.length, reaped };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Best-effort unlink shared by the sweep. Returns true when the file is gone
|
|
246
|
+
* because of (or by the time of) this call. A concurrent sweeper winning the
|
|
247
|
+
* race (ENOENT) is success, not failure; any other error leaves the file for
|
|
248
|
+
* the next sweep.
|
|
249
|
+
*/
|
|
250
|
+
function unlinkIfPresent(path: string): boolean {
|
|
251
|
+
try {
|
|
252
|
+
unlinkSync(path);
|
|
253
|
+
return true;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -21,19 +21,99 @@
|
|
|
21
21
|
// unreadable or malformed file, or a write failure degrades to "that draft
|
|
22
22
|
// isn't there" rather than crashing session startup — mirroring the engine
|
|
23
23
|
// persistence layer's own quarantine-don't-propagate discipline.
|
|
24
|
+
//
|
|
25
|
+
// RECLAIM: `remove(id)` retires a draft the moment it is launched or cancelled,
|
|
26
|
+
// but a draft the user simply walks away from was never reclaimed by anything,
|
|
27
|
+
// so the directory grew forever. `loadAll()` — the recovery point, called once
|
|
28
|
+
// at command-facade construction — now also bounds the directory by BOTH an age
|
|
29
|
+
// TTL and a count cap, and reports what it reclaimed through `lastReclaim` /
|
|
30
|
+
// the `onReclaim` hook. Deletions are `rmSync(..., { force: true })`, so a file
|
|
31
|
+
// another process removed first is not an error: two concurrent `loadAll()`
|
|
32
|
+
// calls converge on the same surviving set.
|
|
24
33
|
// ---------------------------------------------------------------------------
|
|
25
34
|
|
|
26
|
-
import { mkdirSync, readdirSync, readFileSync, rmSync,
|
|
35
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
27
36
|
import { join } from 'node:path';
|
|
37
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
28
38
|
import type { WorkstreamDraft } from './workstream-draft-types.ts';
|
|
29
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Age TTL for an abandoned draft. A proposal that has sat un-launched and
|
|
42
|
+
* un-cancelled this long is not a plan the user is still reviewing; it is
|
|
43
|
+
* litter. Measured from the draft's own `createdAt`.
|
|
44
|
+
*/
|
|
45
|
+
export const WORKSTREAM_DRAFT_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Count cap on retained drafts: the newest this many survive, older ones are
|
|
49
|
+
* reclaimed oldest-first. Bounds the directory even if every draft is recent.
|
|
50
|
+
*/
|
|
51
|
+
export const WORKSTREAM_DRAFT_CAP = 50;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Minimum gap between reaps triggered by `save()`. `loadAll()` is the recovery
|
|
55
|
+
* point but runs once per session, so a session that stays open for days would
|
|
56
|
+
* otherwise never reap again. Every `save()` (draft created, reshaped, or
|
|
57
|
+
* approved) re-checks the bounds, throttled to this interval so the directory
|
|
58
|
+
* is not re-scanned on every keystroke-driven save.
|
|
59
|
+
*/
|
|
60
|
+
export const WORKSTREAM_DRAFT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
61
|
+
|
|
62
|
+
/** What one `loadAll()` reclaimed. Counts only — never draft text. */
|
|
63
|
+
export interface WorkstreamDraftReclaim {
|
|
64
|
+
/** Unix ms of the sweep. */
|
|
65
|
+
readonly at: number;
|
|
66
|
+
/** Valid drafts removed because they aged past the TTL. */
|
|
67
|
+
readonly expired: number;
|
|
68
|
+
/** Valid drafts removed because they fell outside the count cap. */
|
|
69
|
+
readonly overCap: number;
|
|
70
|
+
/** Torn/malformed snapshots removed once they were older than the TTL. */
|
|
71
|
+
readonly unreadable: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WorkstreamDraftStoreOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Called after a `loadAll()` that reclaimed at least one file, so the count
|
|
77
|
+
* can be surfaced to the operator. Receives counts only. Never throws out of
|
|
78
|
+
* `loadAll()` — a failing hook is swallowed like every other side effect here.
|
|
79
|
+
*/
|
|
80
|
+
readonly onReclaim?: (summary: WorkstreamDraftReclaim) => void;
|
|
81
|
+
/** Clock seam (tests). Defaults to Date.now. */
|
|
82
|
+
readonly now?: () => number;
|
|
83
|
+
/** Override the age TTL (tests / embedders). Defaults to WORKSTREAM_DRAFT_TTL_MS. */
|
|
84
|
+
readonly ttlMs?: number;
|
|
85
|
+
/** Override the count cap (tests / embedders). Defaults to WORKSTREAM_DRAFT_CAP. */
|
|
86
|
+
readonly cap?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Throttle for the reap that `save()` triggers. 0 or less means every save
|
|
89
|
+
* reaps (tests); defaults to WORKSTREAM_DRAFT_SWEEP_INTERVAL_MS.
|
|
90
|
+
*/
|
|
91
|
+
readonly sweepIntervalMs?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
export interface WorkstreamDraftStore {
|
|
31
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Every valid draft on disk, oldest first, after reaping abandoned ones. A
|
|
97
|
+
* directory that doesn't exist yet ⇒ []. Malformed files are skipped, never
|
|
98
|
+
* fatal, and are only deleted once older than the TTL.
|
|
99
|
+
*/
|
|
32
100
|
loadAll(): WorkstreamDraft[];
|
|
33
|
-
/** Write (or overwrite) one draft's snapshot. Silently no-ops on any I/O failure — a lost journal write must never break the in-memory flow. */
|
|
101
|
+
/** Write (or overwrite) one draft's snapshot atomically. Silently no-ops on any I/O failure — a lost journal write must never break the in-memory flow. */
|
|
34
102
|
save(draft: WorkstreamDraft): void;
|
|
35
103
|
/** Delete one draft's snapshot (on launch or cancel). Missing file ⇒ no-op. */
|
|
36
104
|
remove(id: string): void;
|
|
105
|
+
/** Summary of the most recent `loadAll()` that reclaimed something; null otherwise. */
|
|
106
|
+
readonly lastReclaim: WorkstreamDraftReclaim | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Human-readable, content-free disclosure line for a reclaim summary. */
|
|
110
|
+
export function formatWorkstreamDraftReclaim(summary: WorkstreamDraftReclaim): string {
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
if (summary.expired > 0) parts.push(`${summary.expired} abandoned`);
|
|
113
|
+
if (summary.overCap > 0) parts.push(`${summary.overCap} over the retention cap`);
|
|
114
|
+
if (summary.unreadable > 0) parts.push(`${summary.unreadable} unreadable`);
|
|
115
|
+
if (parts.length === 0) return 'Workstream drafts: nothing reclaimed.';
|
|
116
|
+
return `Workstream drafts: reclaimed ${parts.join(', ')} draft file(s).`;
|
|
37
117
|
}
|
|
38
118
|
|
|
39
119
|
/** Draft ids are `wsd_<hex>`; this refuses anything else so a crafted id can never escape the drafts directory. */
|
|
@@ -54,48 +134,141 @@ function isDraftShape(value: unknown): value is WorkstreamDraft {
|
|
|
54
134
|
);
|
|
55
135
|
}
|
|
56
136
|
|
|
57
|
-
export function createWorkstreamDraftStore(
|
|
137
|
+
export function createWorkstreamDraftStore(
|
|
138
|
+
projectRoot: string,
|
|
139
|
+
options: WorkstreamDraftStoreOptions = {},
|
|
140
|
+
): WorkstreamDraftStore {
|
|
58
141
|
const dir = join(projectRoot, '.goodvibes', 'orchestration', 'drafts');
|
|
59
142
|
const fileFor = (id: string): string => join(dir, `${id}.json`);
|
|
143
|
+
const clock = options.now ?? Date.now;
|
|
144
|
+
const ttlMs = options.ttlMs ?? WORKSTREAM_DRAFT_TTL_MS;
|
|
145
|
+
const cap = options.cap ?? WORKSTREAM_DRAFT_CAP;
|
|
146
|
+
const sweepIntervalMs = options.sweepIntervalMs ?? WORKSTREAM_DRAFT_SWEEP_INTERVAL_MS;
|
|
147
|
+
let lastReclaim: WorkstreamDraftReclaim | null = null;
|
|
148
|
+
/** Unix ms of this instance's last reap; 0 means "never", so the first save sweeps. */
|
|
149
|
+
let lastSweepAt = 0;
|
|
60
150
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
151
|
+
/** Delete one path, treating "already gone" as success (concurrent sweeps). */
|
|
152
|
+
const discard = (path: string): void => {
|
|
153
|
+
try {
|
|
154
|
+
rmSync(path, { force: true });
|
|
155
|
+
} catch {
|
|
156
|
+
// Unremovable (permissions, or a racing process holding it) — the next
|
|
157
|
+
// loadAll() will try again; never fatal.
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Age of a file by mtime, used only for snapshots whose contents we cannot trust. */
|
|
162
|
+
const modifiedBefore = (path: string, cutoff: number): boolean => {
|
|
163
|
+
try {
|
|
164
|
+
return statSync(path).mtimeMs < cutoff;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read every snapshot, reject the ones whose CONTENT is not a draft, apply
|
|
172
|
+
* both bounds, and record/announce what was reclaimed. Returns the surviving
|
|
173
|
+
* drafts, oldest first. Never throws.
|
|
174
|
+
*
|
|
175
|
+
* Idempotent: a second run over the same directory finds nothing left to
|
|
176
|
+
* remove, which is also what makes two processes running it at once safe —
|
|
177
|
+
* they converge on the same surviving set and unlink races count as done.
|
|
178
|
+
*/
|
|
179
|
+
const reap = (): WorkstreamDraft[] => {
|
|
180
|
+
let entries: string[];
|
|
181
|
+
try {
|
|
182
|
+
entries = readdirSync(dir);
|
|
183
|
+
} catch {
|
|
184
|
+
lastReclaim = null;
|
|
185
|
+
return []; // directory not created yet, or unreadable — nothing to resume
|
|
186
|
+
}
|
|
187
|
+
const now = clock();
|
|
188
|
+
lastSweepAt = now;
|
|
189
|
+
const cutoff = now - ttlMs;
|
|
190
|
+
const drafts: WorkstreamDraft[] = [];
|
|
191
|
+
let unreadable = 0;
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (!entry.endsWith('.json')) continue;
|
|
194
|
+
const path = join(dir, entry);
|
|
195
|
+
let parsed: unknown;
|
|
64
196
|
try {
|
|
65
|
-
|
|
197
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
66
198
|
} catch {
|
|
67
|
-
|
|
199
|
+
parsed = undefined;
|
|
68
200
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
} catch {
|
|
76
|
-
// A malformed or half-written snapshot is skipped, never fatal.
|
|
77
|
-
}
|
|
201
|
+
// Validation is by CONTENT: a torn or half-written snapshot never
|
|
202
|
+
// becomes a draft. It is only DELETED once it is older than the TTL, so
|
|
203
|
+
// a file another process is writing right now is left alone.
|
|
204
|
+
if (parsed !== undefined && isDraftShape(parsed) && isSafeId(parsed.id)) {
|
|
205
|
+
drafts.push(parsed);
|
|
206
|
+
continue;
|
|
78
207
|
}
|
|
79
|
-
|
|
208
|
+
if (modifiedBefore(path, cutoff)) {
|
|
209
|
+
discard(path);
|
|
210
|
+
unreadable += 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
drafts.sort((a, b) => a.createdAt - b.createdAt);
|
|
214
|
+
|
|
215
|
+
// Bound 1 — age TTL: an abandoned proposal is reclaimed.
|
|
216
|
+
const live: WorkstreamDraft[] = [];
|
|
217
|
+
let expired = 0;
|
|
218
|
+
for (const draft of drafts) {
|
|
219
|
+
if (now - draft.createdAt > ttlMs) {
|
|
220
|
+
discard(fileFor(draft.id));
|
|
221
|
+
expired += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
live.push(draft);
|
|
225
|
+
}
|
|
226
|
+
// Bound 2 — count cap: keep the newest `cap`, reclaim the oldest overflow.
|
|
227
|
+
const overCapCount = Math.max(0, live.length - cap);
|
|
228
|
+
for (const draft of live.slice(0, overCapCount)) discard(fileFor(draft.id));
|
|
229
|
+
const kept = live.slice(overCapCount);
|
|
230
|
+
|
|
231
|
+
if (expired + overCapCount + unreadable > 0) {
|
|
232
|
+
lastReclaim = { at: now, expired, overCap: overCapCount, unreadable };
|
|
233
|
+
try {
|
|
234
|
+
options.onReclaim?.(lastReclaim);
|
|
235
|
+
} catch {
|
|
236
|
+
// Disclosure must never take startup down.
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
lastReclaim = null;
|
|
240
|
+
}
|
|
241
|
+
return kept;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
get lastReclaim(): WorkstreamDraftReclaim | null {
|
|
246
|
+
return lastReclaim;
|
|
247
|
+
},
|
|
248
|
+
loadAll(): WorkstreamDraft[] {
|
|
249
|
+
return reap();
|
|
80
250
|
},
|
|
81
251
|
save(draft: WorkstreamDraft): void {
|
|
82
252
|
if (!isSafeId(draft.id)) return;
|
|
83
253
|
try {
|
|
84
254
|
mkdirSync(dir, { recursive: true });
|
|
85
|
-
|
|
255
|
+
// Atomic (temp + fsync + rename): a crash mid-save can no longer leave a
|
|
256
|
+
// truncated snapshot behind for loadAll() to reject.
|
|
257
|
+
atomicWriteFileSync(fileFor(draft.id), JSON.stringify(draft));
|
|
86
258
|
} catch {
|
|
87
259
|
// A failed journal write leaves the in-memory draft intact and usable;
|
|
88
260
|
// only its restart-durability is lost, which is strictly better than
|
|
89
261
|
// failing the edit the user just made.
|
|
90
262
|
}
|
|
263
|
+
// Keep reaping DURING the session, not only at the loadAll() recovery
|
|
264
|
+
// point: loadAll runs once at construction, so a long-lived session would
|
|
265
|
+
// otherwise never bound the directory again. Throttled, and always after
|
|
266
|
+
// this draft is on disk so the newest file is never the one reclaimed.
|
|
267
|
+
if (clock() - lastSweepAt >= sweepIntervalMs) reap();
|
|
91
268
|
},
|
|
92
269
|
remove(id: string): void {
|
|
93
270
|
if (!isSafeId(id)) return;
|
|
94
|
-
|
|
95
|
-
rmSync(fileFor(id), { force: true });
|
|
96
|
-
} catch {
|
|
97
|
-
// Already gone, or unremovable — nothing more to do.
|
|
98
|
-
}
|
|
271
|
+
discard(fileFor(id));
|
|
99
272
|
},
|
|
100
273
|
};
|
|
101
274
|
}
|
|
@@ -56,7 +56,8 @@ import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
|
|
|
56
56
|
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
57
57
|
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
58
58
|
import { editItemBrief, moveItemInSpec, removeItemFromSpec } from './workstream-draft-edits.ts';
|
|
59
|
-
import { createWorkstreamDraftStore } from './workstream-draft-store.ts';
|
|
59
|
+
import { createWorkstreamDraftStore, formatWorkstreamDraftReclaim } from './workstream-draft-store.ts';
|
|
60
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
60
61
|
|
|
61
62
|
export interface WorkstreamServicesDeps {
|
|
62
63
|
readonly agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel' | 'registerCancellationSignal' | 'releaseCancellationSignal'>;
|
|
@@ -184,7 +185,21 @@ function createWorkstreamCommandService(
|
|
|
184
185
|
// proposal at construction so a create/reshape/approve done before a restart
|
|
185
186
|
// is still here to launch afterward — the plan-review gate survives restart,
|
|
186
187
|
// exactly as the live-workstream snapshots do via resumeAllFromDisk().
|
|
187
|
-
|
|
188
|
+
// Reclaiming an abandoned draft deletes a plan the user once wrote, so it is
|
|
189
|
+
// never done silently: the store reaps at load and on a throttled save, and
|
|
190
|
+
// every reap that actually removed something reports a content-free count
|
|
191
|
+
// here. Without this hook the store recorded the reclaim on itself and
|
|
192
|
+
// nothing ever read it, which is deletion the user has no way to notice.
|
|
193
|
+
const store = createWorkstreamDraftStore(projectRoot, {
|
|
194
|
+
onReclaim: (summary) => {
|
|
195
|
+
logger.info(formatWorkstreamDraftReclaim(summary), {
|
|
196
|
+
projectRoot,
|
|
197
|
+
expired: summary.expired,
|
|
198
|
+
overCap: summary.overCap,
|
|
199
|
+
unreadable: summary.unreadable,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
});
|
|
188
203
|
for (const persisted of store.loadAll()) drafts.set(persisted.id, persisted);
|
|
189
204
|
|
|
190
205
|
/**
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -373,7 +373,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
373
373
|
// 'search' case) — the transcript search overlay isn't tracked on the
|
|
374
374
|
// modal stack, so this doesn't call modalOpened either.
|
|
375
375
|
commandContext.openTranscriptSearch = () => {
|
|
376
|
-
if (input.searchManager.active) input.searchManager.close();
|
|
376
|
+
if (input.searchManager.active) input.searchManager.close(input.conversationManager);
|
|
377
377
|
else input.searchManager.open();
|
|
378
378
|
render();
|
|
379
379
|
};
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.
|
|
9
|
+
let _version = '1.21.1';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
// Only trust a version read from OUR OWN package.json. A Bun single-file
|