@loomwork/plugin-content 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +92 -2
  4. package/dist/CachingContentStore.d.ts +54 -0
  5. package/dist/CachingContentStore.d.ts.map +1 -0
  6. package/dist/CachingContentStore.js +171 -0
  7. package/dist/CachingContentStore.js.map +1 -0
  8. package/dist/ContentResolvingEventStore.d.ts +70 -0
  9. package/dist/ContentResolvingEventStore.d.ts.map +1 -0
  10. package/dist/ContentResolvingEventStore.js +245 -0
  11. package/dist/ContentResolvingEventStore.js.map +1 -0
  12. package/dist/ContentStore.d.ts +186 -0
  13. package/dist/ContentStore.d.ts.map +1 -0
  14. package/dist/ContentStore.js +177 -0
  15. package/dist/ContentStore.js.map +1 -0
  16. package/dist/ContentTouchingSnapshotStore.d.ts +56 -0
  17. package/dist/ContentTouchingSnapshotStore.d.ts.map +1 -0
  18. package/dist/ContentTouchingSnapshotStore.js +85 -0
  19. package/dist/ContentTouchingSnapshotStore.js.map +1 -0
  20. package/dist/citations.d.ts +40 -0
  21. package/dist/citations.d.ts.map +1 -0
  22. package/dist/citations.js +118 -0
  23. package/dist/citations.js.map +1 -0
  24. package/dist/content.bench.d.ts +2 -0
  25. package/dist/content.bench.d.ts.map +1 -0
  26. package/dist/content.bench.js +75 -0
  27. package/dist/content.bench.js.map +1 -0
  28. package/dist/content.d.ts +40 -0
  29. package/dist/content.d.ts.map +1 -0
  30. package/dist/content.js +177 -0
  31. package/dist/content.js.map +1 -0
  32. package/dist/derive.d.ts +9 -0
  33. package/dist/derive.d.ts.map +1 -0
  34. package/dist/derive.js +40 -0
  35. package/dist/derive.js.map +1 -0
  36. package/dist/errors.d.ts +6 -0
  37. package/dist/errors.d.ts.map +1 -0
  38. package/dist/errors.js +20 -0
  39. package/dist/errors.js.map +1 -0
  40. package/dist/executionEnd.d.ts +22 -0
  41. package/dist/executionEnd.d.ts.map +1 -0
  42. package/dist/executionEnd.js +66 -0
  43. package/dist/executionEnd.js.map +1 -0
  44. package/dist/gc.d.ts +470 -0
  45. package/dist/gc.d.ts.map +1 -0
  46. package/dist/gc.js +819 -0
  47. package/dist/gc.js.map +1 -0
  48. package/dist/index.d.ts +10 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +42 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/touchAround.d.ts +46 -0
  53. package/dist/touchAround.d.ts.map +1 -0
  54. package/dist/touchAround.js +98 -0
  55. package/dist/touchAround.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/CachingContentStore.ts +184 -0
  58. package/src/ContentResolvingEventStore.ts +325 -0
  59. package/src/ContentStore.ts +317 -0
  60. package/src/ContentTouchingSnapshotStore.ts +109 -0
  61. package/src/citations.ts +117 -0
  62. package/src/content.bench.ts +84 -0
  63. package/src/content.ts +191 -0
  64. package/src/derive.ts +40 -0
  65. package/src/errors.ts +19 -0
  66. package/src/executionEnd.ts +64 -0
  67. package/src/gc.ts +1213 -0
  68. package/src/index.ts +54 -0
  69. package/src/touchAround.ts +131 -0
