@sema-agent/core 7.30.0 → 7.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +274 -0
- package/dist/agents/cross-session-judge.d.ts +4 -12
- package/dist/agents/cross-session-judge.js +4 -11
- package/dist/agents/peer-session-drain.js +0 -5
- package/dist/agents/send-message-tool.js +1 -0
- package/dist/agents/subagent.d.ts +1 -0
- package/dist/agents/subagent.js +1 -0
- package/dist/core/ask-origin.d.ts +6 -0
- package/dist/core/ask-origin.js +10 -0
- package/dist/core/checkpoint-store.d.ts +14 -0
- package/dist/core/checkpoint-store.js +1 -0
- package/dist/core/engine-notice.d.ts +4 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +3 -0
- package/dist/core/memory-engine/data-plane.d.ts +3 -1
- package/dist/core/memory-engine/data-plane.js +1 -1
- package/dist/core/memory-engine/delegation-settlement.d.ts +44 -3
- package/dist/core/memory-engine/delegation-settlement.js +38 -3
- package/dist/core/memory-engine/engine.d.ts +123 -26
- package/dist/core/memory-engine/engine.js +426 -149
- package/dist/core/memory-engine/entry-scan.d.ts +23 -0
- package/dist/core/memory-engine/entry-scan.js +55 -0
- package/dist/core/memory-engine/file-backend.d.ts +16 -23
- package/dist/core/memory-engine/file-backend.js +122 -99
- package/dist/core/memory-engine/layer-observations.d.ts +109 -0
- package/dist/core/memory-engine/layer-observations.js +148 -0
- package/dist/core/memory-engine/layout.d.ts +11 -0
- package/dist/core/memory-engine/layout.js +20 -4
- package/dist/core/memory-engine/types.d.ts +26 -2
- package/dist/core/parent-spec-seat.d.ts +4 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/park-selfcheck.js +1 -0
- package/dist/core/runner/announce-once-ledger.d.ts +26 -0
- package/dist/core/runner/announce-once-ledger.js +37 -0
- package/dist/core/runner/contracts.d.ts +22 -5
- package/dist/core/runner/denial-limit-arms.d.ts +2 -2
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +0 -4
- package/dist/core/runner/prepare-acquire-reconcile.js +1 -3
- package/dist/core/runner/prepare-file-history.d.ts +13 -15
- package/dist/core/runner/prepare-file-history.js +57 -43
- package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
- package/dist/core/runner/prepare-listings.d.ts +3 -1
- package/dist/core/runner/prepare-listings.js +2 -2
- package/dist/core/runner/prepare-memory.js +16 -5
- package/dist/core/runner/prepare-park-ask.js +27 -16
- package/dist/core/runner/prepare-prompt-inputs.d.ts +8 -1
- package/dist/core/runner/prepare-prompt-inputs.js +5 -3
- package/dist/core/runner/prepare-task.js +7 -5
- package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -0
- package/dist/core/runner/prepare-wiring-manifest.js +2 -1
- package/dist/core/runner/run-attachment-seats.js +1 -1
- package/dist/core/runner/run-leg.js +61 -52
- package/dist/core/runner/run-settle-and-teardown.js +4 -0
- package/dist/core/runner/run-turn-boundary.js +6 -6
- package/dist/core/runner/turn-attachments.d.ts +6 -6
- package/dist/core/runner-deps.d.ts +3 -1
- package/dist/core/secret-env.d.ts +11 -0
- package/dist/core/secret-env.js +22 -1
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +22 -0
- package/dist/core/task-result.d.ts +1 -1
- package/dist/core/task-spec.d.ts +17 -8
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +10 -0
- package/dist/core/wiring-manifest.js +7 -1
- package/dist/engine/execution-env/node-execution-env.d.ts +6 -0
- package/dist/engine/execution-env/node-execution-env.js +15 -17
- package/dist/engine/harness/agent-harness.js +7 -5
- package/dist/engine/lsp/node-lsp-manager.d.ts +7 -0
- package/dist/engine/lsp/node-lsp-manager.js +11 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shell/lex.d.ts +8 -3
- package/dist/shell/lex.js +2 -2
- package/dist/tools/fs/bash-readonly-classifier.d.ts +7 -1
- package/dist/tools/fs/bash-readonly-classifier.js +7 -3
- package/dist/tools/fs/shell-read-establish.d.ts +1 -1
- package/dist/tools/fs/shell-read-establish.js +6 -34
- package/dist/tools/fs/shell-read-identity.d.ts +13 -0
- package/dist/tools/fs/shell-read-identity.js +39 -0
- package/dist/tools/fs/shell-read-judge.d.ts +8 -0
- package/dist/tools/fs/shell-read-judge.js +7 -0
- package/dist/tools/fs/shell-read-plan.d.ts +5 -1
- package/dist/tools/fs/shell-read-plan.js +13 -21
- package/dist/tools/fs/shell-read-word.d.ts +16 -2
- package/dist/tools/fs/shell-read-word.js +40 -5
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +7 -1
- package/wire-changes/core@7.30.1.json +154 -0
- package/wire-changes/core@7.31.0.json +705 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Files/dirs the entry scan never treats as entries: the derived index, dotfiles (`.hydrate`,
|
|
2
|
+
* transaction staging files), and anything not `.md`. */
|
|
3
|
+
export declare const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
4
|
+
/** Default nesting depth cap under a scope dir (CC memory is flat; a small allowance for topic dirs). */
|
|
5
|
+
export declare const DEFAULT_MAX_ENTRY_DEPTH = 3;
|
|
6
|
+
/** One on-disk entry file the scan found. */
|
|
7
|
+
export interface ScannedEntryFile {
|
|
8
|
+
/** Absolute path. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Slug = path relative to the scope dir, without `.md` (may contain `/` for nested topics). */
|
|
11
|
+
slug: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Enumerate a scope dir's entry files (depth-capped, symlinks skipped, dotfiles/index/non-`.md`
|
|
15
|
+
* ignored). Shared by the backend and the engine's harvest scan so the two can never disagree on
|
|
16
|
+
* what counts as an entry file. A scope dir is that scope's home and nothing else — no other
|
|
17
|
+
* scope's directory can lie under it, so there is nothing to exclude.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scanEntryFiles(dir: string, opts?: {
|
|
20
|
+
maxDepth?: number;
|
|
21
|
+
/** Harvest gate hook: called for every path the scan SKIPS, with why (backend scans ignore it). */
|
|
22
|
+
onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
|
|
23
|
+
}): ScannedEntryFile[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
export const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
4
|
+
export const DEFAULT_MAX_ENTRY_DEPTH = 3;
|
|
5
|
+
export function scanEntryFiles(dir, opts = {}) {
|
|
6
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_ENTRY_DEPTH;
|
|
7
|
+
const out = [];
|
|
8
|
+
const walk = (d, depth) => {
|
|
9
|
+
let names;
|
|
10
|
+
try {
|
|
11
|
+
names = readdirSync(d);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
if (d !== dir)
|
|
15
|
+
opts.onSkip?.(d, "unreadable");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for (const name of names.sort()) {
|
|
19
|
+
if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME) {
|
|
20
|
+
if (name.startsWith("."))
|
|
21
|
+
opts.onSkip?.(join(d, name), "dotfile");
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const p = join(d, name);
|
|
25
|
+
let st;
|
|
26
|
+
try {
|
|
27
|
+
st = lstatSync(p);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
opts.onSkip?.(p, "unreadable");
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (st.isSymbolicLink()) {
|
|
34
|
+
opts.onSkip?.(p, "symlink");
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (st.isDirectory()) {
|
|
38
|
+
if (depth + 1 <= maxDepth)
|
|
39
|
+
walk(p, depth + 1);
|
|
40
|
+
else
|
|
41
|
+
opts.onSkip?.(p, "depth");
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!st.isFile())
|
|
45
|
+
continue;
|
|
46
|
+
if (!name.endsWith(".md")) {
|
|
47
|
+
opts.onSkip?.(p, "nonmd");
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
out.push({ path: p, slug: relative(dir, p).replace(/\.md$/, "") });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(dir, 0);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -1,29 +1,7 @@
|
|
|
1
1
|
import { scopeDirName, type ChallengeEvent, type MemoryPartitionIncidentSink } from "./layout.js";
|
|
2
2
|
import type { MemoryBundleImportPlan, MemoryImportReport } from "./export-bundle.js";
|
|
3
3
|
import type { HarvestRejection, MemoryBackend, MemoryEntry, MemoryEntryHeader, MemoryScopeEnumeration, NotePatch, PatchReport, ScoredMemoryEntry } from "./types.js";
|
|
4
|
-
|
|
5
|
-
* transaction staging files), and anything not `.md`. */
|
|
6
|
-
export declare const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
7
|
-
/** Default nesting depth cap under a scope dir (CC memory is flat; a small allowance for topic dirs). */
|
|
8
|
-
export declare const DEFAULT_MAX_ENTRY_DEPTH = 3;
|
|
9
|
-
/** One on-disk entry file the scan found. */
|
|
10
|
-
export interface ScannedEntryFile {
|
|
11
|
-
/** Absolute path. */
|
|
12
|
-
path: string;
|
|
13
|
-
/** Slug = path relative to the scope dir, without `.md` (may contain `/` for nested topics). */
|
|
14
|
-
slug: string;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Enumerate a scope dir's entry files (depth-capped, symlinks skipped, dotfiles/index/non-`.md`
|
|
18
|
-
* ignored). Shared by the backend and the engine's harvest scan so the two can never disagree on
|
|
19
|
-
* what counts as an entry file. A scope dir is that scope's home and nothing else — no other
|
|
20
|
-
* scope's directory can lie under it, so there is nothing to exclude.
|
|
21
|
-
*/
|
|
22
|
-
export declare function scanEntryFiles(dir: string, opts?: {
|
|
23
|
-
maxDepth?: number;
|
|
24
|
-
/** Harvest gate hook: called for every path the scan SKIPS, with why (backend scans ignore it). */
|
|
25
|
-
onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
|
|
26
|
-
}): ScannedEntryFile[];
|
|
4
|
+
export { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles, type ScannedEntryFile } from "./entry-scan.js";
|
|
27
5
|
/** design/178 v2-b §3.3(b) — the explicit erasure selector (each form resolves to an explicit id
|
|
28
6
|
* list before anything is deleted; there is no whole-store selector). */
|
|
29
7
|
export type ErasureSelect = {
|
|
@@ -737,6 +715,14 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
737
715
|
* the probe classifies those inline and keeps going — a store with a lingering copy never
|
|
738
716
|
* contends for the writer lock on every read. */
|
|
739
717
|
private readScope;
|
|
718
|
+
/** #1076b — the observation-table door of the adopting read: bytes a session recorded appearing
|
|
719
|
+
* while it held this layer read-only are never adopted. Gate-failing bytes are not taken here —
|
|
720
|
+
* the inbound gate's containment keeps its door. A hit is announced, the committed copy stands in
|
|
721
|
+
* when this path is the entry's bound projection, and the disk is left as found (the account it
|
|
722
|
+
* is judged against is current — the scan sees to that). The notice is deduplicated against what
|
|
723
|
+
* is still queued, so every session that meets the file is told once. Returns whether the file
|
|
724
|
+
* was settled (the caller moves on). */
|
|
725
|
+
private settledAsObserved;
|
|
740
726
|
/**
|
|
741
727
|
* design-A RB-5 ([A3]) — the read-side whitewash gate, shared by the bound-here divergence lane
|
|
742
728
|
* and the move+edit lane so the two can never drift: an out-of-session disk change (git pull /
|
|
@@ -846,6 +832,13 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
846
832
|
* WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
|
|
847
833
|
*/
|
|
848
834
|
retrievalView(): MemoryBackend;
|
|
835
|
+
/** #1076b — the lock-free retrieval face serves no recorded divergence either (side-effect-free, so
|
|
836
|
+
* no notice): the committed copy stands in at the entry's bound projection, anything else is
|
|
837
|
+
* withheld. The table is read per call; the account only when it names one of these ids. */
|
|
838
|
+
private withoutObserved;
|
|
839
|
+
/** The committed rows read afresh from disk (no cache install), or undefined when the account
|
|
840
|
+
* cannot be read — a fault, never folded into "no rows". */
|
|
841
|
+
private committedRowsOrFault;
|
|
849
842
|
/** Dedup keys for divergence findings the restricted view already recorded: one materialize reads
|
|
850
843
|
* a scope several times (listHeaders, then getByIds per scope), and a checkpoint-cadence harvest
|
|
851
844
|
* re-audits the same still-pending state — without the key, one untouched divergence would mint
|
|
@@ -6,63 +6,13 @@ import { MAX_MEMORY_BYTES } from "../memory.js";
|
|
|
6
6
|
import { inlineUntrusted } from "../untrusted-text.js";
|
|
7
7
|
import { ambiguousOriginRepresentation, committedDistilledOf, committedOriginOf, computeEntryRev, distilledEquals, entryFromFile, isValidEntryId, originEquals, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
|
|
8
8
|
import { convertV1SidecarKeys } from "./delegation-settlement.js";
|
|
9
|
-
import {
|
|
9
|
+
import { layerObservationLookup, observedDivergenceNotice } from "./layer-observations.js";
|
|
10
|
+
import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
|
|
11
|
+
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
|
|
10
12
|
import { screenInboundEntries } from "./data-plane.js";
|
|
11
13
|
import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
|
|
12
14
|
import { thrownText } from "../thrown-value.js";
|
|
13
|
-
export
|
|
14
|
-
export const DEFAULT_MAX_ENTRY_DEPTH = 3;
|
|
15
|
-
export function scanEntryFiles(dir, opts = {}) {
|
|
16
|
-
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_ENTRY_DEPTH;
|
|
17
|
-
const out = [];
|
|
18
|
-
const walk = (d, depth) => {
|
|
19
|
-
let names;
|
|
20
|
-
try {
|
|
21
|
-
names = readdirSync(d);
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
if (d !== dir)
|
|
25
|
-
opts.onSkip?.(d, "unreadable");
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
for (const name of names.sort()) {
|
|
29
|
-
if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME) {
|
|
30
|
-
if (name.startsWith("."))
|
|
31
|
-
opts.onSkip?.(join(d, name), "dotfile");
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const p = join(d, name);
|
|
35
|
-
let st;
|
|
36
|
-
try {
|
|
37
|
-
st = lstatSync(p);
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
opts.onSkip?.(p, "unreadable");
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (st.isSymbolicLink()) {
|
|
44
|
-
opts.onSkip?.(p, "symlink");
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
if (st.isDirectory()) {
|
|
48
|
-
if (depth + 1 <= maxDepth)
|
|
49
|
-
walk(p, depth + 1);
|
|
50
|
-
else
|
|
51
|
-
opts.onSkip?.(p, "depth");
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (!st.isFile())
|
|
55
|
-
continue;
|
|
56
|
-
if (!name.endsWith(".md")) {
|
|
57
|
-
opts.onSkip?.(p, "nonmd");
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
out.push({ path: p, slug: relative(dir, p).replace(/\.md$/, "") });
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
walk(dir, 0);
|
|
64
|
-
return out;
|
|
65
|
-
}
|
|
15
|
+
export { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
|
|
66
16
|
export function canonicalJsonStringify(value) {
|
|
67
17
|
if (Array.isArray(value))
|
|
68
18
|
return `[${value.map((v) => (v === undefined || typeof v === "function" || typeof v === "symbol" ? "null" : canonicalJsonStringify(v))).join(",")}]`;
|
|
@@ -257,6 +207,45 @@ function validateLedgerRowsV2(rowsRaw, path) {
|
|
|
257
207
|
}
|
|
258
208
|
return out;
|
|
259
209
|
}
|
|
210
|
+
function parseJournal(raw, jp) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(raw);
|
|
213
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
|
|
214
|
+
throw new Error("wrong shape");
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function journalLedgerSnapshot(journal, jp) {
|
|
222
|
+
const hasV2 = journal.ledger2 !== undefined;
|
|
223
|
+
const hasV1 = journal.ledger !== undefined;
|
|
224
|
+
if (hasV2 === hasV1) {
|
|
225
|
+
throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
|
|
226
|
+
}
|
|
227
|
+
if (hasV2) {
|
|
228
|
+
const env = journal.ledger2;
|
|
229
|
+
if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
|
|
230
|
+
throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
|
|
231
|
+
}
|
|
232
|
+
const extras = {};
|
|
233
|
+
for (const [k, val] of Object.entries(env))
|
|
234
|
+
if (k !== "v" && k !== "rows")
|
|
235
|
+
extras[k] = val;
|
|
236
|
+
return { form: "v2", rows: validateLedgerRowsV2(env.rows, jp), extras };
|
|
237
|
+
}
|
|
238
|
+
const map = journal.ledger;
|
|
239
|
+
if (!map || typeof map !== "object" || Array.isArray(map))
|
|
240
|
+
throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
|
|
241
|
+
const rows = Object.create(null);
|
|
242
|
+
for (const [id, rev] of Object.entries(map)) {
|
|
243
|
+
if (typeof rev !== "string")
|
|
244
|
+
throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
|
|
245
|
+
rows[id] = { rev };
|
|
246
|
+
}
|
|
247
|
+
return { form: "v1", rows };
|
|
248
|
+
}
|
|
260
249
|
function parseLedgerText(raw, path) {
|
|
261
250
|
let parsed;
|
|
262
251
|
try {
|
|
@@ -1240,44 +1229,9 @@ export class FileMemoryEngineBackend {
|
|
|
1240
1229
|
const jp = join(this.controlPlaneRoot, JOURNAL_FILE);
|
|
1241
1230
|
const raw = readControlFileOrAbsent(jp, "transaction journal");
|
|
1242
1231
|
if (raw !== undefined) {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
|
|
1247
|
-
throw new Error("wrong shape");
|
|
1248
|
-
journal = parsed;
|
|
1249
|
-
}
|
|
1250
|
-
catch {
|
|
1251
|
-
throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
|
|
1252
|
-
}
|
|
1253
|
-
const hasV2 = journal.ledger2 !== undefined;
|
|
1254
|
-
const hasV1 = journal.ledger !== undefined;
|
|
1255
|
-
if (hasV2 === hasV1) {
|
|
1256
|
-
throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
|
|
1257
|
-
}
|
|
1258
|
-
let snapshotRows;
|
|
1259
|
-
let snapshotExtras;
|
|
1260
|
-
if (hasV2) {
|
|
1261
|
-
const env = journal.ledger2;
|
|
1262
|
-
if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
|
|
1263
|
-
throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
|
|
1264
|
-
}
|
|
1265
|
-
snapshotRows = validateLedgerRowsV2(env.rows, jp);
|
|
1266
|
-
snapshotExtras = {};
|
|
1267
|
-
for (const [k, val] of Object.entries(env))
|
|
1268
|
-
if (k !== "v" && k !== "rows")
|
|
1269
|
-
snapshotExtras[k] = val;
|
|
1270
|
-
}
|
|
1271
|
-
else {
|
|
1272
|
-
const map = journal.ledger;
|
|
1273
|
-
if (!map || typeof map !== "object" || Array.isArray(map))
|
|
1274
|
-
throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
|
|
1275
|
-
snapshotRows = Object.create(null);
|
|
1276
|
-
for (const [id, rev] of Object.entries(map)) {
|
|
1277
|
-
if (typeof rev !== "string")
|
|
1278
|
-
throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
|
|
1279
|
-
snapshotRows[id] = { rev };
|
|
1280
|
-
}
|
|
1232
|
+
const journal = parseJournal(raw, jp);
|
|
1233
|
+
const { rows: snapshotRows, extras: snapshotExtras, form: snapshotForm } = journalLedgerSnapshot(journal, jp);
|
|
1234
|
+
if (snapshotForm === "v1") {
|
|
1281
1235
|
try {
|
|
1282
1236
|
const diskRaw = readControlFileOrAbsent(join(this.controlPlaneRoot, LEDGER_FILE), "committed-rev ledger");
|
|
1283
1237
|
const diskParsed = diskRaw !== undefined ? JSON.parse(diskRaw) : undefined;
|
|
@@ -1314,7 +1268,7 @@ export class FileMemoryEngineBackend {
|
|
|
1314
1268
|
}
|
|
1315
1269
|
journalEvs.set(e.ev, canonical);
|
|
1316
1270
|
}
|
|
1317
|
-
if (
|
|
1271
|
+
if (snapshotForm === "v1")
|
|
1318
1272
|
throw new ControlPlaneCorruptError(`transaction journal carries transfers with a v1 snapshot (impossible form): ${jp}`);
|
|
1319
1273
|
if (journalEvs.size > 0) {
|
|
1320
1274
|
for (const row of this.readTransferChain(fenceToken)) {
|
|
@@ -1464,12 +1418,12 @@ export class FileMemoryEngineBackend {
|
|
|
1464
1418
|
}
|
|
1465
1419
|
catch (err) {
|
|
1466
1420
|
const code = err.code;
|
|
1467
|
-
throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (thrownText(err))}) — the journal is kept at ${jp}; an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path`);
|
|
1421
|
+
throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (thrownText(err))}) — the journal is kept at ${jp}; ${permissionRemedyOf(err, this.directoryRoot) ?? "an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path"}`);
|
|
1468
1422
|
}
|
|
1469
1423
|
}
|
|
1470
1424
|
this.recoveryCommitProbe?.();
|
|
1471
1425
|
this.assertTxnLockOwnership(fenceToken);
|
|
1472
|
-
if (
|
|
1426
|
+
if (snapshotForm === "v2") {
|
|
1473
1427
|
this.ledger = snapshotRows;
|
|
1474
1428
|
this.envelopeExtras = snapshotExtras;
|
|
1475
1429
|
this.persistedSchemaVersion = "v2";
|
|
@@ -1589,6 +1543,9 @@ export class FileMemoryEngineBackend {
|
|
|
1589
1543
|
const moveEvents = [];
|
|
1590
1544
|
const moveAnnouncements = [];
|
|
1591
1545
|
const skippedUnreadable = [];
|
|
1546
|
+
let table;
|
|
1547
|
+
const observed = () => (table ??= layerObservationLookup(this.controlPlaneRoot));
|
|
1548
|
+
let freshAccount;
|
|
1592
1549
|
for (const f of files) {
|
|
1593
1550
|
let text;
|
|
1594
1551
|
try {
|
|
@@ -1608,6 +1565,12 @@ export class FileMemoryEngineBackend {
|
|
|
1608
1565
|
const committed = rowsRev(rows, entry.id);
|
|
1609
1566
|
const binding = rowsBinding(rows, entry.id);
|
|
1610
1567
|
const relPath = relative(this.directoryRoot, f.path);
|
|
1568
|
+
if (probeOnly === true && observed().covers(entry.id)) {
|
|
1569
|
+
const fresh = (freshAccount ??= this.committedRowsOrFault() ?? null);
|
|
1570
|
+
const freshBinding = fresh !== null ? rowsBinding(fresh, entry.id) : undefined;
|
|
1571
|
+
if (fresh === null || rowsRev(fresh, entry.id) !== committed || freshBinding?.scope !== binding?.scope || freshBinding?.slug !== binding?.slug)
|
|
1572
|
+
return undefined;
|
|
1573
|
+
}
|
|
1611
1574
|
if (committed !== undefined && binding !== undefined && !(binding.scope === scope && binding.slug === f.slug)) {
|
|
1612
1575
|
const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
|
|
1613
1576
|
if (probe === "present") {
|
|
@@ -1618,6 +1581,8 @@ export class FileMemoryEngineBackend {
|
|
|
1618
1581
|
this.announceAdoptionNotice(`bindprobe|${relPath}`, `committed entry ${entry.id} appears at ${JSON.stringify(inlineUntrusted(relPath))} but is bound to ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))}, and the bound projection could not be probed conclusively (a read failure, or a foreign file at that address — not ENOENT) — nothing is served or adopted this pass`);
|
|
1619
1582
|
continue;
|
|
1620
1583
|
}
|
|
1584
|
+
if (this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "quarantined-only"))
|
|
1585
|
+
continue;
|
|
1621
1586
|
if (probeOnly)
|
|
1622
1587
|
return undefined;
|
|
1623
1588
|
if (lockToken !== undefined)
|
|
@@ -1630,6 +1595,8 @@ export class FileMemoryEngineBackend {
|
|
|
1630
1595
|
continue;
|
|
1631
1596
|
}
|
|
1632
1597
|
if (committed !== entry.rev) {
|
|
1598
|
+
if (committed !== undefined && this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "restored"))
|
|
1599
|
+
continue;
|
|
1633
1600
|
if (probeOnly)
|
|
1634
1601
|
return undefined;
|
|
1635
1602
|
if (lockToken !== undefined)
|
|
@@ -1664,6 +1631,8 @@ export class FileMemoryEngineBackend {
|
|
|
1664
1631
|
entry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
1665
1632
|
}
|
|
1666
1633
|
else {
|
|
1634
|
+
if (committed === undefined && this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "restored"))
|
|
1635
|
+
continue;
|
|
1667
1636
|
if (committed !== undefined)
|
|
1668
1637
|
adoptedExternal.push(relPath);
|
|
1669
1638
|
bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug: f.slug, at: this.now() }, moveEvents, "adopted-move");
|
|
@@ -1740,6 +1709,25 @@ export class FileMemoryEngineBackend {
|
|
|
1740
1709
|
this.batchScan.set(scope, entries);
|
|
1741
1710
|
return entries;
|
|
1742
1711
|
}
|
|
1712
|
+
settledAsObserved(table, out, entry, relPath, slug, scope, committed, binding, text, containment) {
|
|
1713
|
+
if (!table.covers(entry.id) || this.inboundGate(relPath, text) !== undefined)
|
|
1714
|
+
return false;
|
|
1715
|
+
if (committed !== undefined && committed !== entry.rev && this.whitewashInboundFinding(relPath, entry, containment) !== undefined)
|
|
1716
|
+
return false;
|
|
1717
|
+
const hit = table.find(entry.id, committed, entry.rev);
|
|
1718
|
+
if (hit === undefined)
|
|
1719
|
+
return false;
|
|
1720
|
+
const standIn = committed !== undefined && binding !== undefined && binding.scope === scope && binding.slug === slug ? this.readCommittedShadow(entry.id) : undefined;
|
|
1721
|
+
const notice = observedDivergenceNotice(relPath, standIn !== undefined, hit.undecided === true);
|
|
1722
|
+
try {
|
|
1723
|
+
enqueueMemoryAnnouncement(this.controlPlaneRoot, { kind: "external", at: this.now(), items: [notice] }, { supersedesPrefix: notice });
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
}
|
|
1727
|
+
if (standIn !== undefined)
|
|
1728
|
+
out.push(entryFromFile(standIn, entry.id, slug, scope));
|
|
1729
|
+
return true;
|
|
1730
|
+
}
|
|
1743
1731
|
whitewashInboundFinding(relPath, entry, containment) {
|
|
1744
1732
|
const committedShadow = this.readCommittedShadow(entry.id);
|
|
1745
1733
|
const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
|
|
@@ -1862,14 +1850,46 @@ export class FileMemoryEngineBackend {
|
|
|
1862
1850
|
throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
|
|
1863
1851
|
};
|
|
1864
1852
|
return {
|
|
1865
|
-
listHeaders: async (scopes) => (this.ensureLayout(), this.listHeadersFrom(scopes, (s) => this.readScope(s, false))),
|
|
1866
|
-
getByIds: async (ids) => (this.ensureLayout(), this.getByIdsFrom(ids, (s) => this.readScope(s, false))),
|
|
1867
|
-
search: async (query, scopes, opts) => (this.ensureLayout(), this.searchFrom(query, scopes, opts, (s) => this.readScope(s, false))),
|
|
1853
|
+
listHeaders: async (scopes) => (this.ensureLayout(), this.listHeadersFrom(scopes, (s) => this.withoutObserved(this.readScope(s, false), s))),
|
|
1854
|
+
getByIds: async (ids) => (this.ensureLayout(), this.getByIdsFrom(ids, (s) => this.withoutObserved(this.readScope(s, false), s))),
|
|
1855
|
+
search: async (query, scopes, opts) => (this.ensureLayout(), this.searchFrom(query, scopes, opts, (s) => this.withoutObserved(this.readScope(s, false), s))),
|
|
1868
1856
|
applyPatches: async () => refuse("applyPatches"),
|
|
1869
1857
|
getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
|
|
1870
1858
|
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
1871
1859
|
};
|
|
1872
1860
|
}
|
|
1861
|
+
withoutObserved(entries, scope) {
|
|
1862
|
+
const table = layerObservationLookup(this.controlPlaneRoot);
|
|
1863
|
+
if (!entries.some((e) => table.covers(e.id)))
|
|
1864
|
+
return entries;
|
|
1865
|
+
const account = this.committedRowsOrFault();
|
|
1866
|
+
if (account === undefined)
|
|
1867
|
+
return entries.filter((e) => !table.covers(e.id));
|
|
1868
|
+
const out = [];
|
|
1869
|
+
for (const e of entries) {
|
|
1870
|
+
const committed = rowsRev(account, e.id);
|
|
1871
|
+
const binding = rowsBinding(account, e.id);
|
|
1872
|
+
const boundHere = binding !== undefined && binding.scope === scope && binding.slug === e.slug;
|
|
1873
|
+
if ((committed === e.rev && boundHere) || table.find(e.id, committed, e.rev) === undefined) {
|
|
1874
|
+
out.push(e);
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
const standIn = committed !== undefined && boundHere ? this.readCommittedShadow(e.id) : undefined;
|
|
1878
|
+
if (standIn !== undefined)
|
|
1879
|
+
out.push(entryFromFile(standIn, e.id, e.slug, scope));
|
|
1880
|
+
}
|
|
1881
|
+
return out;
|
|
1882
|
+
}
|
|
1883
|
+
committedRowsOrFault() {
|
|
1884
|
+
const path = join(this.controlPlaneRoot, LEDGER_FILE);
|
|
1885
|
+
try {
|
|
1886
|
+
const raw = readControlFileOrAbsent(path, "committed-rev ledger");
|
|
1887
|
+
return raw === undefined ? Object.create(null) : parseLedgerText(raw, path).rows;
|
|
1888
|
+
}
|
|
1889
|
+
catch {
|
|
1890
|
+
return undefined;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1873
1893
|
restrictedFindingKeys = new Set();
|
|
1874
1894
|
recordRestrictedFinding(key, finding) {
|
|
1875
1895
|
if (this.restrictedFindingKeys.has(key))
|
|
@@ -2187,6 +2207,9 @@ export class FileMemoryEngineBackend {
|
|
|
2187
2207
|
try {
|
|
2188
2208
|
return await this.applyPatchesLocked(patches, lock.token);
|
|
2189
2209
|
}
|
|
2210
|
+
catch (err) {
|
|
2211
|
+
throw withPermissionRemedy(err, this.directoryRoot);
|
|
2212
|
+
}
|
|
2190
2213
|
finally {
|
|
2191
2214
|
lock.release();
|
|
2192
2215
|
}
|
|
@@ -2865,7 +2888,7 @@ export class FileMemoryEngineBackend {
|
|
|
2865
2888
|
}
|
|
2866
2889
|
catch (err) {
|
|
2867
2890
|
this.ledger = undefined;
|
|
2868
|
-
throw err;
|
|
2891
|
+
throw withPermissionRemedy(err, this.directoryRoot);
|
|
2869
2892
|
}
|
|
2870
2893
|
finally {
|
|
2871
2894
|
lock.release();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { HarvestRejection, MemorySessionHandle } from "./types.js";
|
|
2
|
+
export declare const LAYER_OBSERVATIONS_FILE = "layer-observations.json";
|
|
3
|
+
/** One recorded unexpected write into a read-only layer. */
|
|
4
|
+
export interface LayerObservation {
|
|
5
|
+
/** The layer (scope) the bytes were observed in. */
|
|
6
|
+
scope: string;
|
|
7
|
+
/** Scope-home-relative, `/`-separated path the bytes were observed at (disclosure only). */
|
|
8
|
+
rel: string;
|
|
9
|
+
/** The entry id the observed file carries; absent ⇔ an id-less note. */
|
|
10
|
+
entryId?: string;
|
|
11
|
+
/** The entry's committed rev when observed; absent ⇔ the id had no committed row. */
|
|
12
|
+
rev?: string;
|
|
13
|
+
/** The observed content's identity: its entry rev (what the read side computes from the file), or
|
|
14
|
+
* for an id-less note the sha256 of its bytes. */
|
|
15
|
+
observedRev: string;
|
|
16
|
+
/** The observing session, when the harvest was given one. */
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
observedAt: number;
|
|
19
|
+
/** Whether a commit backs these bytes could not be established when they were observed (a
|
|
20
|
+
* transaction journal was pending — the committed account could not be read whole). The row — one
|
|
21
|
+
* per observation — then stands against ANY committed rev (never adopted) until the first session
|
|
22
|
+
* that may write settles it, once its preflight has recovered the journal
|
|
23
|
+
* ({@link settleUndecidedLayerObservations}): the committed account backs its bytes at its seat ⇒
|
|
24
|
+
* this observation is removed (the bytes are committed content); it does not ⇒ pinned to the
|
|
25
|
+
* committed rev read, an ordinary row from then on, retired when that rev moves (superseded, as
|
|
26
|
+
* every ordinary row is); the account still cannot be read ⇒ it stays undecided. Only id-bearing
|
|
27
|
+
* rows (a note has no commit question) and never with `rev`. */
|
|
28
|
+
undecided?: true;
|
|
29
|
+
/** An undecided row's own identity (random, minted with the row): two observations alike in every
|
|
30
|
+
* other field — the same session in the same millisecond — stay two rows. Undecided rows only. */
|
|
31
|
+
observationId?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Record observations (locked, journaled; an existing key keeps its first row). Nothing is pruned
|
|
34
|
+
* here: the rev a harvest read before this lock need not be current any more, so it cannot decide
|
|
35
|
+
* which other rows are stale — and a row against a rev the entry has left can never match anyway. */
|
|
36
|
+
export declare function recordLayerObservations(controlDir: string, rows: readonly LayerObservation[]): void;
|
|
37
|
+
/** The table as a read-side lookup. */
|
|
38
|
+
export interface LayerObservationLookup {
|
|
39
|
+
/** Whether any row names `entryId` (a cheap pre-test before the committed rev is fetched). */
|
|
40
|
+
covers(entryId: string): boolean;
|
|
41
|
+
/** The row naming exactly this content (`observedRev`) of `entryId` against its committed rev
|
|
42
|
+
* `rev` (absent ⇔ the id has no committed row) — or an undecided row for that content, which stands
|
|
43
|
+
* against any rev until it is settled — else undefined. */
|
|
44
|
+
find(entryId: string, rev: string | undefined, observedRev: string): LayerObservation | undefined;
|
|
45
|
+
/** The row naming exactly these bytes (`digest`) of an id-less note at (`scope`, `rel`), or undefined. */
|
|
46
|
+
findNote(scope: string, rel: string, digest: string): LayerObservation | undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Read the table (throws the strict family's refusal on a corrupt table). */
|
|
49
|
+
export declare function layerObservationLookup(controlDir: string): LayerObservationLookup;
|
|
50
|
+
/** The disclosure line for a recorded divergence met on the adopting read (one spelling, every
|
|
51
|
+
* door). The verdict leads: the render boundary caps an item at a label's length. */
|
|
52
|
+
export declare function observedDivergenceNotice(rel: string, served: boolean, undecided?: boolean): string;
|
|
53
|
+
/** The undecided rows ({@link LayerObservation.undecided}) awaiting settlement. */
|
|
54
|
+
export declare function undecidedLayerObservations(controlDir: string): LayerObservation[];
|
|
55
|
+
/** Settle undecided rows (locked, journaled), each by its own key (one observation — never another
|
|
56
|
+
* observation of the same entry): `"backed"` removes the row (the committed account backs its bytes
|
|
57
|
+
* at its seat: those bytes are committed content, so no unexpected write is lost), `{ rev }` pins
|
|
58
|
+
* it to the committed rev the settlement read (`rev` absent ⇔ no committed row) — an ordinary row,
|
|
59
|
+
* retired like any other when that rev moves. A row without a verdict stays undecided. */
|
|
60
|
+
export declare function settleUndecidedLayerObservations(controlDir: string, verdicts: ReadonlyMap<LayerObservation, "backed" | {
|
|
61
|
+
rev?: string;
|
|
62
|
+
}>): void;
|
|
63
|
+
/** The census digest: whole-file bytes (sha256), so "the bytes that stood when the window opened"
|
|
64
|
+
* means the bytes. */
|
|
65
|
+
export declare function layerDigest(text: string): string;
|
|
66
|
+
/** The read-only layers' census (materialize, zero-copy): canonical path → {@link layerDigest} of
|
|
67
|
+
* every entry file under `homes`, scanned at the read side's depth (what the adopting read sees). */
|
|
68
|
+
export declare function readOnlyLayerCensus(homes: readonly string[]): Map<string, string>;
|
|
69
|
+
/** A file of a read-only layer whose bytes changed since the materialize-time census. */
|
|
70
|
+
export interface ReadOnlyObservationCandidate {
|
|
71
|
+
path: string;
|
|
72
|
+
scope: string;
|
|
73
|
+
rel: string;
|
|
74
|
+
slug: string;
|
|
75
|
+
digest: string;
|
|
76
|
+
id?: string;
|
|
77
|
+
/** The entry rev of the bytes, or their digest for an id-less note. */
|
|
78
|
+
observedRev: string;
|
|
79
|
+
}
|
|
80
|
+
/** The observation half's candidates: files of the layers `handle` holds read-only whose bytes differ
|
|
81
|
+
* from its materialize-time census (none without a census). Throws when the scope registry cannot
|
|
82
|
+
* be read (the homes are resolved through it). */
|
|
83
|
+
export declare function readOnlyLayerCandidates(memoryDir: string, controlDir: string, handle: Pick<MemorySessionHandle, "layerBaseline" | "scopes" | "writeScope">): ReadOnlyObservationCandidate[];
|
|
84
|
+
/** What a harvest's observation half judged at detection: the rows (written AS JUDGED — a retry writes
|
|
85
|
+
* the same rows, never a re-judgment against a later account), the rejections and undecided
|
|
86
|
+
* candidates they carry, whether they landed, and the failure of the last attempt that did not. */
|
|
87
|
+
export interface ObservationBatch {
|
|
88
|
+
rows: LayerObservation[];
|
|
89
|
+
rejections: HarvestRejection[];
|
|
90
|
+
/** Each undecided candidate with the row it was recorded as (its observation identity). */
|
|
91
|
+
undecided: Array<{
|
|
92
|
+
candidate: ReadOnlyObservationCandidate;
|
|
93
|
+
row: LayerObservation;
|
|
94
|
+
}>;
|
|
95
|
+
landed: boolean;
|
|
96
|
+
failure?: string;
|
|
97
|
+
}
|
|
98
|
+
/** The undecided candidates a LANDED batch recorded (an unlanded one claims none). */
|
|
99
|
+
export declare function landedUndecided(batch: ObservationBatch | undefined): ObservationBatch["undecided"];
|
|
100
|
+
/** Whether two rows are the same observation (the table's key — for an undecided row, one
|
|
101
|
+
* observation: session and time included). */
|
|
102
|
+
export declare function sameObservation(a: LayerObservation, b: LayerObservation): boolean;
|
|
103
|
+
/** Whether the committed row backs an observation's bytes at its seat (same rev, bound there) — the
|
|
104
|
+
* observation was a commit, not an unexpected write. */
|
|
105
|
+
export declare function observationBacked(row: Pick<LayerObservation, "scope" | "rel" | "observedRev">, committed: {
|
|
106
|
+
rev: string;
|
|
107
|
+
scope?: string;
|
|
108
|
+
slug?: string;
|
|
109
|
+
} | undefined): boolean;
|