@staix/agent-mem 0.20.2 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/package.json +2 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +2 -1
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +25 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +67 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +292 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +98 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +236 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +513 -0
- package/src/memory/sync-placement-journal.ts +153 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +489 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +20 -64
- package/src/setup.ts +69 -14
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** Body-free, deterministic publication evidence. This module grants no publication authority. */
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
5
|
+
import { validatePlacementKey, placementKeyId, SyncPlacementJournalError, PLACEMENT_JOURNAL_MAX_RECORDS, PLACEMENT_JOURNAL_MAX_OBJECTS,
|
|
6
|
+
PLACEMENT_JOURNAL_CHUNK_MAX_BYTES, PLACEMENT_JOURNAL_ROOT_MAX_BYTES, type PlacementKey, type PlacementRealm, type PlacementHoldReason } from "./sync-placement-journal-format.ts";
|
|
7
|
+
|
|
8
|
+
export const PLACEMENT_PUBLICATION_PLAN_SCHEMA = "agent-mem.sync-placement-publication-plan/v1";
|
|
9
|
+
export const PLACEMENT_PUBLICATION_CHUNK_SCHEMA = "agent-mem.sync-placement-publication-chunk/v1";
|
|
10
|
+
export const PLACEMENT_PUBLICATION_FACT_SCHEMA = "agent-mem.sync-placement-publication-fact/v1";
|
|
11
|
+
/** Closed hold/outcome events fit this bound; reserve future journal capacity before any publication effect. */
|
|
12
|
+
export const PLACEMENT_PUBLICATION_RECORD_MAX_BYTES = 2048;
|
|
13
|
+
export interface PlacementPublicationSource {
|
|
14
|
+
realm: PlacementRealm; source: "generation" | "legacy-local" | "legacy-main"; pathHash: string;
|
|
15
|
+
dev: string; ino: string; byteLength: number; hash: string; mtimeNs: string; ctimeNs: string;
|
|
16
|
+
}
|
|
17
|
+
export type PlacementPublicationDecisionReason = PlacementHoldReason | "journal-hold" | "source-local" | "unsupported-reference" | "unverified-prune" | null;
|
|
18
|
+
export interface PlacementPublicationDecision { key: PlacementKey; sourcePlacement: "local" | "synced"; eligible: boolean; hold: boolean; reason: PlacementPublicationDecisionReason; dependencies: PlacementKey[] }
|
|
19
|
+
export interface PlacementPublicationObject { targetIndex: number; type: "blob" | "tree" | "commit"; path: string | null; oid: string; sha256: string; size: number }
|
|
20
|
+
export interface PlacementPublicationTarget {
|
|
21
|
+
realm: PlacementRealm; publish: boolean; refHash: string; expectedRemoteHead: string | null; expectedCacheHead: string | null;
|
|
22
|
+
descriptorHash: string; exportSetHash: string; tree: string | null; commit: string | null;
|
|
23
|
+
}
|
|
24
|
+
export type PlacementPublicationChunkKind = "sources" | "decisions" | "objects";
|
|
25
|
+
export interface PlacementPublicationChunk { schema: typeof PLACEMENT_PUBLICATION_CHUNK_SCHEMA; kind: PlacementPublicationChunkKind; index: number; items: (PlacementPublicationSource | PlacementPublicationDecision | PlacementPublicationObject)[] }
|
|
26
|
+
export interface PlacementPublicationRoot {
|
|
27
|
+
schema: typeof PLACEMENT_PUBLICATION_PLAN_SCHEMA; epochHash: string; generation: string; homeHash: string; contextHash: string;
|
|
28
|
+
markerHash: string; sourceHash: string; aliasesHash: string; policyHash: string; remoteHash: string;
|
|
29
|
+
journal: { sequence: number; headHash: string }; reservedRecords: number; reservedRecordBytes: number[]; createdUnixSeconds: number; policySnapshotUnixMs: number;
|
|
30
|
+
targets: PlacementPublicationTarget[]; chunks: { kind: PlacementPublicationChunkKind; index: number; hash: string; count: number; bytes: number }[]; seal: string;
|
|
31
|
+
}
|
|
32
|
+
export interface PlacementPublicationBundle { root: PlacementPublicationRoot; chunks: PlacementPublicationChunk[] }
|
|
33
|
+
export type PlacementPublicationFact = {
|
|
34
|
+
schema: typeof PLACEMENT_PUBLICATION_FACT_SCHEMA; attemptHash: string; previousHash: string; seal: string;
|
|
35
|
+
} & ({ phase: "artifact-built"; exportHash: string } | { phase: "publication-intent"; artifactFactHash: string; policyHash: string; policyCheckUnixMs: number }
|
|
36
|
+
| { phase: "completed"; intentFactHash: string | null; journal: { sequence: number; headHash: string }; outcomeHash: string; outcome: "committed" | "unknown" });
|
|
37
|
+
export type PlacementPublicationPhase = PlacementPublicationFact["phase"];
|
|
38
|
+
const reasons: readonly PlacementPublicationDecisionReason[] = ["journal-hold", "source-local", "unsupported-reference", "unverified-prune", null, "secret", "personal-data", "deny-path", "private-or-invalid", "host", "person-local", "related-local"];
|
|
39
|
+
const kinds: readonly PlacementPublicationChunkKind[] = ["sources", "decisions", "objects"];
|
|
40
|
+
const fail = (code: "invalid" | "bound" | "corrupt" = "invalid"): never => { throw new SyncPlacementJournalError(code); };
|
|
41
|
+
export const publicationBytes = (value: unknown): Buffer => Buffer.from(canonicalJSON(value) + "\n");
|
|
42
|
+
export const publicationHash = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
|
|
43
|
+
function hex(value: unknown, size = 64): value is string { return typeof value === "string" && value.length === size && /^[a-f0-9]+$/.test(value); }
|
|
44
|
+
function number(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum; }
|
|
45
|
+
/** Descriptor-only shallow access keeps multi-chunk inputs bounded without the policy snapshot's 4096-node ceiling. */
|
|
46
|
+
function object(input: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
47
|
+
if (!input || typeof input !== "object" || types.isProxy(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length) return fail();
|
|
48
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
49
|
+
if (Object.keys(descriptors).length !== keys.length) return fail();
|
|
50
|
+
const result: Record<string, unknown> = Object.create(null);
|
|
51
|
+
for (const key of keys) { const d = descriptors[key]; if (!d || !("value" in d) || !d.enumerable) return fail(); result[key] = d.value; }
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
function array(input: unknown, maximum: number): unknown[] {
|
|
55
|
+
if (!input || typeof input !== "object" || types.isProxy(input) || !Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype || Object.getOwnPropertySymbols(input).length) return fail();
|
|
56
|
+
const length = Object.getOwnPropertyDescriptor(input, "length")?.value;
|
|
57
|
+
if (!number(length, maximum)) return fail("bound");
|
|
58
|
+
if (Object.getOwnPropertyNames(input).length !== length + 1) return fail();
|
|
59
|
+
const result: unknown[] = [];
|
|
60
|
+
for (let i = 0; i < length; i++) { const d = Object.getOwnPropertyDescriptor(input, String(i)); if (!d || !("value" in d) || !d.enumerable) return fail(); result.push(d.value); }
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
function realm(value: unknown): PlacementRealm { return validatePlacementKey({ realm: value, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm; }
|
|
64
|
+
function frontier(value: unknown) { const v = object(value, ["sequence", "headHash"]); if (!number(v.sequence, PLACEMENT_JOURNAL_MAX_RECORDS) || !hex(v.headHash)) return fail(); return { sequence: v.sequence, headHash: v.headHash }; }
|
|
65
|
+
function bounded<T>(value: T, maximum: number): T { if (publicationBytes(value).length > maximum) return fail("bound"); return value; }
|
|
66
|
+
function item(kind: PlacementPublicationChunkKind, input: unknown): PlacementPublicationSource | PlacementPublicationDecision | PlacementPublicationObject {
|
|
67
|
+
if (kind === "sources") {
|
|
68
|
+
const v = object(input, ["realm", "source", "pathHash", "dev", "ino", "byteLength", "hash", "mtimeNs", "ctimeNs"]);
|
|
69
|
+
if (!["generation", "legacy-local", "legacy-main"].includes(v.source as string) || !hex(v.pathHash) || !hex(v.hash) || !number(v.byteLength, 8 * 1024 * 1024)
|
|
70
|
+
|| ![v.dev, v.ino].every(x => typeof x === "string" && !/[\r\n]/.test(x) && /^(?:0|[1-9][0-9]{0,31})$/.test(x))
|
|
71
|
+
|| ![v.mtimeNs, v.ctimeNs].every(x => typeof x === "string" && !/[\r\n]/.test(x) && /^-?(?:0|[1-9][0-9]{0,31})$/.test(x))) return fail();
|
|
72
|
+
return { realm: realm(v.realm), source: v.source as PlacementPublicationSource["source"], pathHash: v.pathHash, dev: v.dev as string, ino: v.ino as string, byteLength: v.byteLength, hash: v.hash, mtimeNs: v.mtimeNs as string, ctimeNs: v.ctimeNs as string };
|
|
73
|
+
}
|
|
74
|
+
if (kind === "decisions") {
|
|
75
|
+
const v = object(input, ["key", "sourcePlacement", "eligible", "hold", "reason", "dependencies"]);
|
|
76
|
+
if (!["local", "synced"].includes(v.sourcePlacement as string) || typeof v.eligible !== "boolean" || typeof v.hold !== "boolean" || v.eligible && v.hold || !reasons.includes(v.reason as PlacementPublicationDecisionReason) || v.eligible !== (v.reason === null)) return fail();
|
|
77
|
+
const dependencies = array(v.dependencies, PLACEMENT_JOURNAL_MAX_RECORDS).map(validatePlacementKey);
|
|
78
|
+
if (new Set(dependencies.map(placementKeyId)).size !== dependencies.length) return fail();
|
|
79
|
+
return bounded({ key: validatePlacementKey(v.key), sourcePlacement: v.sourcePlacement as "local" | "synced", eligible: v.eligible, hold: v.hold as boolean, reason: v.reason as PlacementPublicationDecisionReason, dependencies }, PLACEMENT_JOURNAL_CHUNK_MAX_BYTES - 256);
|
|
80
|
+
}
|
|
81
|
+
const v = object(input, ["targetIndex", "type", "path", "oid", "sha256", "size"]);
|
|
82
|
+
const path = typeof v.path === "string" && !/[\r\n]/.test(v.path) ? v.path : undefined;
|
|
83
|
+
const pack = path !== undefined && /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.test(path);
|
|
84
|
+
const manifest = path !== undefined && /^prune-witnesses\/[a-f0-9]{64}\.json$/.test(path);
|
|
85
|
+
const blobPath = path === "shard.json" || pack || manifest;
|
|
86
|
+
const treePath = v.path === null || path === "rows" || path === "prune-witnesses" || path !== undefined && /^rows\/[a-f0-9]{64}$/.test(path);
|
|
87
|
+
const maximum = v.type === "commit" || v.type === "blob" && !pack ? 65536 : 1048576;
|
|
88
|
+
if (!number(v.targetIndex, 1) || !["blob", "tree", "commit"].includes(v.type as string) || !hex(v.oid, 40) || !hex(v.sha256)
|
|
89
|
+
|| v.type === "commit" && v.path !== null || v.type === "blob" && !blobPath || v.type === "tree" && !treePath) return fail();
|
|
90
|
+
if(!number(v.size,maximum))return fail("bound");
|
|
91
|
+
if(v.type!=="tree"&&v.size===0)return fail();
|
|
92
|
+
return { targetIndex: v.targetIndex, type: v.type as PlacementPublicationObject["type"], path: v.path as string | null, oid: v.oid, sha256: v.sha256, size: v.size };
|
|
93
|
+
}
|
|
94
|
+
export function validatePlacementPublicationChunk(input: unknown): PlacementPublicationChunk {
|
|
95
|
+
const v = object(input, ["schema", "kind", "index", "items"]);
|
|
96
|
+
if (v.schema !== PLACEMENT_PUBLICATION_CHUNK_SCHEMA || !kinds.includes(v.kind as PlacementPublicationChunkKind) || !number(v.index, PLACEMENT_JOURNAL_MAX_OBJECTS - 1)) return fail();
|
|
97
|
+
const items = array(v.items, PLACEMENT_JOURNAL_MAX_RECORDS).map(x => item(v.kind as PlacementPublicationChunkKind, x)); if (!items.length) return fail();
|
|
98
|
+
return bounded({ schema: PLACEMENT_PUBLICATION_CHUNK_SCHEMA, kind: v.kind as PlacementPublicationChunkKind, index: v.index, items }, PLACEMENT_JOURNAL_CHUNK_MAX_BYTES);
|
|
99
|
+
}
|
|
100
|
+
function normalizedRoot(input: unknown, checkSeal = true): PlacementPublicationRoot {
|
|
101
|
+
const keys = ["schema", "epochHash", "generation", "homeHash", "contextHash", "markerHash", "sourceHash", "aliasesHash", "policyHash", "remoteHash", "journal", "reservedRecords", "reservedRecordBytes", "createdUnixSeconds", "policySnapshotUnixMs", "targets", "chunks", "seal"];
|
|
102
|
+
const v = object(input, keys);
|
|
103
|
+
if (v.schema !== PLACEMENT_PUBLICATION_PLAN_SCHEMA || !hex(v.generation, 32) || ![v.epochHash, v.homeHash, v.contextHash, v.markerHash, v.sourceHash, v.aliasesHash, v.policyHash, v.remoteHash, v.seal].every(x => hex(x))
|
|
104
|
+
|| !number(v.reservedRecords, PLACEMENT_JOURNAL_MAX_RECORDS) || !number(v.createdUnixSeconds) || !number(v.policySnapshotUnixMs)) return fail();
|
|
105
|
+
const reservedRecordBytes = array(v.reservedRecordBytes, PLACEMENT_JOURNAL_MAX_RECORDS);
|
|
106
|
+
if (reservedRecordBytes.length !== v.reservedRecords || reservedRecordBytes.some(x => !number(x, PLACEMENT_PUBLICATION_RECORD_MAX_BYTES) || x < 1)) return fail();
|
|
107
|
+
const journal = frontier(v.journal); if (journal.sequence + v.reservedRecords > PLACEMENT_JOURNAL_MAX_RECORDS) return fail("bound");
|
|
108
|
+
const targets = array(v.targets, 2).map(x => {
|
|
109
|
+
const t = object(x, ["realm", "publish", "refHash", "expectedRemoteHead", "expectedCacheHead", "descriptorHash", "exportSetHash", "tree", "commit"]);
|
|
110
|
+
if (typeof t.publish !== "boolean" || ![t.refHash, t.descriptorHash, t.exportSetHash].every(x => hex(x))
|
|
111
|
+
|| [t.expectedRemoteHead, t.expectedCacheHead].some(x => x !== null && !hex(x, 40))) return fail();
|
|
112
|
+
if (t.publish ? !hex(t.tree, 40) || !hex(t.commit, 40)
|
|
113
|
+
: t.expectedRemoteHead === null ? t.tree !== null || t.commit !== null || t.expectedCacheHead !== null
|
|
114
|
+
: !hex(t.tree, 40) || t.commit !== t.expectedRemoteHead) return fail();
|
|
115
|
+
return { realm: realm(t.realm), publish: t.publish, refHash: t.refHash as string, expectedRemoteHead: t.expectedRemoteHead as string | null, expectedCacheHead: t.expectedCacheHead as string | null, descriptorHash: t.descriptorHash as string, exportSetHash: t.exportSetHash as string, tree: t.tree as string | null, commit: t.commit as string | null };
|
|
116
|
+
});
|
|
117
|
+
if (!targets.length || new Set(targets.map(t => canonicalJSON(t.realm))).size !== targets.length || new Set(targets.map(t => t.refHash)).size !== targets.length) return fail();
|
|
118
|
+
let lastKind = 0;
|
|
119
|
+
const chunks = array(v.chunks, PLACEMENT_JOURNAL_MAX_OBJECTS - 1).map((x, index) => {
|
|
120
|
+
const c = object(x, ["kind", "index", "hash", "count", "bytes"]), kindIndex = kinds.indexOf(c.kind as PlacementPublicationChunkKind);
|
|
121
|
+
if (kindIndex < lastKind || kindIndex < 0 || c.index !== index || !hex(c.hash) || !number(c.count, PLACEMENT_JOURNAL_MAX_RECORDS) || c.count < 1 || !number(c.bytes, PLACEMENT_JOURNAL_CHUNK_MAX_BYTES) || c.bytes < 1) return fail();
|
|
122
|
+
lastKind = kindIndex; return { kind: c.kind as PlacementPublicationChunkKind, index, hash: c.hash, count: c.count, bytes: c.bytes };
|
|
123
|
+
});
|
|
124
|
+
const normalized = { ...v, journal, targets, chunks, reservedRecordBytes } as unknown as PlacementPublicationRoot;
|
|
125
|
+
const { seal, ...body } = normalized; if (checkSeal && hashJSON(body) !== seal) return fail("corrupt");
|
|
126
|
+
return bounded(normalized, PLACEMENT_JOURNAL_ROOT_MAX_BYTES);
|
|
127
|
+
}
|
|
128
|
+
export function validatePlacementPublicationRoot(input: unknown): PlacementPublicationRoot { return normalizedRoot(input); }
|
|
129
|
+
export function validatePlacementPublicationBundle(input: unknown): PlacementPublicationBundle {
|
|
130
|
+
const v = object(input, ["root", "chunks"]), root = validatePlacementPublicationRoot(v.root), chunks = array(v.chunks, PLACEMENT_JOURNAL_MAX_OBJECTS - 1).map(validatePlacementPublicationChunk);
|
|
131
|
+
if (chunks.length !== root.chunks.length) return fail("corrupt");
|
|
132
|
+
let sourceBytes = 0; const totals = { sources: 0, decisions: 0, objects: 0 }, seen = new Set<string>();
|
|
133
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
134
|
+
const expected = root.chunks[index]!, bytes = publicationBytes(chunk);
|
|
135
|
+
if (chunk.index !== index || chunk.kind !== expected.kind || chunk.items.length !== expected.count || bytes.length !== expected.bytes || publicationHash(bytes) !== expected.hash) return fail("corrupt");
|
|
136
|
+
totals[chunk.kind] += chunk.items.length; if (totals[chunk.kind] > PLACEMENT_JOURNAL_MAX_RECORDS) return fail("bound");
|
|
137
|
+
for (const row of chunk.items) {
|
|
138
|
+
let identity: string;
|
|
139
|
+
if (chunk.kind === "sources") { const s = row as PlacementPublicationSource; sourceBytes += s.byteLength; identity = canonicalJSON([s.realm, s.source, s.pathHash]); }
|
|
140
|
+
else if (chunk.kind === "decisions") { const d = row as PlacementPublicationDecision; identity = canonicalJSON([d.key.realm, d.key.id]); }
|
|
141
|
+
else { const o = row as PlacementPublicationObject; if (o.targetIndex >= root.targets.length || !root.targets[o.targetIndex]!.publish) return fail(); identity = canonicalJSON([o.targetIndex, o.type, o.path]); }
|
|
142
|
+
const key = chunk.kind + identity; if (seen.has(key)) return fail(); seen.add(key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (sourceBytes > 8 * 1024 * 1024) return fail("bound");
|
|
146
|
+
return { root, chunks };
|
|
147
|
+
}
|
|
148
|
+
export function planPlacementPublication(input: Omit<PlacementPublicationRoot, "schema" | "chunks" | "seal">, items: {
|
|
149
|
+
sources: readonly PlacementPublicationSource[]; decisions: readonly PlacementPublicationDecision[]; objects: readonly PlacementPublicationObject[];
|
|
150
|
+
}): PlacementPublicationBundle {
|
|
151
|
+
const values = object(items, kinds), chunks: PlacementPublicationChunk[] = [];
|
|
152
|
+
for (const kind of kinds) {
|
|
153
|
+
let selected: PlacementPublicationChunk["items"] = [];
|
|
154
|
+
const flush = () => { if (selected.length) { chunks.push(validatePlacementPublicationChunk({ schema: PLACEMENT_PUBLICATION_CHUNK_SCHEMA, kind, index: chunks.length, items: selected })); selected = []; } };
|
|
155
|
+
for (const value of array(values[kind], PLACEMENT_JOURNAL_MAX_RECORDS)) {
|
|
156
|
+
const row = item(kind, value), candidate = { schema: PLACEMENT_PUBLICATION_CHUNK_SCHEMA, kind, index: chunks.length, items: [...selected, row] };
|
|
157
|
+
if (publicationBytes(candidate).length > PLACEMENT_JOURNAL_CHUNK_MAX_BYTES) flush(); selected.push(row);
|
|
158
|
+
}
|
|
159
|
+
flush();
|
|
160
|
+
}
|
|
161
|
+
const body = { ...object(input, ["epochHash", "generation", "homeHash", "contextHash", "markerHash", "sourceHash", "aliasesHash", "policyHash", "remoteHash", "journal", "reservedRecords", "reservedRecordBytes", "createdUnixSeconds", "policySnapshotUnixMs", "targets"]), schema: PLACEMENT_PUBLICATION_PLAN_SCHEMA,
|
|
162
|
+
chunks: chunks.map(c => ({ kind: c.kind, index: c.index, hash: publicationHash(publicationBytes(c)), count: c.items.length, bytes: publicationBytes(c).length })) };
|
|
163
|
+
const { seal: _seal, ...safeBody } = normalizedRoot({ ...body, seal: "0".repeat(64) }, false);
|
|
164
|
+
return validatePlacementPublicationBundle({ root: { ...safeBody, seal: hashJSON(safeBody) }, chunks });
|
|
165
|
+
}
|
|
166
|
+
export function createPlacementPublicationFact(attemptHash: string, previousHash: string, phase: PlacementPublicationPhase, fact: unknown): PlacementPublicationFact {
|
|
167
|
+
const keys = phase === "artifact-built" ? ["exportHash"] : phase === "publication-intent" ? ["artifactFactHash", "policyHash", "policyCheckUnixMs"] : ["intentFactHash", "journal", "outcomeHash", "outcome"];
|
|
168
|
+
const body = { ...object(fact, keys), schema: PLACEMENT_PUBLICATION_FACT_SCHEMA, attemptHash, previousHash, phase };
|
|
169
|
+
const { seal: _seal, ...safeBody } = normalizedFact({ ...body, seal: "0".repeat(64) }, false);
|
|
170
|
+
return validatePlacementPublicationFact({ ...safeBody, seal: hashJSON(safeBody) });
|
|
171
|
+
}
|
|
172
|
+
function normalizedFact(input: unknown, checkSeal = true): PlacementPublicationFact {
|
|
173
|
+
// Read phase through descriptors before choosing the exact closed field set.
|
|
174
|
+
if (!input || typeof input !== "object" || types.isProxy(input)) return fail();
|
|
175
|
+
const phase = Object.getOwnPropertyDescriptor(input, "phase")?.value;
|
|
176
|
+
const extra = phase === "artifact-built" ? ["exportHash"] : phase === "publication-intent" ? ["artifactFactHash", "policyHash", "policyCheckUnixMs"] : phase === "completed" ? ["intentFactHash", "journal", "outcomeHash", "outcome"] : fail();
|
|
177
|
+
const v = object(input, ["schema", "attemptHash", "previousHash", "phase", "seal", ...extra]);
|
|
178
|
+
if (v.schema !== PLACEMENT_PUBLICATION_FACT_SCHEMA || ![v.attemptHash, v.previousHash, v.seal].every(x => hex(x))) return fail();
|
|
179
|
+
if (phase === "artifact-built" && !hex(v.exportHash) || phase === "publication-intent" && (!hex(v.artifactFactHash) || !hex(v.policyHash) || !number(v.policyCheckUnixMs))
|
|
180
|
+
|| phase === "completed" && (v.intentFactHash !== null && !hex(v.intentFactHash) || !hex(v.outcomeHash) || !["committed", "unknown"].includes(v.outcome as string) || v.outcome === "committed" && v.intentFactHash === null)) return fail();
|
|
181
|
+
if (phase === "completed") v.journal = frontier(v.journal);
|
|
182
|
+
const { seal, ...body } = v; if (checkSeal && hashJSON(body) !== seal) return fail("corrupt");
|
|
183
|
+
return bounded(v as unknown as PlacementPublicationFact, PLACEMENT_JOURNAL_ROOT_MAX_BYTES);
|
|
184
|
+
}
|
|
185
|
+
export function validatePlacementPublicationFact(input: unknown): PlacementPublicationFact { return normalizedFact(input); }
|
|
186
|
+
export function publicationFileNames(root: PlacementPublicationRoot): string[] {
|
|
187
|
+
const stem = `publication-${root.seal}-`;
|
|
188
|
+
return [`${stem}root.json`, ...root.chunks.map(c => `${stem}chunk-${String(c.index).padStart(5, "0")}.json`), `${stem}artifact.json`, `${stem}intent.json`, `${stem}receipt.json`];
|
|
189
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Private publication durability only. Callers verify policy, object bytes, remote CAS and row acknowledgements. */
|
|
2
|
+
import { canonicalJSON } from "./generation.ts";
|
|
3
|
+
import { SyncPlacementJournalError } from "./sync-placement-journal-format.ts";
|
|
4
|
+
import { withPlacementPublicationMetadata, readPlacementPublicationMetadata, type PlacementStorageOptions, type PlacementPublicationStored } from "./sync-placement-journal-storage.ts";
|
|
5
|
+
import { validatePlacementPublicationBundle, createPlacementPublicationFact, publicationFileNames, publicationBytes,
|
|
6
|
+
type PlacementPublicationBundle, type PlacementPublicationPhase, type PlacementPublicationFact } from "./sync-placement-publication-plan.ts";
|
|
7
|
+
|
|
8
|
+
function fail(code: "invalid" | "pending" | "conflict" | "changed" = "invalid"): never { throw new SyncPlacementJournalError(code); }
|
|
9
|
+
const stem = (attempt: string) => `publication-${attempt}-`;
|
|
10
|
+
const validHash = (value: string) => typeof value === "string" && value.length === 64 && /^[a-f0-9]+$/.test(value);
|
|
11
|
+
/** No timestamp expiry or eviction: retained plans remain charged to the epoch's shared 256-object inventory. */
|
|
12
|
+
export function preparePlacementPublication(home: string, input: PlacementPublicationBundle, opts: PlacementStorageOptions): { attemptHash: string } {
|
|
13
|
+
const bundle = validatePlacementPublicationBundle(input), root = bundle.root, names = publicationFileNames(root);
|
|
14
|
+
const required = readPlacementPublicationMetadata(home, root.epochHash, io => [...names, ...io.reservedJournalNames(root)]);
|
|
15
|
+
return withPlacementPublicationMetadata(home, root.epochHash, opts, required, io => {
|
|
16
|
+
const journal = io.journal();
|
|
17
|
+
if (journal.pending || journal.epoch.generation !== root.generation || journal.epoch.homeHash !== root.homeHash
|
|
18
|
+
|| journal.sequence !== root.journal.sequence || journal.headHash !== root.journal.headHash) fail("changed");
|
|
19
|
+
const own = stem(root.seal);
|
|
20
|
+
for (const name of io.names()) if (name.startsWith("publication-") && !name.startsWith(own)) {
|
|
21
|
+
const match = /^publication-([a-f0-9]{64})-/.exec(name); if (!match) fail("pending");
|
|
22
|
+
const other = io.publication(match[1]!); if (other.phase !== "completed" || other.pending) fail("pending");
|
|
23
|
+
}
|
|
24
|
+
for (const name of io.names()) if (name.startsWith(own) && !names.includes(name)) fail("conflict");
|
|
25
|
+
// Reserve every future phase file physically. A later journal append cannot consume these object slots.
|
|
26
|
+
for (const suffix of ["artifact", "intent", "receipt"]) {
|
|
27
|
+
const name = `${own}${suffix}.json`;
|
|
28
|
+
if (io.read(name) === undefined) io.complete(name, Buffer.alloc(0), "publication-slot-written", "publication-slot-fsync");
|
|
29
|
+
}
|
|
30
|
+
for (const chunk of bundle.chunks) io.complete(`${own}chunk-${String(chunk.index).padStart(5, "0")}.json`, publicationBytes(chunk), "publication-chunk-written", "publication-chunk-fsync");
|
|
31
|
+
// The root is the final durability acknowledgement: all chunks and all reserved slots precede its fsync.
|
|
32
|
+
io.complete(`${own}root.json`, publicationBytes(root), "publication-root-written", "publication-root-fsync");
|
|
33
|
+
io.check(); const done = io.publication(root.seal);
|
|
34
|
+
if (done.pending || canonicalJSON(done.bundle) !== canonicalJSON(bundle)) fail("changed");
|
|
35
|
+
return { attemptHash: root.seal };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export interface PlacementPublicationRead extends PlacementPublicationStored {
|
|
39
|
+
reservedRecords: number; consumedRecords: number;
|
|
40
|
+
}
|
|
41
|
+
export function readPlacementPublication(home: string, epochHash: string, attemptHash: string): PlacementPublicationRead {
|
|
42
|
+
if (!validHash(attemptHash)) fail();
|
|
43
|
+
return readPlacementPublicationMetadata(home, epochHash, io => {
|
|
44
|
+
const read = io.publication(attemptHash), journal = io.journal(), root = read.bundle.root;
|
|
45
|
+
if (journal.epoch.generation !== root.generation || journal.epoch.homeHash !== root.homeHash) fail("changed");
|
|
46
|
+
const consumedRecords = journal.sequence - root.journal.sequence;
|
|
47
|
+
if (consumedRecords < 0 || consumedRecords > root.reservedRecords && read.phase !== "completed") fail("conflict");
|
|
48
|
+
return { ...read, reservedRecords: root.reservedRecords, consumedRecords };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export type PlacementPublicationFactInput = { exportHash: string }
|
|
52
|
+
| { artifactFactHash: string; policyHash: string; policyCheckUnixMs: number }
|
|
53
|
+
| { intentFactHash: string | null; journal: { sequence: number; headHash: string }; outcomeHash: string; outcome: "committed" | "unknown" };
|
|
54
|
+
export function recordPlacementPublicationPhase(home: string, epochHash: string, attemptHash: string, phase: PlacementPublicationPhase,
|
|
55
|
+
input: PlacementPublicationFactInput, opts: PlacementStorageOptions): void {
|
|
56
|
+
if (!validHash(attemptHash)) fail();
|
|
57
|
+
const read = readPlacementPublication(home, epochHash, attemptHash), names = publicationFileNames(read.bundle.root);
|
|
58
|
+
const index = ["artifact-built", "publication-intent", "completed"].indexOf(phase); if (index < 0) fail();
|
|
59
|
+
const existing = read.facts.find(fact => fact.phase === phase);
|
|
60
|
+
const prior = phase === "completed" ? read.facts.filter(fact => fact.phase !== "completed").at(-1)
|
|
61
|
+
: phase === "publication-intent" ? read.facts.find(fact => fact.phase === "artifact-built") : undefined;
|
|
62
|
+
const previous = existing?.previousHash ?? prior?.seal ?? (phase === "publication-intent" ? undefined : read.bundle.root.seal);
|
|
63
|
+
if (!previous) fail("pending");
|
|
64
|
+
const fact = createPlacementPublicationFact(attemptHash, previous, phase, input);
|
|
65
|
+
const file = `${stem(attemptHash)}${["artifact", "intent", "receipt"][index]}.json`;
|
|
66
|
+
withPlacementPublicationMetadata(home, epochHash, opts, names, io => {
|
|
67
|
+
const before = io.publication(attemptHash), journal = io.journal();
|
|
68
|
+
const saved = before.facts.find(value => value.phase === phase);
|
|
69
|
+
if (before.pending && before.pendingPhase !== phase) fail("pending");
|
|
70
|
+
if (before.phase === "completed") {
|
|
71
|
+
if (!saved || canonicalJSON(saved) !== canonicalJSON(fact)) fail("conflict");
|
|
72
|
+
io.complete(file, publicationBytes(fact), "publication-fact-written", "publication-fact-fsync"); io.check(); return;
|
|
73
|
+
}
|
|
74
|
+
const durablePrior = phase === "completed" ? before.facts.at(-1)
|
|
75
|
+
: phase === "publication-intent" ? before.facts.find(value => value.phase === "artifact-built") : undefined;
|
|
76
|
+
if ((saved?.previousHash ?? durablePrior?.seal ?? read.bundle.root.seal) !== previous) fail("changed");
|
|
77
|
+
if (phase === "artifact-built" && before.phase !== "prepared" && !saved || phase === "publication-intent" && !durablePrior) fail("pending");
|
|
78
|
+
if (fact.phase === "publication-intent" && (fact.artifactFactHash !== previous || fact.policyHash !== read.bundle.root.policyHash || fact.policyCheckUnixMs !== read.bundle.root.policySnapshotUnixMs)) fail("conflict");
|
|
79
|
+
if (fact.phase === "completed") {
|
|
80
|
+
const intent = before.facts.find(value => value.phase === "publication-intent");
|
|
81
|
+
if (journal.pending || fact.intentFactHash !== (intent?.seal ?? null) || fact.journal.sequence !== journal.sequence || fact.journal.headHash !== journal.headHash
|
|
82
|
+
|| fact.outcome === "committed" && (!intent || journal.sequence !== read.bundle.root.journal.sequence + read.bundle.root.reservedRecords)) fail("conflict");
|
|
83
|
+
}
|
|
84
|
+
io.complete(file, publicationBytes(fact), "publication-fact-written", "publication-fact-fsync");
|
|
85
|
+
io.check(); const done = io.publication(attemptHash); if (canonicalJSON(done.facts.find(value => value.phase === phase)) !== canonicalJSON(fact)) fail("changed");
|
|
86
|
+
});
|
|
87
|
+
}
|