@quantiya/codevibe-core 2.0.83 → 2.0.85

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.
@@ -1,8 +1,29 @@
1
1
  import type { Dirent } from 'node:fs';
2
2
  import type { WorkspaceRootAuthority } from './workspace-authority.js';
3
- /** Shared metadata ceilings for every CodeVibe-owned filesystem traversal. */
3
+ /**
4
+ * Ceiling for **orchestration records** — a retained team specification's work items, briefs and attachments,
5
+ * and a track-bundles record (`durable-store.ts`). It is a memory guard on persisted input and has nothing to
6
+ * do with walking a folder, despite the name it has carried.
7
+ *
8
+ * §5.6 of the workspace-hardening design lists "100,000 entries | `bounded-directory.ts:17` | removed" as a
9
+ * Slice 1 item, which reads as though this constant were traversal-only. It is not: removing it would delete
10
+ * the orchestration guards too. Hendry's decision, 2026-10-24, was to **split the constant** —
11
+ * {@link MAX_TRAVERSAL_ENTRY_COUNT} now carries the traversal meaning and this one keeps its original value
12
+ * and its orchestration consumers.
13
+ */
4
14
  export declare const MAX_METADATA_ENTRY_COUNT = 100000;
5
15
  export declare const MAX_METADATA_PATH_BYTES: number;
