@staix/agent-mem 0.20.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +3 -0
  5. package/src/hook-spool.ts +45 -3
  6. package/src/index.ts +25 -2
  7. package/src/memory/bridge.ts +66 -18
  8. package/src/memory/candidates.ts +71 -9
  9. package/src/memory/generation-index.ts +95 -18
  10. package/src/memory/generation-sync.ts +3 -0
  11. package/src/memory/generation.ts +33 -15
  12. package/src/memory/platform.ts +38 -3
  13. package/src/memory/policy.ts +28 -15
  14. package/src/memory/prune-publisher-lineage.ts +50 -12
  15. package/src/memory/public-prune-advance.ts +11 -5
  16. package/src/memory/public-prune-inputs.ts +17 -16
  17. package/src/memory/shard-sync-applications.ts +153 -0
  18. package/src/memory/shard-sync-format.ts +15 -0
  19. package/src/memory/shard-sync-graph.ts +6 -4
  20. package/src/memory/shard-sync-history.ts +238 -0
  21. package/src/memory/shard-sync.ts +67 -151
  22. package/src/memory/shard.ts +32 -6
  23. package/src/memory/store.ts +14 -1
  24. package/src/memory/sync-binding.ts +63 -0
  25. package/src/memory/sync-placement-active.ts +75 -0
  26. package/src/memory/sync-placement-cache.ts +32 -0
  27. package/src/memory/sync-placement-capture.ts +292 -0
  28. package/src/memory/sync-placement-dependencies.ts +255 -0
  29. package/src/memory/sync-placement-format.ts +182 -0
  30. package/src/memory/sync-placement-git.ts +95 -0
  31. package/src/memory/sync-placement-guard.ts +98 -0
  32. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  33. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  34. package/src/memory/sync-placement-inspection.ts +236 -0
  35. package/src/memory/sync-placement-journal-format.ts +114 -0
  36. package/src/memory/sync-placement-journal-storage.ts +513 -0
  37. package/src/memory/sync-placement-journal.ts +153 -0
  38. package/src/memory/sync-placement-materialize.ts +149 -0
  39. package/src/memory/sync-placement-plan.ts +171 -0
  40. package/src/memory/sync-placement-projection.ts +79 -0
  41. package/src/memory/sync-placement-publication-plan.ts +189 -0
  42. package/src/memory/sync-placement-publication-storage.ts +87 -0
  43. package/src/memory/sync-placement-transport.ts +489 -0
  44. package/src/memory/sync-policy-inspection.ts +126 -0
  45. package/src/memory/sync-screen.ts +128 -33
  46. package/src/memory/sync.ts +20 -64
  47. package/src/ui/inbox-cache.ts +219 -0
  48. package/src/ui/inbox-worker-version.ts +14 -0
  49. package/src/ui/inbox-worker.ts +32 -0
  50. package/src/ui/inbox.ts +281 -0
  51. package/src/ui/panel.js +7 -3
  52. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,98 @@
