dsh-rewind-plugin 0.20.0 → 0.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/README.en.md +2 -2
- package/README.md +2 -2
- package/SECURITY.md +80 -183
- package/lib/client.js +417 -387
- package/lib/index.js +223 -192
- package/lib/types/client/action.d.ts +40 -0
- package/lib/types/client/hidden.d.ts +2 -2
- package/lib/types/client/index.d.ts +14 -11
- package/lib/types/client/locales.d.ts +17 -21
- package/lib/types/client/log.d.ts +1 -1
- package/lib/types/client/mount.d.ts +79 -0
- package/lib/types/client/pending-mount.d.ts +36 -0
- package/lib/types/client/pending.d.ts +43 -57
- package/lib/types/client/popover.d.ts +19 -19
- package/lib/types/client/retract.d.ts +32 -0
- package/lib/types/client/rewind-flow.d.ts +86 -0
- package/lib/types/client/styles.d.ts +5 -14
- package/lib/types/snapshot-format.d.ts +479 -0
- package/lib/types/snapshot-fs.d.ts +85 -0
- package/lib/types/snapshot.d.ts +5 -373
- package/package.json +1 -1
- package/lib/types/client/portals.d.ts +0 -301
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-rewind flow half: the executed rewind command and its composer refill.
|
|
3
|
+
*
|
|
4
|
+
* This is the BEHAVIOR layer, deliberately free of any mounting decision. Both
|
|
5
|
+
* the `/rewind` text flow (command decoration → mode popover) and, in A2, the
|
|
6
|
+
* per-message action call {@link runRewindAndFill}; nothing here portals,
|
|
7
|
+
* observes rows, or knows where the trigger sits.
|
|
8
|
+
*
|
|
9
|
+
* The refill is event-driven: it runs only when THIS page performed the
|
|
10
|
+
* rewind (the user confirmed moments ago). It must NEVER scan loaded history
|
|
11
|
+
* for rewind commands — a session window opens with only the tail page, so
|
|
12
|
+
* "a command already in the snapshot" cannot be told apart from "executed in
|
|
13
|
+
* this page", and the old baseline heuristic refilled withdrawn text into the
|
|
14
|
+
* composer after switching sessions or restarting dsh.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-rewind/client/rewind-flow
|
|
17
|
+
*/
|
|
18
|
+
import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client';
|
|
19
|
+
import { type ChatOf, type ChatWatch, type MessageImageRef } from './hidden.ts';
|
|
20
|
+
/**
|
|
21
|
+
* The session input facade's write face (structural, so the plugin
|
|
22
|
+
* never imports the conversation UI package). `setDraft` replaces the whole
|
|
23
|
+
* composer draft through the harness's own Lexical editor — the correct way
|
|
24
|
+
* to restore the withdrawn text.
|
|
25
|
+
*/
|
|
26
|
+
interface ComposerDraftWriter {
|
|
27
|
+
setDraft(text: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Return the keyboard to the composer with the caret it last held (the
|
|
30
|
+
* facade's own `focus`). Optional so the DOM fallback and older faces still
|
|
31
|
+
* restore the text.
|
|
32
|
+
*/
|
|
33
|
+
focus?(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fill the dsh composer with `text` through the `contenteditable`
|
|
37
|
+
* DOM path. Used by `setComposerText` (the harness-facade-aware writer) as the
|
|
38
|
+
* last-resort and by `runRewindAndFill` to put the withdrawn target message
|
|
39
|
+
* back into the composer after a rewind. Best-effort — no composer match means
|
|
40
|
+
* false, never a throw.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fillComposer(text: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Composer write: prefer the harness facade's `setDraft` (correct whole-draft
|
|
45
|
+
* replace), then degrade to the DOM `fillComposer` (contenteditable). A facade
|
|
46
|
+
* that throws (session teardown) is treated as absent so the DOM path still
|
|
47
|
+
* restores the text. Never throws.
|
|
48
|
+
* @param text - the withdrawn target message text.
|
|
49
|
+
* @param facade - the session input draft writer, when reachable.
|
|
50
|
+
* @returns whether a channel applied the text.
|
|
51
|
+
*/
|
|
52
|
+
export declare function writeComposer(text: string, facade: ComposerDraftWriter | undefined): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Execute one rewind and, when it settles successfully, put the withdrawn
|
|
55
|
+
* target message's text back into the composer so the user can edit and
|
|
56
|
+
* re-send.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', isMainViewSession: (sessionId: string) => boolean, chatOf: ChatOf, watchChat: ChatWatch, setComposerText: (sessionId: string, text: string) => boolean, restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise<void>): Promise<void>;
|
|
59
|
+
/** The current composer draft: the contenteditable `textContent`.
|
|
60
|
+
* Empty when the composer is absent. Exported as a test seam (the
|
|
61
|
+
* empty-composer guard in `runRewindAndFill`). */
|
|
62
|
+
export declare function composerText(): string;
|
|
63
|
+
/**
|
|
64
|
+
* The session-kind slice the durable-target gate reads — the session
|
|
65
|
+
* snapshot's `subagent` cell, the Harness's own runtime signal for a
|
|
66
|
+
* direct-subagent (child) session. Typed structurally so the plugin never
|
|
67
|
+
* imports the session-controller snapshot contract.
|
|
68
|
+
*/
|
|
69
|
+
export interface SessionKindLike {
|
|
70
|
+
/** Non-null exactly while this Session is addressed as a subagent child. */
|
|
71
|
+
readonly subagent: unknown;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether a session is rewind-inert: a direct-subagent (child) session.
|
|
75
|
+
*
|
|
76
|
+
* The Harness refuses every generic Session RPC for a subagent-owned identity
|
|
77
|
+
* (`session/agent-busy`, "use subagent delivery for this child session"), so
|
|
78
|
+
* `/rewind` can never execute there — the command's own admission is the
|
|
79
|
+
* refusal, before any handler runs. A wired-up action in such a session would
|
|
80
|
+
* therefore be dead UI (SiriLee/dsh-rewind#26). This mirrors the Harness's own
|
|
81
|
+
* slash-command directory (which returns no commands for an addressed child)
|
|
82
|
+
* and the Host's `isSubagentSession` skips: a child session gets no rewind
|
|
83
|
+
* surface and records no snapshot.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isRewindInertSession(snapshot: SessionKindLike): boolean;
|
|
86
|
+
export {};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client plugin styling: one injected `<style>` tag (scoped class names),
|
|
3
|
-
* following the dsh design tokens (`--dsw-*`) so the
|
|
4
|
-
*
|
|
3
|
+
* following the dsh design tokens (`--dsw-*`) so the per-message action, the
|
|
4
|
+
* mode popover, the failure toast and the settings card blend with the
|
|
5
|
+
* conversation chrome.
|
|
5
6
|
*
|
|
6
7
|
* @module dsh-rewind/client/styles
|
|
7
8
|
*/
|
|
8
9
|
/** Class names shared between the injected DOM and the stylesheet. */
|
|
9
10
|
export declare const CLASS: {
|
|
10
|
-
readonly
|
|
11
|
+
readonly action: 'dsh-rewind-action';
|
|
11
12
|
readonly popover: 'dsh-rewind-popover';
|
|
12
13
|
readonly popoverTitle: 'dsh-rewind-popover-title';
|
|
13
14
|
readonly popoverTarget: 'dsh-rewind-popover-target';
|
|
@@ -21,15 +22,5 @@ export declare const CLASS: {
|
|
|
21
22
|
readonly popoverGhost: 'dsh-rewind-popover-ghost';
|
|
22
23
|
readonly guardHint: 'dsh-rewind-guard-hint';
|
|
23
24
|
};
|
|
24
|
-
/**
|
|
25
|
-
* The ↶ glyph, drawn inline so the bundle stays dependency-free.
|
|
26
|
-
*
|
|
27
|
-
* Size follows the 0.1.7 product-icon set (15px, as the neighbouring copy/branch
|
|
28
|
-
* actions in `MessageIconActions`). Stroke is 1.25 rather than upstream's
|
|
29
|
-
* `Regular` 1: ↶ is a single open stroke next to a dense outline-plus-fill copy
|
|
30
|
-
* glyph, so at 1 it reads thinned out. Rounded caps/joins stay — upstream keeps
|
|
31
|
-
* them on curved artwork.
|
|
32
|
-
*/
|
|
33
|
-
export declare const REWIND_ICON_SVG: string;
|
|
34
25
|
/** One injected stylesheet (scoped under `.dsh-rewind-*`). */
|
|
35
|
-
export declare const STYLE = "\n.dsh-rewind-
|
|
26
|
+
export declare const STYLE = "\n/* The official action chrome, value for value: the action is Copy's peer in the\n same 28px row, so a different box, glyph scale, or resting tier would read as\n a different kind of control. */\n.dsh-rewind-action {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: calc(28px + var(--dsh-content-font-delta, 0px));\n height: calc(28px + var(--dsh-content-font-delta, 0px));\n padding: 6px;\n border: none;\n border-radius: var(--dsw-radius-sm, 8px);\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-action svg {\n width: calc(15px + var(--dsh-content-font-delta, 0px));\n height: calc(15px + var(--dsh-content-font-delta, 0px));\n}\n@media (hover: hover) {\n .dsh-rewind-action:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n }\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1100;\n box-sizing: border-box;\n width: min(300px, calc(100vw - 24px));\n padding: 16px;\n border: 0;\n border-radius: var(--dsw-radius-lg);\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n backdrop-filter: var(--dsw-menu-backdrop-filter);\n isolation: isolate;\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n /* Elevated surface: match the menu cards' l2 scrollbar tokens. */\n --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);\n --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);\n box-shadow: var(--dsw-elevation-prominent);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n}\n.dsh-rewind-popover-target {\n margin: 2px 0 10px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n /* The full message sits above the popover; two lines are enough context. */\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 2px;\n padding: 6px 8px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n/* Arrow navigation moves real focus, so the focused row carries the pointer's\n fill and the browser ring is suppressed: in dark mode the UA two-tone ring\n draws its dark half against the filled row and reads as a residue edge (the\n harness Menu suppresses it the same way). */\n.dsh-rewind-popover-option:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-note {\n margin-top: 6px;\n font-size: 11px;\n line-height: 15px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n margin-top: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n height: 28px;\n padding: 0 10px;\n border: none;\n border-radius: 8px;\n font: inherit;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-ghost:active {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 0;\n border-radius: var(--dsw-radius-md);\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n backdrop-filter: var(--dsw-menu-backdrop-filter);\n isolation: isolate;\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n box-shadow: var(--dsw-elevation-panel);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Plugin configuration form ---- */\n/* Only the card's own controls: the two switch rows, their hints, and the divider. */\n.dsh-rewind-settings-divider {\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n.dsh-rewind-settings-section {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-settings-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-settings-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-settings-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n";
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk store format — the single source of truth for what a snapshot
|
|
3
|
+
* directory looks like.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is stateless and independent of the file-system service:
|
|
6
|
+
* the naming functions, the entry/journal JSON codecs, and the ref validators
|
|
7
|
+
* that keep a hostile or corrupt record inside the store. The byte format
|
|
8
|
+
* itself is specified in `docs/format.md`; the fail-closed store-version
|
|
9
|
+
* contract is enforced by `readEntry` here and by
|
|
10
|
+
* `SnapshotStore.assertKnownStoreVersion` in `snapshot.ts`.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-rewind/snapshot-format
|
|
13
|
+
*/
|
|
14
|
+
/** Suffix of the raw byte copy holding one entry's before-content. */
|
|
15
|
+
export declare const SIDECAR_SUFFIX = ".before";
|
|
16
|
+
/** Session-dir sub-directory holding captures that are staged but not committed. */
|
|
17
|
+
export declare const PENDING_DIR = ".pending";
|
|
18
|
+
/** Session-dir sub-directory holding one pre-restore ("rescue") copy per op. */
|
|
19
|
+
export declare const RESCUE_DIR = "rescue";
|
|
20
|
+
/** Journal-file prefix of the current format. */
|
|
21
|
+
export declare const JOURNAL_PREFIX = "journal-";
|
|
22
|
+
/** Journal-file prefix written by the released v1 build (read-only compatibility). */
|
|
23
|
+
export declare const LEGACY_JOURNAL_PREFIX = "restore-journal-";
|
|
24
|
+
/**
|
|
25
|
+
* Current on-disk store format version (the session's `store` marker value and
|
|
26
|
+
* the `store` field of every entry this build writes). A value ABOVE this one
|
|
27
|
+
* means the snapshots were written by a NEWER build: readers must then fail
|
|
28
|
+
* closed (no file restore, nothing changed) instead of guessing what the extra
|
|
29
|
+
* fields mean. A missing marker (or `1`) means the released v1 string format,
|
|
30
|
+
* which is still read.
|
|
31
|
+
*/
|
|
32
|
+
export declare const CURRENT_STORE_VERSION = 2;
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when snapshots carry a store format newer than this build understands
|
|
35
|
+
* (ADR-10: whole-operation fail-closed — never a partial restore, never a
|
|
36
|
+
* clear). The session rewind itself does not depend on snapshots and still
|
|
37
|
+
* works.
|
|
38
|
+
*/
|
|
39
|
+
export declare class UnknownStoreVersionError extends Error {
|
|
40
|
+
/** The version found on disk. */
|
|
41
|
+
readonly version: number;
|
|
42
|
+
/** Where it was found (a marker or an entry file). */
|
|
43
|
+
readonly source: string;
|
|
44
|
+
constructor(
|
|
45
|
+
/** The version found on disk. */
|
|
46
|
+
version: number,
|
|
47
|
+
/** Where it was found (a marker or an entry file). */
|
|
48
|
+
source: string);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Recorded before-content — always bytes, never a decoded string:
|
|
52
|
+
*
|
|
53
|
+
* - `blob`: a raw byte copy inside the store (the format every new write uses).
|
|
54
|
+
* - `text`: the exact UTF-8 bytes of a released-v1 string record (a v1 record
|
|
55
|
+
* that was decoded from valid UTF-8 is byte-exact, so restoring it is safe).
|
|
56
|
+
* - `lossyText`: a released-v1 string record that contains U+FFFD, i.e. one
|
|
57
|
+
* the v1 build produced by a LOSSY decode of non-UTF-8 bytes. The original
|
|
58
|
+
* bytes are unknowable, so it can be compared against the disk but must
|
|
59
|
+
* never be written back as a restore target (that would destroy live data).
|
|
60
|
+
*/
|
|
61
|
+
export type ByteSource = {
|
|
62
|
+
readonly kind: 'blob';
|
|
63
|
+
readonly path: string;
|
|
64
|
+
} | {
|
|
65
|
+
readonly kind: 'text';
|
|
66
|
+
readonly bytes: Buffer;
|
|
67
|
+
} | {
|
|
68
|
+
readonly kind: 'lossyText';
|
|
69
|
+
readonly text: string;
|
|
70
|
+
};
|
|
71
|
+
/** Classify a released-v1 string record (U+FFFD means the decode lost bytes). */
|
|
72
|
+
export declare function textSourceOf(text: string): ByteSource;
|
|
73
|
+
/** A staged raw byte copy waiting to be committed (`recordBackup`). */
|
|
74
|
+
export interface PendingBackup {
|
|
75
|
+
/** Absolute path of the staged file (inside the session's `.pending/`). */
|
|
76
|
+
readonly file: string;
|
|
77
|
+
/** Byte size of the staged content. */
|
|
78
|
+
readonly size: number;
|
|
79
|
+
/**
|
|
80
|
+
* Permission bits of the source file (`stat().mode & 0o7777`), captured
|
|
81
|
+
* best-effort. Restored alongside the content — never on its own, and never
|
|
82
|
+
* as part of the change decision (ADR-9).
|
|
83
|
+
*/
|
|
84
|
+
readonly mode?: number;
|
|
85
|
+
}
|
|
86
|
+
/** One committed before-backup, keyed by tool call. */
|
|
87
|
+
export interface CheckpointEntry {
|
|
88
|
+
readonly callId: string;
|
|
89
|
+
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
90
|
+
readonly anchorSeq: number;
|
|
91
|
+
/** Resolved display path (absolute) of the tracked file. */
|
|
92
|
+
readonly path: string;
|
|
93
|
+
/** Byte source of the content before the change; null when the file was created. */
|
|
94
|
+
readonly before: ByteSource | null;
|
|
95
|
+
/** Byte size of `before` (0 when the file was created). */
|
|
96
|
+
readonly size: number;
|
|
97
|
+
/**
|
|
98
|
+
* Checkpoint-time location pin: where `dirname(path)` resolved when the entry
|
|
99
|
+
* was captured — or, when that directory was already gone, where it would
|
|
100
|
+
* have resolved (nearest surviving ancestor plus the missing segments). A
|
|
101
|
+
* restore refuses the path when its parent directory no longer resolves there,
|
|
102
|
+
* because a repointed or moved ancestor would otherwise redirect the write
|
|
103
|
+
* (or the unlink) outside the recorded location; the path's FINAL component is
|
|
104
|
+
* checked separately (see {@link isUnsafeTarget}). Absent means "no pin": a
|
|
105
|
+
* released-v1 entry or a legacy v2 entry, whose restore falls back to the
|
|
106
|
+
* final-component check alone.
|
|
107
|
+
*/
|
|
108
|
+
readonly parent?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Permission bits recorded at capture time, when known. Applied only when
|
|
111
|
+
* the CONTENT is restored (a mode-only difference is never a reason to plan
|
|
112
|
+
* a restore); a missing value means "leave the live mode alone".
|
|
113
|
+
*/
|
|
114
|
+
readonly mode?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Set when this entry's sidecar holds re-encoded LOSSY text (a released-v1
|
|
117
|
+
* record that was decoded with replacement characters, e.g. after `prune`
|
|
118
|
+
* materialized its link). The bytes are comparable but must never be written
|
|
119
|
+
* back: a reader turns such an entry into a `lossyText` source again.
|
|
120
|
+
*/
|
|
121
|
+
readonly lossy?: boolean;
|
|
122
|
+
/** Epoch ms the entry was committed (stable ordering within a group). */
|
|
123
|
+
readonly time: number;
|
|
124
|
+
/**
|
|
125
|
+
* Absolute file this entry was READ from (in-memory only, never serialized):
|
|
126
|
+
* a dedup reference must name the file that actually exists, which for an
|
|
127
|
+
* entry written by an older build is not necessarily the name the current
|
|
128
|
+
* naming function would produce.
|
|
129
|
+
*/
|
|
130
|
+
readonly file?: string;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One in-place dedup link, keyed by tool call. When a tracked file is
|
|
134
|
+
* recorded with a `before` content identical to the immediately-prior entry
|
|
135
|
+
* for that path, the entry is stored as a LINK instead of a full copy: it
|
|
136
|
+
* carries no content, only a `ref` naming the prior entry file
|
|
137
|
+
* (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
|
|
138
|
+
* restore resolution and prune materialization rewrite-free.
|
|
139
|
+
*/
|
|
140
|
+
export interface LinkEntry {
|
|
141
|
+
readonly callId: string;
|
|
142
|
+
readonly anchorSeq: number;
|
|
143
|
+
readonly path: string;
|
|
144
|
+
/** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
|
|
145
|
+
readonly ref: string;
|
|
146
|
+
/**
|
|
147
|
+
* Checkpoint-time location pin (see {@link CheckpointEntry.parent}); a link
|
|
148
|
+
* records its own, so a materialized or resolved entry never loses the
|
|
149
|
+
* location the path was committed under.
|
|
150
|
+
*/
|
|
151
|
+
readonly parent?: string;
|
|
152
|
+
readonly time: number;
|
|
153
|
+
/** Absolute file this link was read from (in-memory only, never serialized). */
|
|
154
|
+
readonly file?: string;
|
|
155
|
+
}
|
|
156
|
+
/** Any on-disk entry: a full before-backup or an in-place dedup link. */
|
|
157
|
+
export type StoredEntry = CheckpointEntry | LinkEntry;
|
|
158
|
+
/** True when an entry is a dedup link (carries `ref`, not `before`). */
|
|
159
|
+
export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
|
|
160
|
+
/** Per-file restore impact preview (served by the host preview route). */
|
|
161
|
+
export interface FileImpact {
|
|
162
|
+
readonly path: string;
|
|
163
|
+
/** `restore` = write the before content back; `delete` = remove the file. */
|
|
164
|
+
readonly action: 'restore' | 'delete';
|
|
165
|
+
}
|
|
166
|
+
/** A recorded path whose current on-disk size is over the backup cap. */
|
|
167
|
+
export interface UncoveredPath {
|
|
168
|
+
readonly path: string;
|
|
169
|
+
/** Byte size observed on disk, when the probe could read one. */
|
|
170
|
+
readonly size?: number;
|
|
171
|
+
}
|
|
172
|
+
/** Outcome of one restore pass. */
|
|
173
|
+
export interface RestoreOutcome {
|
|
174
|
+
readonly restored: readonly string[];
|
|
175
|
+
readonly deleted: readonly string[];
|
|
176
|
+
/** Symlinked or hard-linked paths left untouched. */
|
|
177
|
+
readonly skipped: readonly string[];
|
|
178
|
+
readonly failed: readonly {
|
|
179
|
+
path: string;
|
|
180
|
+
message: string;
|
|
181
|
+
}[];
|
|
182
|
+
}
|
|
183
|
+
/** Deletes one file by its real path (node:fs, bypassing the fs service). */
|
|
184
|
+
export type DeleteFile = (path: string) => Promise<void>;
|
|
185
|
+
/**
|
|
186
|
+
* Test-only fault injection: a crash point inside the write/restore paths.
|
|
187
|
+
* The hook THROWS to simulate a host crash at the exact point; the throw
|
|
188
|
+
* propagates out of the store method, leaving the journal on disk in its
|
|
189
|
+
* current state. Production callers never pass it (undefined = no-op).
|
|
190
|
+
*/
|
|
191
|
+
export type CrashPoint = 'before-action' | 'after-action' | 'after-temp-write';
|
|
192
|
+
/** Options for the journaled restore paths; `crash` is the test-only seam. */
|
|
193
|
+
export interface RestoreRunOptions {
|
|
194
|
+
/**
|
|
195
|
+
* Throws at the given point to simulate a host crash: `before-action`
|
|
196
|
+
* (before an action's fs op), `after-action` (right after the fs op,
|
|
197
|
+
* before its done-mark is persisted), `after-temp-write` (inside an atomic
|
|
198
|
+
* commit, between the temp write and the rename). `index` is the action
|
|
199
|
+
* index for the restore loops.
|
|
200
|
+
*/
|
|
201
|
+
readonly crash?: (point: CrashPoint, index?: number) => void;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Lifecycle of one restore operation journal. Terminal states are kept on
|
|
205
|
+
* disk as a tiny audit trail and skipped by reconciliation.
|
|
206
|
+
*/
|
|
207
|
+
export type RestoreJournalState = 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required';
|
|
208
|
+
/**
|
|
209
|
+
* One journaled restore action — a mutable working record that the restore
|
|
210
|
+
* loop updates (done/failed) as it applies the pass.
|
|
211
|
+
*/
|
|
212
|
+
export interface RestoreJournalAction {
|
|
213
|
+
readonly path: string;
|
|
214
|
+
readonly action: 'restore' | 'delete';
|
|
215
|
+
/** Target content for a restore; null for a delete. */
|
|
216
|
+
readonly before: ByteSource | null;
|
|
217
|
+
/**
|
|
218
|
+
* Pre-restore disk state ("rescue"): a raw byte copy of what the file had
|
|
219
|
+
* right before the restore started, or null when it was absent. Rollback
|
|
220
|
+
* writes this back, so the pre-restore state is recoverable exactly.
|
|
221
|
+
*/
|
|
222
|
+
readonly rescue: ByteSource | null;
|
|
223
|
+
/** Set when the rescue capture failed: rollback then skips this path. */
|
|
224
|
+
rescueError?: string;
|
|
225
|
+
/** Permission bits the restored content should end up with, when recorded. */
|
|
226
|
+
readonly mode?: number;
|
|
227
|
+
/**
|
|
228
|
+
* Checkpoint-time location pin copied from the entry the action was planned
|
|
229
|
+
* from (see {@link CheckpointEntry.parent}): a continue or rollback after a
|
|
230
|
+
* restart re-checks it before touching the path.
|
|
231
|
+
*/
|
|
232
|
+
readonly parent?: string;
|
|
233
|
+
/** Permission bits the file had BEFORE the restore, for a faithful rollback. */
|
|
234
|
+
readonly rescueMode?: number;
|
|
235
|
+
/** True once the action's fs op completed and was marked. */
|
|
236
|
+
done: boolean;
|
|
237
|
+
/** Per-action failure message; the restore pass never aborts. */
|
|
238
|
+
failed?: string;
|
|
239
|
+
}
|
|
240
|
+
/** Durable journal for one attempted restore (written atomically). */
|
|
241
|
+
export interface RestoreJournal {
|
|
242
|
+
/** On-disk schema version: 2 = byte references, 1 = inline legacy strings. */
|
|
243
|
+
readonly version: 1 | 2;
|
|
244
|
+
readonly id: string;
|
|
245
|
+
readonly sessionId: string;
|
|
246
|
+
readonly targetSeq: number;
|
|
247
|
+
readonly startedAt: number;
|
|
248
|
+
finishedAt?: number;
|
|
249
|
+
state: RestoreJournalState;
|
|
250
|
+
readonly actions: RestoreJournalAction[];
|
|
251
|
+
/** Set when a rollback pass failed partway (state becomes `recovery-required`). */
|
|
252
|
+
rollbackError?: string;
|
|
253
|
+
/**
|
|
254
|
+
* Absolute file this journal was read from (in-memory only, never
|
|
255
|
+
* serialized): a legacy `restore-journal-` file is updated IN PLACE so an
|
|
256
|
+
* op that was interrupted before the upgrade never ends up with two
|
|
257
|
+
* divergent versions on disk.
|
|
258
|
+
*/
|
|
259
|
+
sourceFile?: string;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Result of reconciling one interrupted restore journal against the real
|
|
263
|
+
* disk. Path status is relative to the op's current goal: the restore target
|
|
264
|
+
* for `running` journals, the pre-restore (rescue) state for
|
|
265
|
+
* `rollback-running` / `recovery-required` journals — disambiguate with
|
|
266
|
+
* {@link RestoreReconcileReport.journalState}.
|
|
267
|
+
*/
|
|
268
|
+
export interface RestoreReconcileReport {
|
|
269
|
+
readonly opId: string;
|
|
270
|
+
/** `interrupted` = a crash left the op unfinished; `recovery-required` = a rollback could not complete. */
|
|
271
|
+
readonly state: 'interrupted' | 'recovery-required';
|
|
272
|
+
/** Raw journal state (`running` | `rollback-running` | `recovery-required`). */
|
|
273
|
+
readonly journalState: RestoreJournalState;
|
|
274
|
+
readonly targetSeq: number;
|
|
275
|
+
readonly startedAt: number;
|
|
276
|
+
/** Paths whose disk already matches the op's goal. */
|
|
277
|
+
readonly restored: readonly string[];
|
|
278
|
+
/** Paths still short of the op's goal (not yet applied / not yet rolled back). */
|
|
279
|
+
readonly pending: readonly string[];
|
|
280
|
+
/** Actions that failed during the pass (kept failed until a redo succeeds). */
|
|
281
|
+
readonly failed: readonly {
|
|
282
|
+
path: string;
|
|
283
|
+
message: string;
|
|
284
|
+
}[];
|
|
285
|
+
readonly rollbackError?: string;
|
|
286
|
+
/** Set when the journal file itself is corrupt: it cannot be reconciled. */
|
|
287
|
+
readonly corrupt?: string;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Current-on-disk state probe used by restore planning and reconciliation.
|
|
291
|
+
* Injected so the logic runs against a fake FS in tests; the production
|
|
292
|
+
* default reads the real file system with plain `node:fs` (see
|
|
293
|
+
* {@link defaultProbe}) and compares byte streams, never whole files in memory.
|
|
294
|
+
*/
|
|
295
|
+
export interface DiskProbe {
|
|
296
|
+
/**
|
|
297
|
+
* Compare the recorded content with the file currently at `path`.
|
|
298
|
+
*
|
|
299
|
+
* - `true` = the disk matches the record byte-for-byte (for a `null`
|
|
300
|
+
* source: the path is absent).
|
|
301
|
+
* - `false` = it differs — including "the record says the file did not
|
|
302
|
+
* exist but it does" and "the record has content but the file is gone".
|
|
303
|
+
* - `undefined` = the comparison could not be decided (IO/permission
|
|
304
|
+
* failure). Callers stay conservative: a restore is still attempted and a
|
|
305
|
+
* delete still attempted, so an unreadable file is never silently skipped.
|
|
306
|
+
*
|
|
307
|
+
* A `lossyText` source is compared with the same lossy decode the released
|
|
308
|
+
* v1 build used (its original bytes cannot be recovered).
|
|
309
|
+
*/
|
|
310
|
+
matches(source: ByteSource | null, path: string): Promise<boolean | undefined>;
|
|
311
|
+
/**
|
|
312
|
+
* Stage a raw byte copy of the file at `path` into `dest` (the store's
|
|
313
|
+
* rescue area) without loading it into memory.
|
|
314
|
+
*/
|
|
315
|
+
copy(path: string, dest: string): Promise<CopyOutcome>;
|
|
316
|
+
/**
|
|
317
|
+
* Byte size of the regular file at `path`, or `undefined` when absent or
|
|
318
|
+
* unreadable. Consulted before any content comparison so an over-cap file is
|
|
319
|
+
* skipped without being read. Optional; a missing accessor reads as "size
|
|
320
|
+
* unknown", which never counts as "too big".
|
|
321
|
+
*/
|
|
322
|
+
size?(path: string): Promise<number | undefined>;
|
|
323
|
+
/**
|
|
324
|
+
* True when the path's final component is not a single regular file: a
|
|
325
|
+
* symlink, a hard link (`nlink > 1`), a directory, a FIFO or a device. The
|
|
326
|
+
* planner and the apply pass both refuse one — a link redirects the write,
|
|
327
|
+
* a hard link shares the inode with other names, and a FIFO or device is
|
|
328
|
+
* neither restorable nor safe to open.
|
|
329
|
+
*/
|
|
330
|
+
isUnsafeTarget(path: string): Promise<boolean>;
|
|
331
|
+
}
|
|
332
|
+
/** Result of staging one on-disk byte copy. */
|
|
333
|
+
export type CopyOutcome = {
|
|
334
|
+
readonly kind: 'copied';
|
|
335
|
+
readonly size: number;
|
|
336
|
+
} | {
|
|
337
|
+
readonly kind: 'absent';
|
|
338
|
+
} | {
|
|
339
|
+
readonly kind: 'failed';
|
|
340
|
+
readonly message: string;
|
|
341
|
+
};
|
|
342
|
+
/** One restore action the planner derived from record + disk reconciliation. */
|
|
343
|
+
export type PlannedAction = {
|
|
344
|
+
readonly path: string;
|
|
345
|
+
readonly action: 'restore';
|
|
346
|
+
readonly before: ByteSource;
|
|
347
|
+
readonly mode?: number;
|
|
348
|
+
readonly parent?: string;
|
|
349
|
+
} | {
|
|
350
|
+
readonly path: string;
|
|
351
|
+
readonly action: 'delete';
|
|
352
|
+
readonly parent?: string;
|
|
353
|
+
};
|
|
354
|
+
/** Sanitize a call id into a safe file name. */
|
|
355
|
+
export declare function safeFileId(callId: string): string;
|
|
356
|
+
/**
|
|
357
|
+
* Sanitize a session id into a safe path segment. Real ids are harness-minted
|
|
358
|
+
* UUIDs (a no-op here), but a hostile or malformed id must never traverse out
|
|
359
|
+
* of the snapshot root — `.` and `..` are the only bare values the charset
|
|
360
|
+
* permits that would alias the root or its parent.
|
|
361
|
+
*/
|
|
362
|
+
export declare function safeSessionId(sessionId: string): string;
|
|
363
|
+
/**
|
|
364
|
+
* Structural validation of a parsed journal. Unlike checkpoint entries (whose
|
|
365
|
+
* corruption is silently skipped), a corrupt journal is reported
|
|
366
|
+
* fail-loud by `reconcileRestores` — silently dropping it would silently
|
|
367
|
+
* erase the ability to recover the interrupted restore.
|
|
368
|
+
*
|
|
369
|
+
* Accepts both formats: `version: 2` (byte references), the released
|
|
370
|
+
* `version: 1` (inline strings) and the legacy prefix that carried no
|
|
371
|
+
* `version` at all — but never best-effort-coerces a malformed action.
|
|
372
|
+
*/
|
|
373
|
+
export declare function isRestoreJournal(value: unknown): value is RestoreJournal;
|
|
374
|
+
/**
|
|
375
|
+
* Format a store-relative filesystem path as a serialized ref. Refs are always
|
|
376
|
+
* POSIX-separated (`13/call.before`) even on Windows, where `relative()`
|
|
377
|
+
* returns backslashes; the journal and link validators only accept `/`.
|
|
378
|
+
*/
|
|
379
|
+
export declare function sessionRelativeRef(sessionDir: string, file: string): string;
|
|
380
|
+
/** Serialized (on-disk) form of a journal action. */
|
|
381
|
+
export declare function journalToJson(journal: RestoreJournal, sessionDir: string): Record<string, unknown>;
|
|
382
|
+
/** Parse a validated journal's raw shape into the in-memory form. */
|
|
383
|
+
export declare function journalFromJson(raw: Record<string, unknown>, sessionDir: string): RestoreJournal | undefined;
|
|
384
|
+
/** True when a session-dir member is a restore journal (either prefix). */
|
|
385
|
+
export declare function isJournalName(name: string): boolean;
|
|
386
|
+
/** The op id encoded in a journal file name. */
|
|
387
|
+
export declare function journalOpIdOf(name: string): string;
|
|
388
|
+
/** The entry file name one call id maps to (the single naming function). */
|
|
389
|
+
export declare function entryFileName(callId: string): string;
|
|
390
|
+
/** Stable 8-hex digest of an arbitrary string (ids, paths, capture keys). */
|
|
391
|
+
export declare function shortHash(value: string): string;
|
|
392
|
+
/** The sidecar file name of one entry file (`<base>.json` → `<base>.before`). */
|
|
393
|
+
export declare function sidecarName(entryFile: string): string;
|
|
394
|
+
/** Anchor seq encoded in a link `ref` (`<anchorSeq>/<file>.json`). */
|
|
395
|
+
export declare function refAnchorOf(ref: string): number;
|
|
396
|
+
/** Serialize one in-memory entry for disk (the current byte format). */
|
|
397
|
+
export declare function entryToJson(entry: CheckpointEntry): Record<string, unknown>;
|
|
398
|
+
/** Serialize one in-memory dedup link for disk (the current byte format). */
|
|
399
|
+
export declare function linkToJson(link: LinkEntry): Record<string, unknown>;
|
|
400
|
+
/**
|
|
401
|
+
* Read one committed entry, or undefined when missing/corrupt. Accepts both
|
|
402
|
+
* formats: the current byte format (`store: 2`, `file`, `blob` + sidecar) and
|
|
403
|
+
* the released v1 format (`anchorSeq` + inline `before` string).
|
|
404
|
+
*
|
|
405
|
+
* The byte format deliberately does NOT carry `anchorSeq` (it equals the
|
|
406
|
+
* parent directory) and does NOT reuse the v1 key names — a downgraded v1
|
|
407
|
+
* build rejects such an entry instead of reading it as "created", which is
|
|
408
|
+
* what keeps a downgrade from deleting workspace files. See
|
|
409
|
+
* `tests/downgrade-safety.test.ts`.
|
|
410
|
+
*/
|
|
411
|
+
export declare function readEntry(file: string, anchorSeq: number): Promise<StoredEntry | undefined>;
|
|
412
|
+
/**
|
|
413
|
+
* True when a dedup-link `ref` is a SAFE relative reference to a checkpoint
|
|
414
|
+
* entry — `<digits>/<callId>.json`, a single level below the session dir, with
|
|
415
|
+
* no traversal or absolute segment. The plugin always writes refs this way
|
|
416
|
+
* ({@link SnapshotStore.entryRefOf}); this validates a `ref` read back from
|
|
417
|
+
* disk so a hostile or corrupt ref can never escape the store root when a
|
|
418
|
+
* restore resolution or prune materialization follows it (mirrors the
|
|
419
|
+
* `safeSessionId` / `safeFileId` containment guarantee).
|
|
420
|
+
*/
|
|
421
|
+
export declare function isSafeLinkRef(ref: string): boolean;
|
|
422
|
+
/**
|
|
423
|
+
* True when a journal's byte reference is SAFE and relative to the session
|
|
424
|
+
* dir: either `<digits>/<name>.before` (an entry sidecar) or
|
|
425
|
+
* `rescue/<opId>/<index>.before` (a rescue copy). Validated on both write and
|
|
426
|
+
* read, so a corrupt or hostile journal can never point a restore or a
|
|
427
|
+
* rollback outside the store (`..`, absolute paths and unknown roots are
|
|
428
|
+
* rejected).
|
|
429
|
+
*/
|
|
430
|
+
export declare function isSafeBackupRef(ref: string): boolean;
|
|
431
|
+
/**
|
|
432
|
+
* Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
|
|
433
|
+
*
|
|
434
|
+
* The sweep is ANTI-DELETE: it only ever removes WHOLE session directories
|
|
435
|
+
* that have been idle past `maxAgeDays`. `scanned` counts every session dir
|
|
436
|
+
* evaluated; `kept` + `skippedActive` + `deleted` sum to it. `remainingBytes`
|
|
437
|
+
* is the total of directories that SURVIVE the policy (when `dryRun` it is the
|
|
438
|
+
* would-be total, not the current on-disk total), so it is comparable across
|
|
439
|
+
* dry and real runs.
|
|
440
|
+
*/
|
|
441
|
+
export interface PruneStaleReport {
|
|
442
|
+
/** Number of session directories evaluated. */
|
|
443
|
+
readonly scanned: number;
|
|
444
|
+
/** Session directories removed (would-be count when `dryRun`). */
|
|
445
|
+
readonly deleted: number;
|
|
446
|
+
/** Bytes reclaimed (would-be bytes when `dryRun`). */
|
|
447
|
+
readonly freedBytes: number;
|
|
448
|
+
/** Session directories retained (not past the cutoff, not the active one). */
|
|
449
|
+
readonly kept: number;
|
|
450
|
+
/** Bytes across the retained + skipped-active directories. */
|
|
451
|
+
readonly remainingBytes: number;
|
|
452
|
+
/** Directories skipped because they are the active session. */
|
|
453
|
+
readonly skippedActive: number;
|
|
454
|
+
/** Whether nothing was really removed (the sweep only reported). */
|
|
455
|
+
readonly dryRun: boolean;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Result of a manual whole-session clear ({@link SnapshotStore.clearSession}).
|
|
459
|
+
*
|
|
460
|
+
* Unlike the age-based sweep, a clear removes EVERY snapshot of ONE session
|
|
461
|
+
* (all anchor groups, all checkpoint entries, all restore journals) on demand —
|
|
462
|
+
* the active session the user is driving, to drop the rewind overhead or to
|
|
463
|
+
* archive a conversation immediately. `dryRun` reports what would be removed
|
|
464
|
+
* without touching disk or memory.
|
|
465
|
+
*/
|
|
466
|
+
export interface ClearSessionReport {
|
|
467
|
+
/** The session whose records were (or would be) cleared. */
|
|
468
|
+
readonly sessionId: string;
|
|
469
|
+
/** Number of anchor-group (user-message) directories present. */
|
|
470
|
+
readonly anchorGroups: number;
|
|
471
|
+
/** Number of committed checkpoint entries (full backups + dedup links). */
|
|
472
|
+
readonly entries: number;
|
|
473
|
+
/** Number of restore-journal files (terminal + pending). */
|
|
474
|
+
readonly journals: number;
|
|
475
|
+
/** Bytes occupied by the session directory (the amount freed). */
|
|
476
|
+
readonly bytes: number;
|
|
477
|
+
/** Whether nothing was really removed (the clear only reported). */
|
|
478
|
+
readonly dryRun: boolean;
|
|
479
|
+
}
|