16
+ /**
17
+ * Ceiling for a CodeVibe-owned **filesystem traversal**.
18
+ *
19
+ * §5.6 retires the historical 100,000-entry / 16 MiB-path cap: a folder's size is computed by the walk and the
20
+ * marker no longer holds every path inline once it is large (`shadow-baseline-shards.ts`). What remains is a
21
+ * memory sanity bound, not a product limit: every walked path is held in memory (≈ 200 bytes each), so 5,000,000
22
+ * entries / 1 GiB of path bytes keeps a pathological tree from exhausting the desktop. A folder past it is refused
23
+ * with this reason rather than silently truncated (P63 SB-33).
24
+ */
25
+ export declare const MAX_TRAVERSAL_ENTRY_COUNT = 5000000;
26
+ export declare const MAX_TRAVERSAL_PATH_BYTES: number;
6
27
  export interface MetadataBudget {
7
28
  entries: number;
8
29
  utf8PathBytes: number;
@@ -40,6 +61,15 @@ export interface BoundedDirectoryStat {
40
61
  mtimeNs: string;
41
62
  ctimeNs: string;
42
63
  nlink: string;
64
+ /**
65
+ * Ownership and mode, carried in the same isolated enumeration request as the rest of the tuple so the §5.2
66
+ * start record needs no second `lstat` per entry. The record's fields are
67
+ * `type, size, mtimeNs, ctimeNs, ino, dev, uid, gid, mode`, and these three completed that set.
68
+ * Decimal strings, like `dev`/`ino`/`nlink`, because the helper reads them from a `bigint` `lstat`.
69
+ */
70
+ uid: string;
71
+ gid: string;
72
+ mode: string;
43
73
  }
44
74
  /** Metadata captured in the same isolated enumeration request, when requested. */
45
75
  export declare function boundedDirectoryStat(entry: Dirent): BoundedDirectoryStat | undefined;
@@ -0,0 +1,154 @@
1
+ import { type WorkspaceRootAuthority } from './workspace-authority';
2
+ export type NoLinkOperation = 'replace' | 'create' | 'delete';
3
+ export type NoLinkState = 'prepared' | 'moved' | 'placing' | 'restoring' | 'restoring-link' | 'pending-restore' | 'create-conflict' | 'done';
4
+ export interface NoLinkJournalEntry {
5
+ version: 1;
6
+ token: string;
7
+ op: NoLinkOperation;
8
+ /** The target's path relative to the workspace root, for reporting; the helper uses only its basename. */
9
+ path: string;
10
+ /**
11
+ * Stage 1 R1-F2: the workspace root the entry belongs to — a record of which folder the step touched. Slice 1
12
+ * reads entries only through their own task (which knows its folder); no recovery path consumes this field yet
13
+ * (Stage 1 R2-N2, a recorded Slice 1 deviation). Absent on entries written before this field existed.
14
+ */
15
+ workspaceRoot?: string;
16
+ state?: NoLinkState;
17
+ reservation?: {
18
+ ino: string;
19
+ };
20
+ rreservation?: {
21
+ ino: string;
22
+ };
23
+ restoreHash?: string;
24
+ /** Stage 2 R4-F1: the restore copy's mode, checked when a restore is recognised as complete. */
25
+ restoreMode?: number;
26
+ oldIdentity?: {
27
+ dev: string;
28
+ ino: string;
29
+ } | null;
30
+ baseIdentity?: {
31
+ ino: string;
32
+ size: string;
33
+ mtimeNs: string;
34
+ };
35
+ kept?: string[];
36
+ report?: string;
37
+ timesNotKept?: true;
38
+ /** Fixed at the first write so recovery replays the same request. */
39
+ spec: Omit<NoLinkHelperSpec, 'name' | 'token' | 'crashAt' | 'crashAfter' | 'seams' | 'failAfterReservation' | 'failReservationCleanup' | 'failKeptRename' | 'disableFinalGuard'>;
40
+ }
41
+ interface NoLinkHelperSpec {
42
+ op: NoLinkOperation;
43
+ name: string;
44
+ token: string;
45
+ content?: string;
46
+ mode?: number;
47
+ baseHash: string | null;
48
+ intendedHash: string | null;
49
+ linkVolume: boolean;
50
+ inodesTrusted: boolean;
51
+ modesTracked: boolean;
52
+ crashAt?: string;
53
+ crashAfter?: NoLinkState;
54
+ seams?: string[];
55
+ failAfterReservation?: number;
56
+ /** @internal test only: the next N reservation clean-ups after a failed placement fail (the reservation is left). */
57
+ failReservationCleanup?: number;
58
+ /** @internal test only: the next N kept-file renames fail AND leave their reservation (§9.3 T30b). */
59
+ failKeptRename?: number;
60
+ /** Stage 2 R2-F3: where modes are tracked, the mode the name must still have (the base); null/absent = unchecked. */
61
+ baseMode?: number | null;
62
+ /** @internal control-test only: skip the final guard to show what it prevents. */
63
+ disableFinalGuard?: boolean;
64
+ }
65
+ export interface NoLinkResult {
66
+ outcome: 'applied' | 'applied-kept' | 'deleted' | 'conflict' | 'pending' | 'error' | 'restored' | 'kept-original' | 'nothing-moved' | 'nothing-placed' | 'original-kept' | 'gone' | 'closed' | 'name-holds-another-file' | 'original-gone';
67
+ kept: string[];
68
+ reason?: string;
69
+ error?: string;
70
+ putBack?: string;
71
+ report?: string;
72
+ }
73
+ /**
74
+ * Durable per-operation journal: one FLAT file per token, `<prefix>-<token>.json`, in `dir` (the task's state
75
+ * directory, which must stay flat — P63 SB-41).
76
+ */
77
+ /**
78
+ * Stage 1 N8: read one of this task's own state files (journal entry, capabilities, probe journal, start-record
79
+ * manifest) the way the shipped markers are read — never through a symlink, never blocking on a FIFO, and only a
80
+ * regular file within `maxBytes`. A missing file rejects with its ENOENT, so callers keep their absent-file paths.
81
+ */
82
+ export declare function readStateFile(file: string, maxBytes?: number): Promise<string>;
83
+ /**
84
+ * Stage 2 R1-F8: the same read, returning the exact bytes (a shard's hash covers bytes). With `exactBytes` (what the
85
+ * writer recorded), any other size is refused before a byte is read.
86
+ */
87
+ export declare function readStateFileBytes(file: string, maxBytes: number, exactBytes?: number,
88
+ /** @internal test only (R2-F4): runs between the size check and the read. */
89
+ afterStatForTest?: () => Promise<void>): Promise<Buffer>;
90
+ /**
91
+ * Stage 2 R1-F8: the largest serialized line one shard entry can take — a 4 KiB path JSON-escaped at worst 6× plus
92
+ * the fixed fields. A shard of N entries is never read past N times this.
93
+ */
94
+ export declare const MAX_SHARD_LINE_BYTES: number;
95
+ export declare function shardByteBound(entries: number): number;
96
+ /**
97
+ * Stage 1 R2-F1: the largest journal entry the engine can write. An entry carries the new file's content as base64
98
+ * (`spec.content`), and a reviewed file may be up to {@link MAX_REVIEW_FILE_BYTES}; the remainder is JSON. Every
99
+ * journal read uses this bound — a smaller one (the 1 MiB of N8(a)) made an applied file over ~768 KiB unreadable,
100
+ * so discard failed and a pending step could never be recovered.
101
+ */
102
+ export declare const NOLINK_JOURNAL_MAX_BYTES: number;
103
+ export declare class NoLinkJournalStore {
104
+ readonly dir: string;
105
+ readonly prefix: string;
106
+ constructor(dir: string, prefix?: string);
107
+ fileFor(token: string): string;
108
+ write(entry: NoLinkJournalEntry): Promise<void>;
109
+ read(token: string): Promise<NoLinkJournalEntry | null>;
110
+ /**
111
+ * Every entry not yet `done`. Slice 1 recovers them at every `promote()` — each retry and the startup reconcile
112
+ * (R1-F1); §9.3's 10-second retry while the desktop runs is not implemented (Stage 1 R2-N2, a recorded deviation).
113
+ */
114
+ open(): Promise<NoLinkJournalEntry[]>;
115
+ }
116
+ /** Stage 2 R3-F1: parse + validate one persisted entry; throws `EJOURNAL` for anything that is not exactly one. */
117
+ export declare function parseNoLinkJournalEntry(text: string, token: string): NoLinkJournalEntry;
118
+ /** The token of a `<prefix>-<token>.json` journal file name, or null when the name is not one. */
119
+ export declare function journalTokenOf(name: string, prefix: string): string | null;
120
+ /** The CodeVibe token for one operation: the same 48-hex shape as the link-based helper's `.codevibe-cas-<token>`. */
121
+ export declare function noLinkToken(rootCanonicalPath: string, rel: string, op: NoLinkOperation, baseHash: string | null, intendedHash: string | null): string;
122
+ export interface NoLinkRunOptions {
123
+ workspaceRoot: string;
124
+ /**
125
+ * Stage 1 R1-F3: the workspace root's recorded identity. When given, it is re-checked before the target's parent
126
+ * is bound, exactly as the link-based helper requires it.
127
+ */
128
+ workspaceRootAuthority?: WorkspaceRootAuthority;
129
+ rel: string;
130
+ journal: NoLinkJournalStore;
131
+ helperTimeoutMs?: number;
132
+ /** @internal deterministic test controls, forwarded to the helper. */
133
+ test?: Pick<NoLinkHelperSpec, 'crashAt' | 'crashAfter' | 'seams' | 'failAfterReservation' | 'failReservationCleanup' | 'failKeptRename' | 'disableFinalGuard'> & {
134
+ onSeam?: (name: string, parentDir: string) => Promise<void>;
135
+ };
136
+ }
137
+ /** Apply one change on a no-link volume. */
138
+ export declare function applyNoLink(opts: NoLinkRunOptions & {
139
+ op: NoLinkOperation;
140
+ content?: string;
141
+ mode?: number;
142
+ /** Stage 2 R2-F3: the name's base mode, compared by the helper where modes are tracked. */
143
+ baseMode?: number | null;
144
+ baseHash: string | null;
145
+ linkVolume: boolean;
146
+ inodesTrusted: boolean;
147
+ modesTracked: boolean;
148
+ }): Promise<NoLinkResult>;
149
+ /** Recover one journaled operation from its last durable state (§9.3 "Crash recovery"). */
150
+ export declare function recoverNoLink(opts: NoLinkRunOptions & {
151
+ entry: NoLinkJournalEntry;
152
+ inodesTrusted: boolean;
153
+ }): Promise<NoLinkResult>;
154
+ export {};
@@ -0,0 +1,114 @@
1
+ import { applyNoLink, type NoLinkResult } from './nolink-apply';
2
+ import type { WorkspaceRootAuthority } from './workspace-authority';
3
+ import { type VolumeCapabilities } from './shadow-volume-probe';
4
+ export interface ApplyArtifactNames {
5
+ probeJournal: string;
6
+ capabilities: string;
7
+ /** The §9.3 journal lives FLAT in the state dir, as `nolink-<uuid>-<token>.json` (state containers are flat). */
8
+ nolinkDir: string;
9
+ nolinkPrefix: string;
10
+ }
11
+ export declare function applyArtifactNames(stateDir: string, shadowDir: string): ApplyArtifactNames;
12
+ /** §5.2/§7.4: this instance's flat start-record file prefix (`start-record-<uuid>`). */
13
+ export declare function startRecordPrefix(shadowDir: string): string;
14
+ /** Test override: run the no-link path on a volume that has links, as SP-C's "APFS forced copy" did. */
15
+ export interface ForcedApplyCapabilities {
16
+ mode: 'nolink';
17
+ modesTracked?: boolean;
18
+ inodeStable?: boolean;
19
+ /** drvfs-shaped: no-link state machine with link-based placement. */
20
+ linkVolume?: boolean;
21
+ }
22
+ export type ResolvedApplyCapabilities = VolumeCapabilities & {
23
+ linkVolume: boolean;
24
+ };
25
+ /**
26
+ * The volume's capabilities for this snapshot's apply: read back if this snapshot already probed (a resumed or
27
+ * recovered promotion never probes twice), otherwise probed now — after approval, never at task start (§9.1).
28
+ */
29
+ export declare function ensureApplyCapabilities(stateDir: string, shadowDir: string, workspaceRoot: string, forced?: ForcedApplyCapabilities): Promise<ResolvedApplyCapabilities>;
30
+ export declare class NoLinkPendingError extends Error {
31
+ readonly rel: string;
32
+ readonly result: NoLinkResult;
33
+ readonly code = "ENOLINKPENDING";
34
+ constructor(rel: string, result: NoLinkResult);
35
+ }
36
+ /**
37
+ * Apply one reviewed file on a no-link volume. Returns true when the name now holds the post-image (applied, or
38
+ * already applied by an interrupted earlier run), false for a conflict — the link-based helper's contract.
39
+ */
40
+ export declare function applyFileNoLink(args: {
41
+ caps: ResolvedApplyCapabilities;
42
+ stateDir: string;
43
+ shadowDir: string;
44
+ workspaceRoot: string;
45
+ /** R1-F3: re-checked before every no-link step, as the link-based helper does. */
46
+ workspaceRootAuthority?: WorkspaceRootAuthority;
47
+ rel: string;
48
+ /** null = delete. */
49
+ content: string | null;
50
+ /**
51
+ * Stage 1 R1-F4: the mode the placed file must carry where the volume tracks modes (§9.1 `modesTracked`); the
52
+ * helper sets it on the file it writes. Ignored where modes are untracked (exFAT, FAT, drvfs).
53
+ */
54
+ mode?: number | null;
55
+ expectedHash: string | null;
56
+ postHash: string | null;
57
+ currentHash: () => Promise<string | null>;
58
+ /**
59
+ * Stage 2 R1-F7: where modes are tracked, the name's current mode and the mode it must have before this step. A
60
+ * mode-only change has equal content hashes, so "already landed" must also compare the mode.
61
+ */
62
+ currentMode?: () => Promise<number | null>;
63
+ expectedMode?: number | null;
64
+ onKept?: (rel: string, kept: string[]) => void;
65
+ test?: Parameters<typeof applyNoLink>[0]['test'];
66
+ }): Promise<boolean>;
67
+ /**
68
+ * Stage 1 R1-F1: recover EVERY open §9.3 journal entry of this snapshot, whatever its token — before a promotion
69
+ * attempts anything new. `applyFileNoLink` recovers only the entry of the operation it is about to retry, but a
70
+ * rollback runs the REVERSE operation (pre- and post-image swapped, so a different token): a reverse step left
71
+ * `pending-restore` by a failure or a crash would otherwise never be recovered, the user's name would stay empty and
72
+ * the task would sit in APPLYING for good. This is what every retry and the startup reconcile reach, because both
73
+ * call `promote()`. Returns the paths still pending (their originals are at a CodeVibe name; APPLYING must stay).
74
+ * Stage 1 R2-F2: an entry whose recovery throws is reported pending for that path (APPLYING stays, the next attempt
75
+ * retries it) rather than failing the whole pass; an entry closed without a result to apply goes to `onClosed`.
76
+ */
77
+ export declare function recoverOpenNoLinkEntries(args: {
78
+ caps: ResolvedApplyCapabilities;
79
+ stateDir: string;
80
+ shadowDir: string;
81
+ workspaceRoot: string;
82
+ workspaceRootAuthority?: WorkspaceRootAuthority;
83
+ onKept?: (rel: string, kept: string[]) => void;
84
+ onClosed?: (rel: string, result: NoLinkResult) => void;
85
+ onError?: (rel: string, err: Error) => void;
86
+ }): Promise<string[]>;
87
+ /** Open (not `done`) §9.3 journal entries of this snapshot — non-empty blocks discard. */
88
+ export declare function openNoLinkEntries(stateDir: string, shadowDir: string): Promise<import("./nolink-apply").NoLinkJournalEntry[]>;
89
+ /**
90
+ * Remove this snapshot's apply artefacts at discard. Refuses while any journal entry is open: that entry may be
91
+ * the only record of where the user's original is.
92
+ */
93
+ export declare function retireApplyArtifacts(stateDir: string, shadowDir: string, workspaceRoot?: string): Promise<void>;
94
+ /**
95
+ * The cleanup paths other than {@link WorkspaceShadow.discard} (a retried DISPOSING discard whose tree is gone, GC,
96
+ * displaced-snapshot recovery) retire the instance marker themselves. They call this once the marker is gone, just
97
+ * before removing the task state directory, so the instance's flat records do not keep that directory alive.
98
+ * Best effort and fail-closed: an open §9.3 journal entry (impossible here — it keeps the marker APPLYING, and no
99
+ * path disposes an APPLYING marker) or an unreadable record is kept, and the directory removal then simply fails.
100
+ */
101
+ export declare function retireInstanceRecords(stateDir: string, shadowDir: string): Promise<void>;
102
+ /**
103
+ * {@link retireInstanceRecords} for a path that knows only the instance's MARKER file (the vanished-root
104
+ * reconcile, which retires markers naming bytes that cannot exist). The instance UUID comes from the marker name.
105
+ */
106
+ export declare function retireInstanceRecordsForMarker(markerFile: string): Promise<void>;
107
+ /** Old enough that no create or discard of that instance can still be between its two writes. */
108
+ export declare const ORPHAN_ARTEFACT_MIN_AGE_MS: number;
109
+ /**
110
+ * Remove instance artefacts whose marker (`shadow-state<uuid>.json`) is gone and that are older than
111
+ * {@link ORPHAN_ARTEFACT_MIN_AGE_MS}. A `nolink<uuid>/` journal with an OPEN entry is never removed: it may be the
112
+ * only record of where the user's original is. Returns the names removed.
113
+ */
114
+ export declare function reapOrphanedInstanceArtefacts(stateDir: string, now?: number): Promise<string[]>;
@@ -0,0 +1,81 @@
1
+ import type { WorkspaceRootAuthority } from './workspace-authority';
2
+ import type { StartRecordEntry, StartRecordManifest } from './shadow-start-record';
3
+ /** Why a base hash could not be established. Every one of these is a conflict, never a silent null. */
4
+ export type BaseHashConflict =
5
+ /** The start record's tuple no longer matches the file: the user wrote during the task. */
6
+ {
7
+ kind: 'changed-since-start-record';
8
+ path: string;
9
+ field: 'ino' | 'size' | 'mtimeNs' | 'ctimeNs';
10
+ }
11
+ /** The tuple changed across the read itself — a write landed while we were hashing. */
12
+ | {
13
+ kind: 'changed-during-read';
14
+ path: string;
15
+ }
16
+ /** The path is gone from the user's folder. */
17
+ | {
18
+ kind: 'absent';
19
+ path: string;
20
+ }
21
+ /** Present in the record but not a regular file, so it has no content hash (§5.7). */
22
+ | {
23
+ kind: 'not-a-regular-file';
24
+ path: string;
25
+ type: StartRecordEntry['type'];
26
+ };
27
+ export type BaseHashResult = {
28
+ ok: true;
29
+ path: string;
30
+ sha256: string;
31
+ mode: number;
32
+ size: number;
33
+ fromRacyPrehash?: true;
34
+ } | {
35
+ ok: false;
36
+ conflict: BaseHashConflict;
37
+ };
38
+ export interface BaseHashResolverOptions {
39
+ /** The user's folder. Read-only here. */
40
+ sourceRoot: string;
41
+ manifest: StartRecordManifest;
42
+ /** Every entry of the start record, keyed by its relative path. */
43
+ entries: Map<string, StartRecordEntry>;
44
+ workspaceRootAuthority?: WorkspaceRootAuthority;
45
+ maxBytes: number;
46
+ /**
47
+ * Hashes computed at start for *racy* entries (§5.2). Those files were already fd-stable-hashed because a
48
+ * same-size rewrite inside one timestamp tick is invisible to `stat`, so re-deriving them here would be both
49
+ * wasteful and wrong — their pre-task bytes may no longer be on disk.
50
+ */
51
+ racyPrehashes?: Map<string, string>;
52
+ }
53
+ /**
54
+ * Resolves and caches §7.3 base hashes.
55
+ *
56
+ * Caching matters for correctness as much as speed: a second resolution of the same path could observe a
57
+ * different file, and two different `base_hash` values for one path inside a single task would make the later
58
+ * compare-and-swap non-deterministic.
59
+ */
60
+ export declare class BaseHashResolver {
61
+ private readonly opts;
62
+ private readonly cache;
63
+ constructor(opts: BaseHashResolverOptions);
64
+ /** Paths come from the START RECORD, so callers never iterate a partially-filled hash map (SB-13). */
65
+ allPaths(): IterableIterator<string>;
66
+ entry(path: string): StartRecordEntry | undefined;
67
+ /** How many paths the record holds, which is the count §5.6 shows as progress instead of capping. */
68
+ get recordedEntryCount(): number;
69
+ resolve(path: string): Promise<BaseHashResult>;
70
+ private compute;
71
+ /**
72
+ * Compare the recorded tuple with what the read observed.
73
+ *
74
+ * The inode is compared **only where the record says it is an identity** — §5.2 does not use it on FAT,
75
+ * exFAT or drvfs, because a truncate-and-rewrite gets a new inode there and an exFAT inode read back after
76
+ * close can differ from the one seen at create time.
77
+ */
78
+ private tupleDrift;
79
+ }
80
+ /** Load a start record's shards back into the path-keyed map the resolver walks. */
81
+ export declare function readStartRecordEntries(stateDir: string, manifest: StartRecordManifest): Promise<Map<string, StartRecordEntry>>;
@@ -0,0 +1,34 @@
1
+ import { type ShadowStateFileV2 } from './shadow-protocol';
2
+ /** Inline baselines up to this many serialized bytes stay in the marker, so every shipped-size marker is unchanged. */
3
+ export declare const BASELINE_INLINE_MAX_BYTES: number;
4
+ export declare const BASELINE_SHARD_ENTRIES = 50000;
5
+ export declare const BASELINE_RECORD_VERSION = 1;
6
+ type BaselineEntry = ShadowStateFileV2['baseline'][number];
7
+ export interface BaselineRecord {
8
+ version: typeof BASELINE_RECORD_VERSION;
9
+ entries: number;
10
+ /** `bytes` (Stage 2 R1-F8): the shard's exact size, checked before it is read. */
11
+ shards: Array<{
12
+ name: string;
13
+ sha256: string;
14
+ entries: number;
15
+ bytes?: number;
16
+ }>;
17
+ }
18
+ export declare const BASELINE_SHARD_RE: RegExp;
19
+ /** `baseline-<instance uuid>-NNNNN.jsonl` — the instance UUID ties every shard to exactly one marker. */
20
+ export declare function baselineShardName(shadowDir: string, index: number): string;
21
+ export declare function inlineBaselineBytes(baseline: readonly BaselineEntry[]): number;
22
+ /** Write the immutable shards (each `O_EXCL`, fsynced; then the directory) and return the record naming them. */
23
+ export declare function writeBaselineShards(stateDir: string, shadowDir: string, baseline: readonly BaselineEntry[], entriesPerShard?: number): Promise<BaselineRecord>;
24
+ /** The form written to disk: a recorded baseline is never serialized inline. */
25
+ export declare function compactMarkerForSerialization(marker: ShadowStateFileV2): ShadowStateFileV2;
26
+ /**
27
+ * Expand a parsed marker's `baselineRecord` into `baseline`, verifying every shard against its recorded hash and
28
+ * the record's counts. Shard names must be exactly the ones this marker's own instance would write — a record cannot
29
+ * point a reader anywhere else. A marker without a record is returned unchanged.
30
+ */
31
+ export declare function expandMarkerBaseline(stateDir: string, raw: unknown): Promise<unknown>;
32
+ /** This instance's shard files, for retirement at discard. */
33
+ export declare function listBaselineShards(stateDir: string, shadowDir: string): Promise<string[]>;
34
+ export {};
@@ -0,0 +1,20 @@
1
+ export type CopyStrategy = 'clone' | 'full-copy';
2
+ export interface CopyStrategyDecision {
3
+ strategy: CopyStrategy;
4
+ /** Why — recorded in the task log so a slow full copy is explained rather than mysterious. */
5
+ reason: 'clone-supported' | 'forced-physical' | 'different-device' | 'clone-unsupported' | 'probe-failed';
6
+ }
7
+ /** Probe whether `dir`'s filesystem can clone, using a CodeVibe-owned file it creates and always removes. */
8
+ export declare function probeCloneCapability(dir: string): Promise<boolean>;
9
+ /**
10
+ * Choose the task's strategy. `full-copy` is the conservative answer for every uncertain case, because the §5.5
11
+ * free-space check then runs — whereas wrongly choosing `clone` would skip the check for a copy that then
12
+ * writes the whole folder.
13
+ */
14
+ export declare function chooseCopyStrategy(opts: {
15
+ sourceRoot: string;
16
+ copyLocation: string;
17
+ forcePhysical?: boolean;
18
+ /** Injected in tests. */
19
+ probe?: (dir: string) => Promise<boolean>;
20
+ }): Promise<CopyStrategyDecision>;
@@ -0,0 +1,124 @@
1
+ /** §5.5: "`available ≥ bytesToCopy + max(1 GiB, 10% of bytesToCopy)`". */
2
+ export declare function requiredBytesForCopy(bytesToCopy: number): number;
3
+ /** §5.5 watchdog: "Below `max(1 GiB, 2% of the filesystem)` it sends SIGTERM to the agent's process group". */
4
+ export declare function watchdogFloorBytes(filesystemTotalBytes: number): number;
5
+ export interface FilesystemSpace {
6
+ path: string;
7
+ availableBytes: number;
8
+ totalBytes: number;
9
+ }
10
+ type StatfsFn = (path: string) => Promise<{
11
+ bavail: number | bigint;
12
+ bsize: number | bigint;
13
+ blocks: number | bigint;
14
+ }>;
15
+ /** Read what a filesystem can actually give an unprivileged writer. */
16
+ export declare function readFilesystemSpace(path: string, statfs?: StatfsFn): Promise<FilesystemSpace>;
17
+ export type CopySpaceVerdict = {
18
+ ok: true;
19
+ checked: FilesystemSpace[];
20
+ requiredBytes: number;
21
+ } | {
22
+ ok: false;
23
+ /** Names the folder size and the free space, as §5.5 requires of the refusal message. */
24
+ reason: string;
25
+ short: FilesystemSpace;
26
+ requiredBytes: number;
27
+ reservedBytes: number;
28
+ };
29
+ export interface CopySpaceRequest {
30
+ /** The copy location (§5.1). The space test runs here. */
31
+ copyLocation: string;
32
+ bytesToCopy: number;
33
+ /**
34
+ * Bytes concurrent tracks have already reserved on the same device, from the quota registry. Two tracks that
35
+ * each pass the check alone must not both start into space only one of them fits.
36
+ */
37
+ reservedBytes?: number;
38
+ /**
39
+ * Further filesystems the same condition must hold on. On WSL2 the copy location sits in an ext4 virtual disk
40
+ * that grows into the Windows drive, and the two report very different free space (1007 GB disk with 953 GB
41
+ * free vs a 50 GB `C:` with 23 GB free [SP-B]) — so `/mnt/c` is checked too, and the smaller wins.
42
+ */
43
+ alsoRequireOn?: string[];
44
+ statfs?: StatfsFn;
45
+ }
46
+ /** §5.5's pre-copy check. A refusal means the task does not start, with this message. */
47
+ export declare function checkCopySpace(req: CopySpaceRequest): Promise<CopySpaceVerdict>;
48
+ export type WatchdogVerdict = {
49
+ trip: false;
50
+ checked: FilesystemSpace[];
51
+ } | {
52
+ trip: true;
53
+ reason: string;
54
+ low: FilesystemSpace;
55
+ floorBytes: number;
56
+ };
57
+ /**
58
+ * One watchdog tick (§5.5 runs it every 5 s). When it trips, the caller sends SIGTERM to the agent's process
59
+ * group, SIGKILL after 10 s, halts the task with "disk nearly full", applies nothing, and keeps the copy.
60
+ *
61
+ * The floor is per filesystem because each has its own total. On WSL2 the Windows drive is checked too: the
62
+ * virtual disk can look roomy while the drive that backs it is nearly full.
63
+ */
64
+ export declare function watchdogTick(paths: string[], statfs?: StatfsFn): Promise<WatchdogVerdict>;
65
+ /** A task that §5.5 refuses to start. The message names the folder size and the free space. */
66
+ export declare class InsufficientCopySpaceError extends Error {
67
+ readonly verdict: Extract<CopySpaceVerdict, {
68
+ ok: false;
69
+ }>;
70
+ readonly code = "ECOPYSPACE";
71
+ constructor(verdict: Extract<CopySpaceVerdict, {
72
+ ok: false;
73
+ }>);
74
+ }
75
+ /** The task-start gate's answer: the strategy, and how many bytes this task may write as physical copies. */
76
+ export type CopySpaceDecision = import('./shadow-copy-strategy').CopyStrategyDecision & {
77
+ /**
78
+ * §5.6: the fixed 64 MiB / 512 MiB caps are gone, and this budget replaces them for every physical byte the task
79
+ * writes — the materializer's `maxFileBytes`/`maxTotalBytes`. A full copy may use the free space less §5.5's
80
+ * headroom (at least the folder's size, or the task was refused). A clone writes physical bytes only when single
81
+ * files cannot be cloned; those may use the free space down to the watchdog floor, so a fallback can never drive
82
+ * the disk below the level at which a running task is stopped. Always ≥ 1, as the materializer requires.
83
+ */
84
+ physicalCopyBudgetBytes: number;
85
+ };
86
+ /**
87
+ * The task-start gate: choose the copy strategy and, for a full copy only, refuse to start when the copy would
88
+ * not fit with §5.5's headroom. Throws {@link InsufficientCopySpaceError}. A clone is never refused here.
89
+ */
90
+ export declare function assertCopySpaceForTask(opts: {
91
+ sourceRoot: string;
92
+ copyLocation: string;
93
+ forcePhysical: boolean;
94
+ reservedBytes?: number;
95
+ /** Injected in tests to simulate a full disk. */
96
+ statfs?: StatfsFn;
97
+ }): Promise<CopySpaceDecision>;
98
+ export interface CopySpaceWatchdog {
99
+ /** Stop the timer. Idempotent. */
100
+ stop(): void;
101
+ /** Resolves with the trip verdict the first time it trips; never resolves if it never trips. */
102
+ tripped: Promise<Extract<WatchdogVerdict, {
103
+ trip: true;
104
+ }>>;
105
+ }
106
+ /**
107
+ * §5.5's running-task watchdog: every `intervalMs` (5 s by default) it checks `paths` — the copy location, and on
108
+ * WSL2 the Windows drive too — and calls `onTrip` ONCE, then stops, when any falls below its floor.
109
+ *
110
+ * Deliberately only the timer and the decision: what `onTrip` does — SIGTERM to the agent's process group, SIGKILL
111
+ * after 10 s, halt the task with "disk nearly full", apply nothing, keep the copy — belongs to the process layer
112
+ * (Stream A's `process-tree.ts` / `local-executor-impl.ts`). A failed `statfs` is not a trip: a watchdog that
113
+ * stopped tasks on its own errors would be worse than none; the failure is reported to `onError`.
114
+ */
115
+ export declare function startCopySpaceWatchdog(opts: {
116
+ paths: string[];
117
+ onTrip: (verdict: Extract<WatchdogVerdict, {
118
+ trip: true;
119
+ }>) => void | Promise<void>;
120
+ onError?: (err: unknown) => void;
121
+ intervalMs?: number;
122
+ statfs?: StatfsFn;
123
+ }): CopySpaceWatchdog;
124
+ export {};
@@ -0,0 +1,59 @@
1
+ /** Margins from §5.2's measured table. */
2
+ export declare const FS_MARGIN_MS: {
3
+ readonly apfsNs: 1;
4
+ readonly linuxNs: 20;
5
+ readonly exfat: 20;
6
+ readonly hfsPlus: 1000;
7
+ readonly fat: 2000;
8
+ /** 9p/drvfs, NFS, SMB, FUSE — and the design's "anything else" row. */
9
+ readonly foreignClock: 3000;
10
+ };
11
+ /**
12
+ * The fail-closed margin. §5.2's table ends with "anything else → 3 s", so an unrecognised filesystem
13
+ * fingerprints more files rather than fewer. Every lookup below falls back here.
14
+ */
15
+ export declare const DEFAULT_MARGIN_MS: 3000;
16
+ export interface StartRecordMargin {
17
+ marginMs: number;
18
+ /** How the margin was reached, so a task log can say why a folder was treated as coarse. */
19
+ basis: 'linux-magic' | 'darwin-measured' | 'probe' | 'fail-closed-default';
20
+ /** The raw `statfs` type, for the task log and for diagnosing an unrecognised filesystem. */
21
+ fsType?: number;
22
+ }
23
+ /**
24
+ * Resolve the margin for a path.
25
+ *
26
+ * On Linux the documented magic number decides. On darwin a measured index decides **only when a granularity
27
+ * probe agrees** — pass `probedGranularityMs` from {@link probeTimestampGranularityMs}. Without a probe, darwin
28
+ * takes the fail-closed default even for a recognised index, because the index alone is not evidence (SB-9).
29
+ */
30
+ export declare function resolveStartRecordMargin(path: string, opts?: {
31
+ probedGranularityMs?: number;
32
+ }): Promise<StartRecordMargin>;
33
+ /**
34
+ * Measure how long a same-size rewrite takes to become visible to `lstat` on this volume.
35
+ *
36
+ * This is the direct evidence the margin wants, and it is how §5.2's table was produced in the first place
37
+ * ("40/40 rapid same-size rewrites distinct" on APFS; "40 rapid rewrites → 1 distinct stat tuple" on HFS+).
38
+ * It answers one question — *after how long does a same-size rewrite change `mtimeNs`?* — which is exactly the
39
+ * quantity the racy-file rule needs, rather than inferring it from a filesystem's name.
40
+ *
41
+ * Runs in a **CodeVibe-named** temp entry in `dir`, which must be the copy location and never the user's folder:
42
+ * it writes. The entry is created `O_EXCL` and always removed.
43
+ *
44
+ * Returns the observed granularity in milliseconds, or `undefined` if it could not be measured — in which case
45
+ * the caller keeps the fail-closed default rather than guessing.
46
+ */
47
+ /** Probe entries older than this cannot belong to a probe still running (each lasts well under a minute). */
48
+ export declare const STALE_PROBE_AGE_MS: number;
49
+ /**
50
+ * Stage 1 N8: remove probe entries a crash left in a shared copy root — only the two exact probe name shapes, only
51
+ * a regular file (granularity) or a real directory (clone, from `mkdtemp`), and only once older than
52
+ * {@link STALE_PROBE_AGE_MS}. Never a symlink, never anything else. Best effort: a failure leaves the entry.
53
+ */
54
+ export declare function reapStaleProbeEntries(dir: string, now?: number): Promise<string[]>;
55
+ export declare function probeTimestampGranularityMs(dir: string, opts?: {
56
+ capMs?: number;
57
+ }): Promise<number | undefined>;
58
+ /** §5.1: a copy location must be a disk filesystem. Fedora's `/tmp` is tmpfs, which is why `os.tmpdir()` is never used. */
59
+ export declare function isMemoryBackedFilesystem(path: string): Promise<boolean>;