@@ -0,0 +1,109 @@
1
+ import { type DecoratorPlan, installOptional } from "@loomwork/event-runtime";
2
+ import type {
3
+ OptionalSnapshotStoreMethod,
4
+ Snapshot,
5
+ SnapshotStore,
6
+ } from "@loomwork/snapshot-runtime";
7
+ import type { ContentStore } from "./ContentStore";
8
+ import { type TouchErrorHook, touchAround } from "./touchAround";
9
+
10
+ /**
11
+ * The brand a {@link ContentTouchingSnapshotStore} carries (a registered symbol, so a second copy of
12
+ * this package still recognises it). Read it through {@link isContentTouchingSnapshotStore}.
13
+ */
14
+ export const CONTENT_TOUCHING_SNAPSHOT_STORE: unique symbol = Symbol.for(
15
+ "loom.plugin-content.ContentTouchingSnapshotStore"
16
+ );
17
+
18
+ /**
19
+ * True when `store` is a {@link ContentTouchingSnapshotStore}. Evidence about THIS object only: a
20
+ * `false` does not prove the runtime wrote unwrapped (a sweep or the CLI reads a raw store over the
21
+ * same tables a wrapped one wrote), and a `true` does not prove every OTHER store the runtime saves
22
+ * to is wrapped. `sweepUnpinnedContent` therefore uses it only to warn, never to relax or refuse.
23
+ */
24
+ export function isContentTouchingSnapshotStore(store: unknown): boolean {
25
+ return (
26
+ typeof store === "object" &&
27
+ store !== null &&
28
+ (store as Record<symbol, unknown>)[CONTENT_TOUCHING_SNAPSHOT_STORE] === true
29
+ );
30
+ }
31
+
32
+ export interface ContentTouchingSnapshotStoreOptions {
33
+ /** See {@link TouchErrorHook}: the touch after a durable save, and a cited blob found missing. */
34
+ onTouchError?: TouchErrorHook;
35
+ }
36
+
37
+ /**
38
+ * ADR 0055 D6 amendment 2 — the SECOND citation path. `copyPrefix` copies only the post-gap events
39
+ * of a compacted parent; the refs its truncated prefix cited live in the child's SEED SNAPSHOT
40
+ * (the runtime's `fork` saves it), where an R5 media source or a claim-check envelope stays
41
+ * verbatim and nothing `put`s it again. `ContentResolvingEventStore` cannot see it, so this
42
+ * decorator touches the citations of every snapshot it SAVES, before and after, exactly as that
43
+ * store does for events (same walker, same guarantee: D5's margin, see {@link touchAround}).
44
+ *
45
+ * Chosen over a hook in the runtime because the runtime holds no content store (and must not
46
+ * depend on the content plugin), and over touching at fork start because a decorator also covers
47
+ * compaction checkpoints and every other save with no extra seam. Cost: one iterative walk of the
48
+ * saved state and a round trip only when it cites a blob; with a content store that has no `touch`
49
+ * nothing at all.
50
+ *
51
+ * Wrap it OUTSIDE an `EncryptingSnapshotStore` (it must see the plaintext state, or refs under a
52
+ * sensitive path are hidden from the walk), and pass the same content store the event log uses.
53
+ * The seed is saved after the prefix copy, so a fork racing the DELETION of its own parent is
54
+ * outside the guarantee (the fork's read of the parent fails then, too).
55
+ */
56
+ export class ContentTouchingSnapshotStore implements SnapshotStore {
57
+ readonly deleteExecution?: NonNullable<SnapshotStore["deleteExecution"]>;
58
+ readonly loadLatestSealed?: NonNullable<SnapshotStore["loadLatestSealed"]>;
59
+ readonly pruneSnapshots?: NonNullable<SnapshotStore["pruneSnapshots"]>;
60
+ readonly listSequences?: NonNullable<SnapshotStore["listSequences"]>;
61
+ /** Brand: see {@link isContentTouchingSnapshotStore}. */
62
+ readonly [CONTENT_TOUCHING_SNAPSHOT_STORE] = true;
63
+ private readonly onTouchError: TouchErrorHook | undefined;
64
+
65
+ constructor(
66
+ private readonly inner: SnapshotStore,
67
+ private readonly content: Pick<ContentStore, "touch">,
68
+ options: ContentTouchingSnapshotStoreOptions = {}
69
+ ) {
70
+ this.onTouchError = options.onTouchError;
71
+ const plan = {
72
+ deleteExecution: "forward",
73
+ loadLatestSealed: "forward",
74
+ // ADR 0134 D6 — a delete cites nothing, so there is nothing to touch. A blob only a pruned
75
+ // snapshot cited was never pinned by the sweep (it pins the latest and the checkpoint,
76
+ // both of which pruning keeps), so no sweep decision changes.
77
+ pruneSnapshots: "forward",
78
+ // ADR 0134 amendment - a read of sequences cites no content.
79
+ listSequences: "forward",
80
+ } satisfies DecoratorPlan<SnapshotStore, OptionalSnapshotStoreMethod>;
81
+ installOptional<SnapshotStore, OptionalSnapshotStoreMethod>(this, inner, plan);
82
+ }
83
+
84
+ save(snapshot: Snapshot): Promise<void> {
85
+ return touchAround(
86
+ this.content,
87
+ [snapshot.state],
88
+ { operation: "snapshotSave", executionId: snapshot.executionId },
89
+ this.onTouchError,
90
+ () => this.inner.save(snapshot)
91
+ );
92
+ }
93
+
94
+ loadLatest(executionId: string): Promise<Snapshot | null> {
95
+ return this.inner.loadLatest(executionId);
96
+ }
97
+
98
+ loadAt(executionId: string, sequence: number): Promise<Snapshot | null> {
99
+ return this.inner.loadAt(executionId, sequence);
100
+ }
101
+
102
+ loadLatestBefore(executionId: string, sequence: number): Promise<Snapshot | null> {
103
+ return this.inner.loadLatestBefore(executionId, sequence);
104
+ }
105
+
106
+ close(): Promise<void> {
107
+ return this.inner.close();
108
+ }
109
+ }
@@ -0,0 +1,117 @@
1
+ // The ONE walker for "which blobs does this value cite" — shared by the mark phase of
2
+ // `sweepUnpinnedContent` and by `ContentResolvingEventStore`, which touches what it carries
3
+ // into a log without a `put` (ADR 0055 D6 amendment 2). Iterative + cycle-safe: neither a
4
+ // retention job nor an append may stack-overflow on an app-controlled payload.
5
+
6
+ /**
7
+ * The structural claim-check test, identical to `isClaimCheck` in `@loomwork/middleware`
8
+ * (kept structural so this plugin takes no middleware dependency): the flag AND a string
9
+ * `ref.__loomContent`. An app object that merely carries the flag is app data.
10
+ */
11
+ function envelopeRef(value: object): string | undefined {
12
+ if ((value as { __loomClaimCheck?: unknown }).__loomClaimCheck !== true) return undefined;
13
+ const address = (value as { ref?: { __loomContent?: unknown } | null }).ref?.__loomContent;
14
+ return typeof address === "string" ? address : undefined;
15
+ }
16
+
17
+ /** The address of an R5 media source `{ kind: "ref", ref: { address: string } }`, if `value` is one. */
18
+ function mediaSourceAddress(value: object): string | undefined {
19
+ if ((value as { kind?: unknown }).kind !== "ref") return undefined;
20
+ const address = (value as { ref?: { address?: unknown } | null }).ref?.address;
21
+ return typeof address === "string" ? address : undefined;
22
+ }
23
+
24
+ /**
25
+ * Visit every content address an event payload (or snapshot state) cites. Any object carrying a
26
+ * string `__loomContent` is a ref leaf — this covers bare ADR 0046
27
+ * `ContentRef`s (e.g. a finding's `evidence` list, an externalized field) AND
28
+ * the `ref` of an ADR 0054 claim-check envelope. `inEnvelope` is true ONLY for
29
+ * a RECLAIMABLE envelope's own `ref` — the offloaded value, the one citation whose
30
+ * erasure is replay-invisible (ADR 0055 D4). An envelope marked `retain: true` (ADR 0140:
31
+ * the opt-in agent claim-check, whose offloaded bytes are the ONLY copy of a tool output the
32
+ * log once held) reports its ref as load-bearing (`inEnvelope` false): kept while any live
33
+ * log, archive segment or snapshot cites it, freed only when that history is deleted. Every other envelope field (`digest` above
34
+ * all: orchestration branches on it) is recorded, replay-visible data and is
35
+ * walked in the envelope's own context like any other payload.
36
+ *
37
+ * Because the enveloped context never descends (the `ref` is a leaf), one
38
+ * WeakSet cycle guard suffices: an object aliased at a bare site and as an
39
+ * envelope's `ref` is still visited bare, since the envelope reports its ref
40
+ * directly rather than through the guard.
41
+ *
42
+ * Iterative with cycle detection: the sweep is a retention job over
43
+ * app-controlled payloads, so a pathologically deep (or, from an in-memory
44
+ * store, cyclic) value must not stack-overflow the pass (audit W8).
45
+ */
46
+ export function visitCitedAddresses(
47
+ value: unknown,
48
+ visit: (address: string, inEnvelope: boolean) => void,
49
+ /**
50
+ * ADR 0140 D11 - when given, a `retain: true` envelope is honoured as load-bearing ONLY if it is in
51
+ * this set (see {@link retainRootsOf}); any other (a nested, tool-authored lookalike) stays reclaimable.
52
+ * Absent: every retained envelope is honoured (snapshots and archives, which pin everything anyway).
53
+ */
54
+ trustedRetain?: ReadonlySet<object>
55
+ ): void {
56
+ const stack: unknown[] = [value];
57
+ const seen = new WeakSet<object>();
58
+ while (stack.length > 0) {
59
+ const current = stack.pop();
60
+ if (typeof current !== "object" || current === null) continue;
61
+ if (seen.has(current)) continue;
62
+ seen.add(current);
63
+ const address = (current as { __loomContent?: unknown }).__loomContent;
64
+ if (typeof address === "string") {
65
+ visit(address, false);
66
+ continue;
67
+ }
68
+ if (Array.isArray(current)) {
69
+ for (const item of current) stack.push(item);
70
+ continue;
71
+ }
72
+ // R5 (ADR 0120) — an LLM media source `{ kind: "ref", ref: { address } }` cites a blob by its
73
+ // address WITHOUT the `__loomContent` marker (deliberately: the resolving store would
74
+ // JSON-decode that marker). It is as load-bearing as a bare ref — a live model call resolves
75
+ // it, and erasing it makes the next call fail — so the mark phase must see it, or the sweep
76
+ // would reclaim media the transcript still cites.
77
+ const mediaAddress = mediaSourceAddress(current);
78
+ if (mediaAddress !== undefined) {
79
+ visit(mediaAddress, false);
80
+ continue;
81
+ }
82
+ const offloaded = envelopeRef(current);
83
+ // ADR 0140 - a RETAINED envelope's blob is audit evidence, not telemetry: load-bearing.
84
+ if (offloaded !== undefined) {
85
+ visit(
86
+ offloaded,
87
+ (current as { retain?: unknown }).retain !== true ||
88
+ (trustedRetain !== undefined && !trustedRetain.has(current))
89
+ );
90
+ }
91
+ for (const [key, item] of Object.entries(current)) {
92
+ if (offloaded !== undefined && key === "ref") continue;
93
+ stack.push(item);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * ADR 0140 D11 - the objects in an event payload where a retained envelope is TRUSTED: the top-level
100
+ * `output` of a `STEP_COMPLETED` (through a taint wrapper), exactly where the opt-in preset records its
101
+ * envelope. A `retain: true` lookalike nested inside a tool's result is tool-authored data: it cannot
102
+ * pin a blob.
103
+ */
104
+ export function retainRootsOf(event: { eventType: string; payload: unknown }): Set<object> {
105
+ const roots = new Set<object>();
106
+ if (event.eventType !== "STEP_COMPLETED") return roots;
107
+ let out = (event.payload as { output?: unknown } | null)?.output;
108
+ if (
109
+ typeof out === "object" &&
110
+ out !== null &&
111
+ (out as { __loomTainted?: unknown }).__loomTainted !== undefined
112
+ ) {
113
+ out = (out as { value?: unknown }).value;
114
+ }
115
+ if (typeof out === "object" && out !== null) roots.add(out);
116
+ return roots;
117
+ }
@@ -0,0 +1,84 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import { InMemoryAsyncEventStore } from "@loomwork/event-runtime";
3
+ import { bench, describe } from "vitest";
4
+ import { ContentResolvingEventStore } from "./ContentResolvingEventStore";
5
+ import { type ContentStore, InMemoryContentStore } from "./ContentStore";
6
+ import type { ContentRef } from "./content";
7
+
8
+ // ── ADR 0062 guardrail bench — frozen-snapshot serving (TS-10) ────────────────
9
+ //
10
+ // 50 externalized refs (one dashboard's widgets) read through a backend with
11
+ // ~2ms simulated round-trip latency. Compare: batched getMany (one round-trip)
12
+ // vs the no-getMany fallback (coalesced parallel gets) vs the pre-ADR-0062
13
+ // baseline shape (serialized per-ref awaits). Budget: p95 ≤ 150ms for ≤ 50 refs
14
+ // — the batched path should sit near the single round-trip floor.
15
+
16
+ const LATENCY_MS = 2;
17
+ const WIDGETS = 50;
18
+
19
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ function withLatency(inner: InMemoryContentStore, supportsGetMany: boolean): ContentStore {
22
+ const store: ContentStore = {
23
+ put: (bytes) => inner.put(bytes),
24
+ get: async (ref) => {
25
+ await sleep(LATENCY_MS);
26
+ return inner.get(ref);
27
+ },
28
+ delete: (ref) => inner.delete(ref),
29
+ };
30
+ if (supportsGetMany) {
31
+ store.getMany = async (refs: readonly ContentRef[]) => {
32
+ await sleep(LATENCY_MS);
33
+ return inner.getMany(refs);
34
+ };
35
+ }
36
+ return store;
37
+ }
38
+
39
+ function envelope(sequence: number, payload: unknown): EventEnvelope {
40
+ return {
41
+ id: `e-${sequence}`,
42
+ executionId: "x-1",
43
+ workflowId: "w-1",
44
+ eventType: "STEP_COMPLETED",
45
+ sequence,
46
+ timestamp: 1_000 + sequence,
47
+ payload,
48
+ };
49
+ }
50
+
51
+ async function seeded(supportsGetMany: boolean) {
52
+ const memory = new InMemoryContentStore();
53
+ const content = withLatency(memory, supportsGetMany);
54
+ const store = new ContentResolvingEventStore(new InMemoryAsyncEventStore(), content, {
55
+ externalize: { STEP_COMPLETED: ["output.doc"] },
56
+ });
57
+ for (let i = 0; i < WIDGETS; i++) {
58
+ await store.append(envelope(i, { stepId: `hydrate#${i}`, output: { doc: { widget: i } } }));
59
+ }
60
+ return store;
61
+ }
62
+
63
+ let batched: ContentResolvingEventStore | undefined;
64
+ let fallback: ContentResolvingEventStore | undefined;
65
+
66
+ describe(`resolve ${WIDGETS} refs @ ${LATENCY_MS}ms backend latency`, () => {
67
+ bench("getMany (one batched round-trip)", async () => {
68
+ batched ??= await seeded(true);
69
+ await batched.read("x-1");
70
+ });
71
+
72
+ bench("no getMany (coalesced parallel gets)", async () => {
73
+ fallback ??= await seeded(false);
74
+ await fallback.read("x-1");
75
+ });
76
+
77
+ bench("pre-ADR-0062 shape (serialized per-ref awaits — baseline)", async () => {
78
+ // The old resolver awaited object fields sequentially; emulate its
79
+ // round-trip profile directly against the latency store.
80
+ for (let i = 0; i < WIDGETS; i++) {
81
+ await sleep(LATENCY_MS);
82
+ }
83
+ });
84
+ });
package/src/content.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { createHash } from "node:crypto";
2
+ import { expandPaths, hasWildcard } from "@loomwork/classify";
3
+
4
+ // ── ADR 0046 — content-addressed references ───────────────────────────────────
5
+ //
6
+ // A payload field can be *externalized*: its value is stored in a content store and the log
7
+ // keeps only a reference. The reference is content-ADDRESSED — the address is `hash(bytes)`,
8
+ // a pure function of the content — which is what preserves determinism (replay reproduces the
9
+ // same address), gives natural dedup (identical values collapse to one blob), and yields the
10
+ // refcount key for free (forks that copy a prefix share the same address).
11
+
12
+ /** A content-addressed reference standing in for an externalized value in the log. */
13
+ export interface ContentRef {
14
+ /** The content address, e.g. `"sha256:<hex>"`. Uniquely and deterministically names the bytes. */
15
+ readonly __loomContent: string;
16
+ /** Byte length of the stored content (informational). */
17
+ readonly size?: number;
18
+ /** How the bytes decode back to a value (only `"json"` today). */
19
+ readonly codec?: string;
20
+ }
21
+
22
+ export function isContentRef(value: unknown): value is ContentRef {
23
+ return (
24
+ typeof value === "object" &&
25
+ value !== null &&
26
+ typeof (value as { __loomContent?: unknown }).__loomContent === "string"
27
+ );
28
+ }
29
+
30
+ /** The content address of some bytes: `sha256:<hex>`. Pure — the same bytes always map here. */
31
+ export function contentAddress(bytes: Uint8Array): string {
32
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
33
+ }
34
+
35
+ // Stateless for whole-input encode/decode — hoisted (audit nit: decodeValue runs
36
+ // per ref occurrence during substitution, incl. the live tail).
37
+ const UTF8_ENCODER = new TextEncoder();
38
+ const UTF8_DECODER = new TextDecoder();
39
+
40
+ /** Encode a value to bytes for the content store (JSON — the only codec today). */
41
+ export function encodeValue(value: unknown): Uint8Array {
42
+ return UTF8_ENCODER.encode(JSON.stringify(value));
43
+ }
44
+
45
+ /** Decode content-store bytes back to the original value. */
46
+ export function decodeValue(bytes: Uint8Array): unknown {
47
+ return JSON.parse(UTF8_DECODER.decode(bytes));
48
+ }
49
+
50
+ /**
51
+ * An ADR 0054 claim-check envelope, recognised structurally with the same test as
52
+ * `@loomwork/middleware`'s `isClaimCheck` (`__loomClaimCheck === true` AND a string
53
+ * `ref.__loomContent`), so this plugin takes no dependency for it (ADR 0055 D3).
54
+ *
55
+ * The read-side walkers below treat one as a LEAF. An envelope is resolved only by the
56
+ * claim-check plugin (`resolveClaimCheck`): its `ref` is a TTL-reclaimable citation whose
57
+ * erasure the fold never sees, and its bytes may not be JSON (`codec: "bytes"`). Descending
58
+ * into it replaced `ref` with the decoded value, threw `ContentMissingError` once the sweep
59
+ * had reclaimed the blob, and failed outright on a binary one (ADR 0055 D4's known gap).
60
+ */
61
+ function isClaimCheckEnvelope(value: object): boolean {
62
+ const v = value as { __loomClaimCheck?: unknown; ref?: { __loomContent?: unknown } };
63
+ return (
64
+ v.__loomClaimCheck === true &&
65
+ typeof v.ref === "object" &&
66
+ v.ref !== null &&
67
+ typeof v.ref.__loomContent === "string"
68
+ );
69
+ }
70
+
71
+ /** Whether a payload carries any {@link ContentRef} (the read-side fast path). */
72
+ export function hasContentRef(value: unknown): boolean {
73
+ if (isContentRef(value)) return true;
74
+ if (Array.isArray(value)) return value.some(hasContentRef);
75
+ if (typeof value === "object" && value !== null) {
76
+ if (isClaimCheckEnvelope(value)) return false; // the claim-check plugin's to resolve
77
+ return Object.values(value as Record<string, unknown>).some(hasContentRef);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * ADR 0062 — phase 1 of batched resolution: collect every {@link ContentRef} in
84
+ * a payload into `into` (keyed by content address — duplicates collapse), and
85
+ * report whether any was found. One walk replaces the old `hasContentRef`
86
+ * pre-walk + per-ref fetch recursion.
87
+ */
88
+ export function collectContentRefs(value: unknown, into: Map<string, ContentRef>): boolean {
89
+ if (isContentRef(value)) {
90
+ if (!into.has(value.__loomContent)) into.set(value.__loomContent, value);
91
+ return true;
92
+ }
93
+ if (Array.isArray(value)) {
94
+ let found = false;
95
+ for (const v of value) if (collectContentRefs(v, into)) found = true;
96
+ return found;
97
+ }
98
+ if (typeof value === "object" && value !== null) {
99
+ if (isClaimCheckEnvelope(value)) return false; // a leaf: see isClaimCheckEnvelope
100
+ let found = false;
101
+ for (const v of Object.values(value as Record<string, unknown>)) {
102
+ if (collectContentRefs(v, into)) found = true;
103
+ }
104
+ return found;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * ADR 0062 — phase 2 of batched resolution: pure rebuild substituting each
111
+ * {@link ContentRef} from the fetched bytes. Decoding is deliberately
112
+ * per-OCCURRENCE (the fetch dedupes by address — the expensive part — but two
113
+ * payload sites never alias one decoded object). Ref-free subtrees are returned
114
+ * as-is (structural sharing).
115
+ */
116
+ export function substituteContentRefs(
117
+ value: unknown,
118
+ bytesByAddress: ReadonlyMap<string, Uint8Array>
119
+ ): unknown {
120
+ if (isContentRef(value)) {
121
+ const bytes = bytesByAddress.get(value.__loomContent);
122
+ // Callers guarantee every collected address was fetched; a miss here is a
123
+ // programming error, surfaced loudly.
124
+ if (bytes === undefined) throw new Error(`unfetched content ref: ${value.__loomContent}`);
125
+ return decodeValue(bytes);
126
+ }
127
+ if (Array.isArray(value)) {
128
+ let changed = false;
129
+ const out = value.map((v) => {
130
+ const r = substituteContentRefs(v, bytesByAddress);
131
+ if (r !== v) changed = true;
132
+ return r;
133
+ });
134
+ return changed ? out : value;
135
+ }
136
+ if (typeof value === "object" && value !== null) {
137
+ if (isClaimCheckEnvelope(value)) return value; // a leaf: see isClaimCheckEnvelope
138
+ let changed = false;
139
+ const out: Record<string, unknown> = {};
140
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
141
+ const r = substituteContentRefs(v, bytesByAddress);
142
+ out[k] = r;
143
+ if (r !== v) changed = true;
144
+ }
145
+ return changed ? out : value;
146
+ }
147
+ return value;
148
+ }
149
+
150
+ function shallowCopy(value: object): Record<string, unknown> {
151
+ return Array.isArray(value)
152
+ ? ([...value] as unknown as Record<string, unknown>)
153
+ : { ...(value as Record<string, unknown>) };
154
+ }
155
+
156
+ /**
157
+ * Async structural path-mapper — like the crypto plugin's `mapPaths`, but the transform is
158
+ * awaited (a content `put` is async). Copies only the spine it touches (structural sharing
159
+ * elsewhere), never mutates `root`, and skips absent paths.
160
+ */
161
+ export async function mapPathsAsync(
162
+ root: unknown,
163
+ paths: readonly string[],
164
+ transform: (value: unknown) => Promise<unknown>
165
+ ): Promise<unknown> {
166
+ if (paths.length === 0 || typeof root !== "object" || root === null) return root;
167
+ // `[]` segments are resolved against the concrete value first, so a mark inside an array
168
+ // element reaches every element (#260). Paths without one pass through untouched.
169
+ const targets = hasWildcard(paths) ? expandPaths(root, paths) : paths;
170
+ const out = shallowCopy(root);
171
+ for (const path of targets) {
172
+ const segs = path.split(".");
173
+ let src = root as Record<string, unknown>;
174
+ let dst = out;
175
+ let reachable = true;
176
+ for (let i = 0; i < segs.length - 1; i++) {
177
+ const seg = segs[i] as string;
178
+ const child = src[seg];
179
+ if (typeof child !== "object" || child === null) {
180
+ reachable = false;
181
+ break;
182
+ }
183
+ if (dst[seg] === child) dst[seg] = shallowCopy(child as object);
184
+ src = child as Record<string, unknown>;
185
+ dst = dst[seg] as Record<string, unknown>;
186
+ }
187
+ const leaf = segs[segs.length - 1] as string;
188
+ if (reachable && Object.hasOwn(src, leaf)) dst[leaf] = await transform(src[leaf]);
189
+ }
190
+ return out;
191
+ }
package/src/derive.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { externalizedPaths } from "@loomwork/classify";
2
+ import type { StepDefinition } from "@loomwork/core";
3
+
4
+ // ── ADR 0046 — derive the externalize map from `externalized()` marks ──────────
5
+ //
6
+ // Mark a field once — `doc: externalized(z.object({...}))` — and derive the
7
+ // ContentResolvingEventStore `externalize` map, instead of hand-authoring
8
+ // `{ STEP_COMPLETED: ["output.doc"] }`. Mirrors `sensitivePathsFromSteps` (crypto): a step's
9
+ // output is stored under `output.*` in STEP_COMPLETED, so an externalized output field
10
+ // contributes `STEP_COMPLETED → "output.<path>"`.
11
+
12
+ /** The STEP_COMPLETED payload nests a step's output under this key. */
13
+ const OUTPUT_PREFIX = "output";
14
+
15
+ /**
16
+ * Build the {@link ContentResolvingEventStore} `externalize` map from step definitions: every
17
+ * field marked `externalized()` in a step's `outputSchema` becomes a `STEP_COMPLETED` dot-path
18
+ * `output.<path>`. Paths are unioned + O(n) de-duplicated across steps; an empty map when
19
+ * nothing is marked (zero-overhead unwired path, G2).
20
+ */
21
+ export function externalizePathsFromSteps(
22
+ // Only the output schema is read; Pick drops the invariant handler field so concrete
23
+ // `StepDefinition<In, Out>` values pass without a cast.
24
+ steps: readonly Pick<StepDefinition, "outputSchema">[]
25
+ ): Record<string, string[]> {
26
+ const seen = new Set<string>();
27
+ const stepCompleted: string[] = [];
28
+ for (const step of steps) {
29
+ const schema = step.outputSchema;
30
+ if (schema === undefined) continue;
31
+ for (const path of externalizedPaths(schema)) {
32
+ const dotted = `${OUTPUT_PREFIX}.${path}`;
33
+ if (!seen.has(dotted)) {
34
+ seen.add(dotted);
35
+ stepCompleted.push(dotted);
36
+ }
37
+ }
38
+ }
39
+ return stepCompleted.length > 0 ? { STEP_COMPLETED: stepCompleted } : {};
40
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,19 @@
1
+ // ── ADR 0046 — loud on absence (the generalized PAYLOAD_SHREDDED contract) ─────
2
+ //
3
+ // A fold that needs an externalized value whose bytes were erased (or never stored) must fail
4
+ // LOUDLY — never silently substitute a default and diverge (G1). This mirrors the crypto
5
+ // plugin's `PayloadShreddedError`: the value is gone, so replay of it cannot proceed.
6
+
7
+ export class ContentMissingError extends Error {
8
+ constructor(
9
+ readonly contentAddress: string,
10
+ readonly executionId?: string
11
+ ) {
12
+ super(
13
+ `externalized content "${contentAddress}"${
14
+ executionId ? ` (execution "${executionId}")` : ""
15
+ } is missing: the bytes were erased or never stored, so replay cannot restore this value`
16
+ );
17
+ this.name = "ContentMissingError";
18
+ }
19
+ }
@@ -0,0 +1,64 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+
3
+ // A terminal timestamp that is not a finite number (NaN, a string from an imported log or a 3P store,
4
+ // +-Infinity, null) is not comparable: treat the execution as not ended so the citation clock applies.
5
+ const finiteOrUndefined = (t: unknown): number | undefined =>
6
+ typeof t === "number" && Number.isFinite(t) ? t : undefined;
7
+
8
+ /**
9
+ * ADR 0146 - when did this execution END, as far as the live log can tell? The `at` of its newest
10
+ * kernel terminal event, or `undefined` when it has not ended: no terminal event, or it was RE-OPENED
11
+ * after its newest one (`resumeAs` appends EXECUTION_RESUMED to a terminal execution; a later
12
+ * EXECUTION_SUSPENDED / EXECUTION_STARTED likewise means live). `undefined` makes the sweep fall back
13
+ * to the citation clock, so every uncertainty can only age a ref LATER or equal, never earlier.
14
+ *
15
+ * The terminal set is the one `@loomwork/workflow-runtime`'s `terminalEndedAt` uses (and the replay VM's
16
+ * `isKernelWritten` check for EXECUTION_CONTINUED); it is duplicated, not imported, so this package
17
+ * takes no dependency on workflow-runtime. `gc.endClock.test.ts` (l) pins it against the ReplayVM.
18
+ * Scans from the tail in sequence order (not timestamp order), usually stopping within a few events.
19
+ */
20
+ export function terminalEndOf(events: readonly EventEnvelope[]): number | undefined {
21
+ for (let i = events.length - 1; i >= 0; i--) {
22
+ const e = events[i] as EventEnvelope;
23
+ switch (e.eventType) {
24
+ case "EXECUTION_RESUMED":
25
+ case "EXECUTION_SUSPENDED":
26
+ case "EXECUTION_STARTED":
27
+ return undefined;
28
+ case "EXECUTION_COMPLETED":
29
+ case "EXECUTION_FAILED":
30
+ case "EXECUTION_CANCELLED":
31
+ return finiteOrUndefined(e.timestamp);
32
+ case "EXECUTION_CONTINUED":
33
+ if (e.metadata?.kernel === true) return finiteOrUndefined(e.timestamp);
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * ADR 0146 - was this execution RE-OPENED after a terminal event? True iff its tail is live
41
+ * (`terminalEndOf` returns `undefined` because a RESUMED / SUSPENDED / STARTED follows) AND a terminal
42
+ * event (the same set as `terminalEndOf`) precedes that live tail. A run that never ended is false.
43
+ * A terminal event with a non-finite timestamp still counts as an end here (the log shows it ended).
44
+ */
45
+ export function wasReopened(events: readonly EventEnvelope[]): boolean {
46
+ let live = false;
47
+ for (let i = events.length - 1; i >= 0; i--) {
48
+ const e = events[i] as EventEnvelope;
49
+ switch (e.eventType) {
50
+ case "EXECUTION_RESUMED":
51
+ case "EXECUTION_SUSPENDED":
52
+ case "EXECUTION_STARTED":
53
+ live = true;
54
+ break;
55
+ case "EXECUTION_COMPLETED":
56
+ case "EXECUTION_FAILED":
57
+ case "EXECUTION_CANCELLED":
58
+ return live;
59
+ case "EXECUTION_CONTINUED":
60
+ if (e.metadata?.kernel === true) return live;
61
+ }
62
+ }
63
+ return false;
64
+ }