1
+ import { readActivePlacementBinding } from "./sync-placement-active.ts";
2
+ import { types } from "node:util";
3
+ /** Closed activation metadata admits v3 readers; publication still requires full journal/source/policy proof. */
4
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { LOCAL_METADATA_MAX_BYTES, legacyProfileMarkerShape, type BoundProfile } from "./generation.ts";
7
+ import { legacyShardMarkerShape } from "./shard.ts";
8
+ import { PLACEMENT_PROFILE_SCHEMA, PLACEMENT_SHARDS_SCHEMA, validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
9
+
10
+ export const PLACEMENT_STAGE1_REFUSAL = "sync placement v3 requires matching durable activation metadata; keep the current compatible profile active until person-authorized activation is complete";
11
+ export const PLACEMENT_MARKER_REFUSAL = "sync placement metadata is invalid or unsupported; person inspection and a compatible reader are required before continuing";
12
+ export class SyncPlacementRuntimeError extends Error {
13
+ readonly code: "sync_placement_not_enabled" | "sync_placement_unavailable";
14
+ constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable"; }
15
+ }
16
+ class MarkerChanged extends Error {}
17
+ const same = (left: BigIntStats, right: BigIntStats) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
18
+ function readMarker(file: string): unknown | undefined {
19
+ for (let attempt = 0; attempt < 6; attempt++) {
20
+ let fd: number | undefined;
21
+ try {
22
+ const before = lstatSync(file, { bigint: true, throwIfNoEntry: false });
23
+ if (!before) return undefined;
24
+ if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(LOCAL_METADATA_MAX_BYTES)) throw new SyncPlacementRuntimeError(PLACEMENT_MARKER_REFUSAL);
25
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26
+ const opened = fstatSync(fd, { bigint: true });
27
+ if (!same(before, opened)) throw new MarkerChanged();
28
+ const bytes = Buffer.alloc(Number(opened.size) + 1); let count = 0;
29
+ while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
30
+ const after = fstatSync(fd, { bigint: true }), named = lstatSync(file, { bigint: true, throwIfNoEntry: false });
31
+ if (count !== Number(opened.size) || !same(opened, after) || !named || named.isSymbolicLink() || !same(opened, named)) throw new MarkerChanged();
32
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count)));
33
+ } catch (error) { if (error instanceof MarkerChanged && attempt < 5) continue; throw new SyncPlacementRuntimeError(PLACEMENT_MARKER_REFUSAL); }
34
+ finally { if (fd !== undefined) closeSync(fd); }
35
+ }
36
+ throw new SyncPlacementRuntimeError(PLACEMENT_MARKER_REFUSAL);
37
+ }
38
+ export function readPlacementRuntimeMarkers(home: string): { profile: unknown | undefined; shards: unknown | undefined } {
39
+ return { profile: readMarker(join(home, "profile.json")), shards: readMarker(join(home, "shards.json")) };
40
+ }
41
+ function schema(value: unknown): unknown {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
43
+ return Object.getOwnPropertyDescriptor(value, "schema")?.value;
44
+ }
45
+ function plainMarker(value: unknown): boolean {
46
+ if (value === undefined) return true;
47
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value) || Object.getOwnPropertySymbols(value).length) return false;
48
+ const prototype = Object.getPrototypeOf(value);
49
+ if (prototype !== Object.prototype && prototype !== null) return false;
50
+ return Object.values(Object.getOwnPropertyDescriptors(value)).every(field => "value" in field && field.enumerable);
51
+ }
52
+ /** Pure dispatch, so generation's active selector reader need not recursively call readSelector. */
53
+ export function placementMarkerRefusal(profile: unknown, shards: unknown): string | undefined {
54
+ if (!plainMarker(profile) || !plainMarker(shards)) return PLACEMENT_MARKER_REFUSAL;
55
+ if (schema(profile) === PLACEMENT_PROFILE_SCHEMA || schema(shards) === PLACEMENT_SHARDS_SCHEMA) {
56
+ try { validatePlacementShards(shards, validatePlacementProfile(profile)); }
57
+ catch { return PLACEMENT_MARKER_REFUSAL; }
58
+ return PLACEMENT_STAGE1_REFUSAL;
59
+ }
60
+ if (!legacyProfileMarkerShape(profile) || !legacyShardMarkerShape(shards)) return PLACEMENT_MARKER_REFUSAL;
61
+ return undefined;
62
+ }
63
+ /** Selector preflight only recognizes v3. Legacy malformed/split metadata must retain its existing reader/recovery path. */
64
+ // This narrow probe is not a data/transport gate; effects must still use the full runtime guard.
65
+ export function placementSelectorRefusal(home: string): string | undefined {
66
+ // A durable placement fence forbids falling back to raw legacy sync after marker rollback.
67
+ const fenced = placementFencePresent(home);
68
+ let profile: unknown, shards: unknown;
69
+ try { profile = readMarker(join(home, "profile.json")); } catch { /* The original selector reader reports this legacy failure. */ }
70
+ try { shards = readMarker(join(home, "shards.json")); } catch { /* Ordinary migration still owns malformed split-state recovery. */ }
71
+ if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
72
+ const refusal = placementMarkerRefusal(profile, shards);
73
+ if (refusal !== PLACEMENT_STAGE1_REFUSAL) return refusal;
74
+ try { return readActivePlacementBinding(home, profile, shards) ? undefined : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
75
+ }
76
+ function placementFencePresent(home: string): boolean {
77
+ const root = lstatSync(join(home, "sync-placement"), { throwIfNoEntry: false });
78
+ if (!root) return false;
79
+ if (root.isSymbolicLink() || !root.isDirectory() || root.uid !== process.getuid?.() || (root.mode & 0o077) !== 0) return true;
80
+ return lstatSync(join(home, "sync-placement", "active.json"), { throwIfNoEntry: false }) !== undefined;
81
+ }
82
+ /** Bounded activation/view metadata only; no data/index open, cache mutation, lock or child/network call.
83
+ * The v3 publication coordinator separately proves the complete journal, selected sources and policy. */
84
+ export function placementRuntimeRefusal(home: string, bound?: Pick<BoundProfile,"kind"|"sharded"|"generation"|"publicationFormat"|"placementHead">): string | undefined {
85
+ try { const fenced = placementFencePresent(home), markers = readPlacementRuntimeMarkers(home);
86
+ const refusal = placementMarkerRefusal(markers.profile, markers.shards);
87
+ if (refusal === PLACEMENT_STAGE1_REFUSAL) {
88
+ const active=readActivePlacementBinding(home,markers.profile,markers.shards);
89
+ if(!active)return refusal;
90
+ if(bound && (bound.kind!=="generation"||bound.sharded!==true||bound.generation!==active.profile.generation||bound.publicationFormat!==active.profile.publicationFormat||bound.placementHead!==active.profile.placementHead)) return PLACEMENT_MARKER_REFUSAL;
91
+ return undefined;
92
+ }
93
+ return refusal ?? (fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
94
+ catch { return PLACEMENT_MARKER_REFUSAL; }
95
+ }
96
+ export function assertPlacementRuntime(home: string): void {
97
+ const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(refusal);
98
+ }
@@ -0,0 +1,257 @@
1
+ /** Read-only retained placement evidence. Neither a planned object nor a cached ref is a publication receipt. */
2
+ import { capturePlacementPublicationMetadata, type PlacementJournalRead, type PlacementPublicationStored } from "./sync-placement-journal-storage.ts";
3
+ import { rebuildPlacementProjection, type PlacementProjection } from "./sync-placement-projection.ts";
4
+ import { SyncPlacementJournalError } from "./sync-placement-journal-format.ts";
5
+ import { createHash } from "node:crypto";
6
+ import { lstatSync, opendirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { canonicalJSON, hashJSON } from "./generation.ts";
9
+ import { memoryGitEnv, memoryGitSync } from "./platform.ts";
10
+ import { placementCacheConfigStamp, placementCacheRootStamp, validatePlacementCacheConfig } from "./sync-placement-cache.ts";
11
+ import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS } from "./sync-screen.ts";
12
+ import { PUBLIC_PRUNE_REPLAY_MAX_BYTES } from "./public-prune-input-chain.ts";
13
+ import { PUBLIC_PRUNE_GRAPH_MAX_BYTES } from "./public-prune-inputs.ts";
14
+ import { ShardHistoryAuditor } from "./shard-sync-history.ts";
15
+ import { SHARD_PACK_MAX_BYTES, shardSyncRef, type ShardSyncRealm } from "./shard-sync-format.ts";
16
+ import type { PlacementRealm } from "./sync-placement-journal-format.ts";
17
+ import { placementKeyId } from "./sync-placement-journal-format.ts";
18
+ import type { ShardHistory } from "./shard-sync-history.ts";
19
+ import type { PlacementPublicationDecision, PlacementPublicationObject } from "./sync-placement-publication-plan.ts";
20
+
21
+ export interface InspectionJournal {
22
+ journal: PlacementJournalRead;
23
+ projection: PlacementProjection;
24
+ publications: ReadonlyMap<string, PlacementPublicationStored>;
25
+ assertCurrent(): void;
26
+ }
27
+
28
+ export function captureInspectionJournal(home: string, epochHash: string): InspectionJournal {
29
+ const captured = capturePlacementPublicationMetadata(home, epochHash, io => {
30
+ const journal = io.journal();
31
+ if (journal.pending) throw new SyncPlacementJournalError("pending");
32
+ const attempts = new Set<string>();
33
+ for (const name of io.names()) {
34
+ const match = /^publication-([a-f0-9]{64})-/.exec(name);
35
+ if (match) attempts.add(match[1]!);
36
+ }
37
+ const publications = new Map<string, PlacementPublicationStored>();
38
+ for (const attempt of [...attempts].sort()) {
39
+ const publication = io.publication(attempt);
40
+ if (publication.pending) throw new SyncPlacementJournalError("pending");
41
+ const frontier = (sequence: number) => sequence === 0 ? publication.bundle.root.epochHash : journal.records[sequence - 1]?.seal;
42
+ if (frontier(publication.bundle.root.journal.sequence) !== publication.bundle.root.journal.headHash) throw new SyncPlacementJournalError("corrupt");
43
+ const receipt = publication.facts.find(fact => fact.phase === "completed");
44
+ if (receipt?.phase === "completed" && frontier(receipt.journal.sequence) !== receipt.journal.headHash) throw new SyncPlacementJournalError("corrupt");
45
+ const root = publication.bundle.root, artifact = publication.facts.find(fact => fact.phase === "artifact-built");
46
+ if (artifact?.phase === "artifact-built" && artifact.exportHash !== hashJSON(root.targets.map(target => target.exportSetHash))) throw new SyncPlacementJournalError("corrupt");
47
+ if (receipt?.phase === "completed" && receipt.outcomeHash !== (receipt.outcome === "committed"
48
+ ? hashJSON({ attempt: root.seal, commits: root.targets.map(target => target.commit) }) : hashJSON({ attempt: root.seal, outcome: "unknown" }))) throw new SyncPlacementJournalError("corrupt");
49
+ publications.set(attempt, publication);
50
+ }
51
+ return { journal, projection: rebuildPlacementProjection(journal.epoch, journal.records), publications };
52
+ });
53
+ return { ...captured.value, assertCurrent: captured.assertCurrent };
54
+ }
55
+
56
+ export interface InspectionExposureProof { remoteHash: string; refHash: string; commit: string }
57
+ export interface InspectionTarget { realm: ShardSyncRealm; generation: string }
58
+ export interface RetainedInspectionHistories {
59
+ old: (ShardHistory | undefined)[];
60
+ tips: (string | undefined)[];
61
+ published: Map<string, InspectionExposureProof[]>;
62
+ }
63
+ const physicalRealm = (realm: ShardSyncRealm): PlacementRealm => realm.scope === "user" ? { scope: "user" } : { scope: "project", keyHash: hash(realm.key) };
64
+
65
+ /** Historical core acknowledgement plus exact immutable membership, never a current remote-head observation. */
66
+ export async function inspectRetainedPublicationHistories(snapshot: InspectionJournal, targets: readonly InspectionTarget[], objects?: PlacementInspectionObjects): Promise<RetainedInspectionHistories> {
67
+ const result: RetainedInspectionHistories = { old: targets.map(() => undefined), tips: targets.map(() => undefined), published: new Map() };
68
+ const selected = new Map(targets.map((target, index) => [canonicalJSON(physicalRealm(target.realm)), index]));
69
+ const publications = [...snapshot.publications.values()].sort((a, b) => a.bundle.root.journal.sequence - b.bundle.root.journal.sequence || a.bundle.root.seal.localeCompare(b.bundle.root.seal));
70
+ for (const publication of publications) {
71
+ const root = publication.bundle.root, decisions = publication.bundle.chunks.filter(chunk => chunk.kind === "decisions").flatMap(chunk => chunk.items as PlacementPublicationDecision[]);
72
+ const decisionByKey = new Map(decisions.map(decision => [placementKeyId(decision.key), decision]));
73
+ const receipt = publication.facts.find(fact => fact.phase === "completed"), artifact = publication.facts.find(fact => fact.phase === "artifact-built");
74
+ if (!objects || publication.phase !== "completed" || receipt?.phase !== "completed" || receipt.outcome !== "committed") continue;
75
+ if (artifact?.phase !== "artifact-built") unavailable();
76
+ for (const [targetIndex, planned] of root.targets.entries()) {
77
+ const index = selected.get(canonicalJSON(planned.realm)); if (index === undefined) continue;
78
+ const target = targets[index]!, tip = planned.commit ?? planned.expectedRemoteHead;
79
+ if (!tip) continue;
80
+ if (planned.refHash !== hash(shardSyncRef(target.realm))) unavailable();
81
+ const auditor = objects.auditor(target.realm, target.generation), history = await auditor.history(tip);
82
+ if (history.rows.size > SYNC_SCREEN_MAX_ROWS) bound();
83
+ const membership = [...history.rows.values()].map(row => ({ id: row.id, rowHash: hashJSON(row) })).sort((a, b) => a.id.localeCompare(b.id));
84
+ if (hashJSON(membership) !== planned.exportSetHash || hash(history.files.get("shard.json") ?? Buffer.alloc(0)) !== planned.descriptorHash
85
+ || planned.tree !== objects.tree(tip)) unavailable();
86
+ const retained = publication.bundle.chunks.filter(chunk => chunk.kind === "objects").flatMap(chunk => chunk.items as PlacementPublicationObject[]);
87
+ for (const object of retained) if (object.targetIndex === targetIndex) {
88
+ const body = objects.object(object.oid, object.type, object.type === "commit" ? 65536 : SHARD_PACK_MAX_BYTES);
89
+ if (body.length !== object.size || hash(body) !== object.sha256) unavailable();
90
+ }
91
+ const acknowledgedHistories = new Map<string, ShardHistory>([[tip, history]]);
92
+ for (const record of snapshot.journal.records) {
93
+ if (record.kind !== "publication-committed" || record.outcome.attemptHash !== root.seal
94
+ || record.sequence <= root.journal.sequence || record.sequence > root.journal.sequence + root.reservedRecords
95
+ || canonicalJSON(record.key.realm) !== canonicalJSON(planned.realm)) continue;
96
+ const id = placementKeyId(record.key), decision = decisionByKey.get(id), commit = record.outcome.commit;
97
+ if (!decision || record.sourcePlacement !== decision.sourcePlacement || record.outcome.remoteHash !== root.remoteHash || record.outcome.refHash !== planned.refHash
98
+ || !(commit === planned.expectedRemoteHead || decision.eligible && planned.publish && commit === planned.commit)
99
+ || record.requestId !== hashJSON({ attempt: root.seal, key: record.key, kind: record.kind, commit })
100
+ || record.effectHash !== hashJSON({ attempt: root.seal, context: root.contextHash, key: record.key, kind: record.kind, commit })
101
+ || record.outcome.proofHash !== hashJSON({ attempt: root.seal, key: record.key, commit, remote: root.remoteHash, ref: planned.refHash })) unavailable();
102
+ let atCommit = acknowledgedHistories.get(commit);
103
+ if (!atCommit) {
104
+ if (!await auditor.verifiedAncestor(commit, tip)) unavailable();
105
+ atCommit = await auditor.history(commit); acknowledgedHistories.set(commit, atCommit);
106
+ }
107
+ const row = atCommit.rows.get(record.key.id);
108
+ if (!row || hashJSON(row) !== record.key.rowHash) unavailable();
109
+ const proof = { remoteHash: root.remoteHash, refHash: planned.refHash, commit };
110
+ const prior = result.published.get(id) ?? [];
111
+ const previous = prior.findIndex(value => value.remoteHash === proof.remoteHash && value.refHash === proof.refHash);
112
+ if (previous < 0) prior.push(proof); else prior[previous] = proof;
113
+ result.published.set(id, prior);
114
+ }
115
+ // Only the retained selected origin supplies publication dependencies. A receipt from another remote
116
+ // is historical exposure, never a reason to hydrate its bodies into the current publication target.
117
+ if (root.remoteHash === objects.remoteHash) { result.old[index] = history; result.tips[index] = tip; }
118
+ }
119
+ }
120
+ for (const proofs of result.published.values()) proofs.sort((a, b) => canonicalJSON(a).localeCompare(canonicalJSON(b)));
121
+ objects?.assertCurrent(); snapshot.assertCurrent(); return result;
122
+ }
123
+
124
+ function unavailable(): never { throw new SyncPlacementJournalError("corrupt"); }
125
+ function bound(): never { throw new SyncPlacementJournalError("bound"); }
126
+ const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
127
+ const oid = (value: string) => /^[a-f0-9]{40}$/.test(value);
128
+ const forbidden = ["commondir", "shallow", "info/grafts", "objects/info/alternates", "objects/info/http-alternates", "config.worktree"];
129
+ function readCommand(args: readonly string[]): boolean {
130
+ const ref = (value: string | undefined) => !!value && /^refs\/heads\/amem\/(?:u|p\/[a-f0-9]{16})$/.test(value);
131
+ if (args[0] === "config") return args.join("\0") === "config\0--local\0--no-includes\0--null\0--list";
132
+ if (args[0] === "cat-file") return args.length === 3 && ["-t", "-s", "blob", "commit", "tree"].includes(args[1] ?? "") && oid(args[2] ?? "");
133
+ if (args[0] === "rev-list") return args.length === 5 && args.slice(0, 4).join("\0") === "rev-list\0--topo-order\0--reverse\0--parents" && oid(args[4] ?? "");
134
+ if (args[0] === "symbolic-ref") return args.length === 3 && args[1] === "--quiet" && ref(args[2]);
135
+ return args[0] === "rev-parse" && (args.length === 2 && ["--is-bare-repository", "--show-object-format"].includes(args[1] ?? "")
136
+ || args.length === 4 && args[1] === "--verify" && args[2] === "--quiet" && ref(args[3]));
137
+ }
138
+
139
+ /** Bind the complete private cache inventory, not a mutable ref as evidence of a remote acknowledgement.
140
+ * ponytail: diagnostic cache metadata shares the 20000-entry/8MiB ceiling and the existing 256MiB replay-disk
141
+ * ceiling. Larger inventories need a reviewed immutable object inventory; no entry is silently skipped. */
142
+ function cacheInventory(cache: string): string {
143
+ const pending = [cache], rows: unknown[] = []; let metadataBytes = 0, diskBytes = 0;
144
+ const record = (file: string) => {
145
+ if (rows.length >= SYNC_SCREEN_MAX_ROWS) return bound();
146
+ const stat = lstatSync(file, { bigint: true });
147
+ if (stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid?.() ?? -1) || (stat.mode & 0o077n) !== 0n
148
+ || !stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n) || file.endsWith(".promisor")) unavailable();
149
+ if (stat.isFile()) { diskBytes += Number(stat.size); if (diskBytes > PUBLIC_PRUNE_REPLAY_MAX_BYTES) bound(); }
150
+ const row = [file, stat.isDirectory() ? "directory" : "file", ...[stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs].map(String)];
151
+ metadataBytes += Buffer.byteLength(canonicalJSON(row)); if (metadataBytes > SYNC_SCREEN_MAX_BYTES) bound(); rows.push(row);
152
+ return stat.isDirectory();
153
+ };
154
+ record(cache);
155
+ while (pending.length) {
156
+ const reader = opendirSync(pending.pop()!);
157
+ try { for (;;) { const entry = reader.readSync(); if (!entry) break; const file = join(reader.path, entry.name); if (record(file)) pending.push(file); } }
158
+ finally { reader.closeSync(); }
159
+ }
160
+ return hash(canonicalJSON(rows.sort((a, b) => canonicalJSON(a).localeCompare(canonicalJSON(b)))));
161
+ }
162
+
163
+ /** Fixed local Git reads only. No initialization, fetch, remote probing, write command, credential or lazy fetch. */
164
+ export class PlacementInspectionObjects {
165
+ readonly cache: string;
166
+ readonly remoteHash: string;
167
+ private readonly home: string;
168
+ private readonly stamp: string;
169
+ private readonly rootStamp: string;
170
+ private readonly inventory: string;
171
+ private readonly check: () => void;
172
+ private bytes = 0;
173
+ private sourceRows = 0;
174
+ private sourceBytes = 0;
175
+ private readonly sourcePacks = new Set<string>();
176
+ private readonly objects = new Map<string, { type: string; bytes: Buffer }>();
177
+ constructor(home: string, check: () => void) {
178
+ this.home = home; this.cache = join(home, "shard-sync-placement", "objects.git"); this.check = check;
179
+ this.rootStamp = placementCacheRootStamp(home);
180
+ this.inventory = cacheInventory(this.cache); this.stamp = placementCacheConfigStamp(this.cache);
181
+ this.quick();
182
+ const config = validatePlacementCacheConfig(this.text(["config", "--local", "--no-includes", "--null", "--list"]));
183
+ const origin = config.get("remote.origin.url");
184
+ if (!origin || origin.length > 4096 || origin.startsWith("-") || /[\r\n\0]/.test(origin) || config.has("remote.origin.pushurl") && config.get("remote.origin.pushurl") !== origin) unavailable();
185
+ this.remoteHash = hash(origin);
186
+ if (this.text(["rev-parse", "--is-bare-repository"]) !== "true" || this.text(["rev-parse", "--show-object-format"]) !== "sha1") unavailable();
187
+ this.assertCurrent();
188
+ }
189
+ private quick(): void {
190
+ this.check(); this.cacheCheck();
191
+ }
192
+ private cacheCheck(): void {
193
+ if (placementCacheRootStamp(this.home) !== this.rootStamp) throw new SyncPlacementJournalError("changed");
194
+ if (placementCacheConfigStamp(this.cache) !== this.stamp) throw new SyncPlacementJournalError("changed");
195
+ for (const file of forbidden) if (lstatSync(join(this.cache, file), { throwIfNoEntry: false })) unavailable();
196
+ }
197
+ private run(args: string[], maximum: number): { code: number; bytes: Buffer } {
198
+ this.quick();
199
+ if (!readCommand(args)) unavailable();
200
+ const env = memoryGitEnv(process.env, { repositoryIsolation: true, readOnlyDiagnostic: true });
201
+ const result = memoryGitSync(["--no-replace-objects", "--git-dir", this.cache, "-c", "protocol.allow=never", "-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "core.commitGraph=false", ...args],
202
+ { encoding: "buffer", cwd: this.home, env, timeout: 10000, killSignal: "SIGKILL", maxBuffer: maximum });
203
+ this.quick();
204
+ if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOBUFS") bound(); unavailable(); }
205
+ return { code: result.status ?? -1, bytes: result.stdout };
206
+ }
207
+ private text(args: string[]): string {
208
+ const result = this.run(args, SYNC_SCREEN_MAX_BYTES); if (result.code !== 0) unavailable();
209
+ return new TextDecoder("utf-8", { fatal: true }).decode(result.bytes).trim();
210
+ }
211
+ ref(realm: ShardSyncRealm): string | undefined {
212
+ const ref = shardSyncRef(realm), symbolic = this.run(["symbolic-ref", "--quiet", ref], 65536);
213
+ if (symbolic.code !== 1) unavailable();
214
+ const result = this.run(["rev-parse", "--verify", "--quiet", ref], 65536), value = result.bytes.toString("utf8").trim();
215
+ if (result.code === 1) return undefined;
216
+ if (result.code !== 0 || !oid(value)) unavailable(); return value;
217
+ }
218
+ object(object: string, type: "commit" | "tree" | "blob", maximum: number): Buffer {
219
+ // ponytail: a diagnostic retains at most 20000 object identities within the existing 80MiB graph bound.
220
+ // Larger retained histories need a reviewed immutable digest inventory; physical row-pack limits stay separate.
221
+ if (!oid(object)) unavailable();
222
+ const prior = this.objects.get(object); if (prior) { if (prior.type !== type || prior.bytes.length > maximum) unavailable(); return Buffer.from(prior.bytes); }
223
+ if (this.objects.size >= SYNC_SCREEN_MAX_ROWS) bound();
224
+ if (this.text(["cat-file", "-t", object]) !== type) unavailable();
225
+ const size = Number(this.text(["cat-file", "-s", object]));
226
+ if (!Number.isSafeInteger(size) || size < 0 || size > maximum || this.bytes + size > PUBLIC_PRUNE_GRAPH_MAX_BYTES) bound();
227
+ const result = this.run(["cat-file", type, object], Math.max(1, maximum));
228
+ if (result.code !== 0 || result.bytes.length !== size || createHash("sha1").update(`${type} ${size}\0`).update(result.bytes).digest("hex") !== object) unavailable();
229
+ this.bytes += size; this.objects.set(object, { type, bytes: result.bytes }); return Buffer.from(result.bytes);
230
+ }
231
+ auditor(realm: ShardSyncRealm, generation: string): ShardHistoryAuditor {
232
+ // The byte cache is shared and bounded. Do not retain a full decoded body map for every historical receipt.
233
+ return new ShardHistoryAuditor({ realm, generation, placement: true, publicPrune: true, git: async args => this.text(args),
234
+ object: async (id, type) => this.object(id, type, type === "commit" ? 65536 : SHARD_PACK_MAX_BYTES),
235
+ blob: async (id, path) => {
236
+ const bytes = this.object(id, "blob", path === "shard.json" || path.startsWith("prune-witnesses/") ? 65536 : SHARD_PACK_MAX_BYTES);
237
+ const physical = canonicalJSON([realm, id]);
238
+ if (path.startsWith("rows/") && !this.sourcePacks.has(physical)) {
239
+ let lines = 0; for (const byte of bytes) if (byte === 10) lines++;
240
+ if (this.sourceRows + lines > SYNC_SCREEN_MAX_ROWS || this.sourceBytes + bytes.length > SYNC_SCREEN_MAX_BYTES) bound();
241
+ this.sourceRows += lines; this.sourceBytes += bytes.length; this.sourcePacks.add(physical);
242
+ }
243
+ return bytes;
244
+ } });
245
+ }
246
+ tree(commit: string): string {
247
+ const body = this.object(commit, "commit", 65536), first = body.subarray(0, body.indexOf(10)).toString("ascii");
248
+ if (!/^tree [a-f0-9]{40}$/.test(first)) unavailable(); return first.slice(5);
249
+ }
250
+ assertCurrent(): void {
251
+ this.check(); this.assertRetained();
252
+ }
253
+ /** Passive final proof: never invokes an attestation/policy callback after another input's final check. */
254
+ assertRetained(): void {
255
+ this.cacheCheck(); if (cacheInventory(this.cache) !== this.inventory) throw new SyncPlacementJournalError("changed");
256
+ }
257
+ }
@@ -0,0 +1,66 @@
1
+ /** Passive pending-prune namespace capture. It grants no recovery, writer or publication authority. */
2
+ import { lstatSync, opendirSync, type BigIntStats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { types } from "node:util";
5
+ import { canonicalJSON, hashJSON } from "./generation.ts";
6
+ import { realPath } from "./platform.ts";
7
+ import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS } from "./sync-screen.ts";
8
+ import { SyncPlacementJournalError, type SyncPlacementJournalErrorCode } from "./sync-placement-journal-format.ts";
9
+
10
+ function fail(code: SyncPlacementJournalErrorCode = "corrupt"): never { throw new SyncPlacementJournalError(code); }
11
+ function owned(stat: BigIntStats, directory: boolean): void {
12
+ if (stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid?.() ?? -1) || (stat.mode & 0o077n) !== 0n
13
+ || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)) fail();
14
+ }
15
+ const stamp = (stat: BigIntStats) => [stat.dev, stat.ino, stat.uid, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs].map(String);
16
+ function parentIdentity(target: string): string {
17
+ const stat = lstatSync(target, { bigint: true }); owned(stat, true);
18
+ // Derived indexes may change the target directory; its identity/ownership, rather than unrelated contents, is binding.
19
+ return canonicalJSON([stat.dev, stat.ino, stat.uid, stat.mode].map(String));
20
+ }
21
+ function snapshot(target: string): string {
22
+ const dir = join(target, "prune-plans"), before = lstatSync(dir, { bigint: true, throwIfNoEntry: false });
23
+ if (!before) return "absent";
24
+ owned(before, true);
25
+ const rows: string[][] = []; let count = 0, metadataBytes = Buffer.byteLength(canonicalJSON({ directory: stamp(before), entries: [] }));
26
+ const iterator = opendirSync(dir);
27
+ try {
28
+ for (;;) {
29
+ const entry = iterator.readSync(); if (!entry) break;
30
+ if (++count > SYNC_SCREEN_MAX_ROWS) fail("bound");
31
+ // Refuse incomplete/malformed claim names too. No content is read, decoded, completed or removed.
32
+ if (entry.name.startsWith("pending-local-") || entry.name.startsWith("pending-log-")) fail("pending");
33
+ const stat = lstatSync(join(dir, entry.name), { bigint: true }); owned(stat, stat.isDirectory());
34
+ const row = [entry.name, stat.isDirectory() ? "directory" : "file", ...stamp(stat)];
35
+ metadataBytes += Buffer.byteLength(canonicalJSON(row)) + (rows.length ? 1 : 0); if (metadataBytes > SYNC_SCREEN_MAX_BYTES) fail("bound");
36
+ rows.push(row);
37
+ }
38
+ } finally { iterator.closeSync(); }
39
+ const after = lstatSync(dir, { bigint: true }); owned(after, true);
40
+ if (canonicalJSON(stamp(before)) !== canonicalJSON(stamp(after))) fail("changed");
41
+ rows.sort((a, b) => a[0]! < b[0]! ? -1 : a[0]! > b[0]! ? 1 : 0);
42
+ return hashJSON({ directory: stamp(before), entries: rows });
43
+ }
44
+ function safeError(error: unknown, final: boolean): never {
45
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) {
46
+ const code = Object.getOwnPropertyDescriptor(error, "code");
47
+ if (code && "value" in code && ["bound", "pending", "changed", "corrupt"].includes(code.value))
48
+ return fail(final && code.value !== "bound" ? "changed" : code.value);
49
+ }
50
+ return fail(final ? "changed" : "corrupt");
51
+ }
52
+ /** Captures the selected target's namespace and returns a no-effect final recheck.
53
+ * ponytail: at most20000 direct metadata entries/8MiB of canonical name/stat metadata may be captured. Saved plan
54
+ * bodies are never read; a reviewed streaming namespace proof is required before this inventory ceiling can grow. */
55
+ export function captureInspectionPendingPrunes(targetDir: string): () => void {
56
+ try {
57
+ const original = parentIdentity(targetDir), target = realPath(targetDir);
58
+ if (parentIdentity(target) !== original) fail("changed");
59
+ const before = snapshot(target);
60
+ if (parentIdentity(target) !== original) fail("changed");
61
+ return () => {
62
+ try { if (parentIdentity(target) !== original || snapshot(target) !== before || parentIdentity(target) !== original) fail("changed"); }
63
+ catch (error) { safeError(error, true); }
64
+ };
65
+ } catch (error) { return safeError(error, false); }
66
+ }