@hydraharness/harness-session-projection 0.1.1-rc.10
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/LICENSE +21 -0
- package/README.md +50 -0
- package/lib/index.js +327 -0
- package/lib/invariant.js +31 -0
- package/lib/types/index.d.ts +265 -0
- package/lib/types/index.js +344 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +30 -0
- package/lib/types/types.d.ts +25 -0
- package/lib/types/types.js +11 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @hydraharness/harness-session-projection
|
|
2
|
+
|
|
3
|
+
Session-projection Service Definition and drive registry. It owns `ctx.sessionProjections`, the registry that drives every registered projection unit over committed session events and serves finished whole values to carriers, currently the api-proxy history tail page and `session/projection` push frame. A domain registers pure mathematics; the framework owns the drive. The [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) records the design rationale.
|
|
4
|
+
|
|
5
|
+
Definitions with `history: active-version` fold only the selected transcript and refold on version creation or selection. Durable checkpoint watermarks still use the complete log sequence. A suffix containing a path change cannot reconstruct a shared prefix; restoration requests the complete log. Definitions sharing a key must agree on history mode and state version.
|
|
6
|
+
|
|
7
|
+
## Service: `SessionProjectionRegistry` (ctx key: `sessionProjections`)
|
|
8
|
+
|
|
9
|
+
### Public API
|
|
10
|
+
|
|
11
|
+
- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
|
|
12
|
+
- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per client-visible unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
|
|
13
|
+
- `ctx.sessionProjections.stateOf(session, key)` Read one registered unit's current host state without computing unrelated views. The returned value is a live read-only reference; callers must not mutate it.
|
|
14
|
+
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is available only through `stateOf`.
|
|
15
|
+
|
|
16
|
+
### Key Types
|
|
17
|
+
|
|
18
|
+
- `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
|
|
19
|
+
- `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here.
|
|
20
|
+
- `ProjectionDefinition<K, S>` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only.
|
|
21
|
+
|
|
22
|
+
## Contract
|
|
23
|
+
|
|
24
|
+
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch.
|
|
25
|
+
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
|
|
26
|
+
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
|
|
27
|
+
- **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`.
|
|
28
|
+
- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike.
|
|
29
|
+
- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
|
|
30
|
+
- **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent.
|
|
31
|
+
|
|
32
|
+
## Role
|
|
33
|
+
|
|
34
|
+
This package owns the Service Definition and drive roles of the capability seam: domain host plugins (e.g. `@hydraharness/harness-tool-todo`) contribute units, carriers (`@hydraharness/harness-host-apiproxy`) consume the snapshot and change feed, and neither knows the other.
|
|
35
|
+
|
|
36
|
+
## Model Experience
|
|
37
|
+
|
|
38
|
+
None, as the registry only computes client-facing read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
|
|
39
|
+
|
|
40
|
+
#### KV Cache effect
|
|
41
|
+
|
|
42
|
+
None; projections never assemble or send provider requests.
|
|
43
|
+
|
|
44
|
+
## Known Limitations and Deferred Work
|
|
45
|
+
|
|
46
|
+
- **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
|
|
47
|
+
- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `@hydraharness/harness-token-meter` sits there.
|
|
48
|
+
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
|
|
49
|
+
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `@hydraharness/harness-session-projection-cache` seed that fold from persisted rows instead.
|
|
50
|
+
- **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { Service } from "@hydraharness/cordis";
|
|
2
|
+
import { sessionVersions } from "@hydraharness/harness-session";
|
|
3
|
+
//#region lib/types/index.js
|
|
4
|
+
/**
|
|
5
|
+
* Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type
|
|
6
|
+
* tables, the `ProjectionDefinition` state-driven computation unit contract,
|
|
7
|
+
* and the `ctx.sessionProjections` registry that DRIVES every registered unit
|
|
8
|
+
* forward eagerly over committed session events. Domain host plugins
|
|
9
|
+
* contribute pure folds and optional client views; the framework owns the
|
|
10
|
+
* subscription, the per-session watermark cache, and change notification;
|
|
11
|
+
* carriers consume the snapshot read face and the change feed. Neither side
|
|
12
|
+
* knows the other
|
|
13
|
+
* (capability-seam three-way split). Design authority: the session-projection
|
|
14
|
+
* RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
|
15
|
+
*
|
|
16
|
+
* Whole-value event rule (load-bearing): a state-carrying log event MUST
|
|
17
|
+
* carry the complete post-change state, never a bare delta — it keeps every
|
|
18
|
+
* unit's transition trivially cheap and every served value self-describing.
|
|
19
|
+
*
|
|
20
|
+
* @module @hydraharness/harness-session-projection
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* `ctx.sessionProjections`: the projection unit table and its drive. The
|
|
24
|
+
* service subscribes to `session/event` once; every committed event passes
|
|
25
|
+
* every registered unit's `apply` (eager drive), and a changed state
|
|
26
|
+
* reference in a client-visible unit notifies the change feed with the
|
|
27
|
+
* schema-validated view.
|
|
28
|
+
* Cells build lazily — a unit registered after events flowed, or a session
|
|
29
|
+
* older than the registry, folds `init` over the in-memory log on first
|
|
30
|
+
* touch (event or read). Registration is an effect (disposer rides the
|
|
31
|
+
* calling fiber): an unloaded domain plugin's key disappears from snapshots
|
|
32
|
+
* and clients read it as capability absence. Domain
|
|
33
|
+
* plugins register under `ctx.inject(['sessionProjections'], …)` so headless
|
|
34
|
+
* assemblies without the registry stay unaffected. Registrants sharing a key
|
|
35
|
+
* share one unit and are counted: the same tool package mounted in N agent
|
|
36
|
+
* presets registers N times, and the key survives until the last one
|
|
37
|
+
* unloads.
|
|
38
|
+
*/
|
|
39
|
+
var SessionProjectionRegistry = class extends Service {
|
|
40
|
+
registrations = /* @__PURE__ */ new Map();
|
|
41
|
+
listeners = /* @__PURE__ */ new Set();
|
|
42
|
+
/**
|
|
43
|
+
* Create and install the registry as `ctx.sessionProjections`.
|
|
44
|
+
* @param ctx - Cordis context that owns the service.
|
|
45
|
+
*/
|
|
46
|
+
constructor(ctx) {
|
|
47
|
+
super(ctx, "sessionProjections");
|
|
48
|
+
ctx.on("session/event", (session, event) => {
|
|
49
|
+
this.drive(session, event);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
register(definition) {
|
|
53
|
+
const wire = definition.wire;
|
|
54
|
+
const erased = {
|
|
55
|
+
history: definition.history,
|
|
56
|
+
key: definition.key,
|
|
57
|
+
stateSchema: definition.stateSchema,
|
|
58
|
+
init: () => definition.init(),
|
|
59
|
+
apply: (state, event) => definition.apply(state, event),
|
|
60
|
+
wire: wire === void 0 ? void 0 : {
|
|
61
|
+
viewSchema: wire.viewSchema,
|
|
62
|
+
view: (state) => wire.view(state)
|
|
63
|
+
},
|
|
64
|
+
stateVersion: definition.stateVersion
|
|
65
|
+
};
|
|
66
|
+
if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`);
|
|
67
|
+
const dispose = this.ctx.effect(function* () {
|
|
68
|
+
const key = erased.key;
|
|
69
|
+
const existing = this.registrations.get(key);
|
|
70
|
+
if (existing === void 0) this.registrations.set(key, {
|
|
71
|
+
def: erased,
|
|
72
|
+
cells: /* @__PURE__ */ new WeakMap(),
|
|
73
|
+
refs: 1
|
|
74
|
+
});
|
|
75
|
+
else {
|
|
76
|
+
if (existing.def.stateVersion !== erased.stateVersion || existing.def.history !== erased.history) throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`);
|
|
77
|
+
existing.refs += 1;
|
|
78
|
+
}
|
|
79
|
+
yield () => {
|
|
80
|
+
const live = this.registrations.get(key);
|
|
81
|
+
/* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */
|
|
82
|
+
if (live === void 0) return;
|
|
83
|
+
live.refs -= 1;
|
|
84
|
+
if (live.refs === 0) this.registrations.delete(key);
|
|
85
|
+
};
|
|
86
|
+
}.bind(this), "sessionProjections.register()");
|
|
87
|
+
return () => void dispose();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Subscribe to the change feed. The registration is an effect on the
|
|
91
|
+
* calling context's fiber.
|
|
92
|
+
* @param listener - called once per client-visible unit whose state reference changed, per committed event.
|
|
93
|
+
* @returns the exact disposer that unsubscribes.
|
|
94
|
+
*/
|
|
95
|
+
onChanged(listener) {
|
|
96
|
+
const dispose = this.ctx.effect(() => {
|
|
97
|
+
this.listeners.add(listener);
|
|
98
|
+
return () => {
|
|
99
|
+
this.listeners.delete(listener);
|
|
100
|
+
};
|
|
101
|
+
}, "sessionProjections.onChanged()");
|
|
102
|
+
return () => void dispose();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read one unit's current host state without computing unrelated views.
|
|
106
|
+
* The returned value is live; callers must not mutate it.
|
|
107
|
+
* @param session - the session whose state is read.
|
|
108
|
+
* @param key - the registered unit key.
|
|
109
|
+
* @returns current state, or `undefined` when the key is not registered.
|
|
110
|
+
*/
|
|
111
|
+
stateOf(session, key) {
|
|
112
|
+
const registration = this.registrations.get(key);
|
|
113
|
+
if (registration === void 0) return void 0;
|
|
114
|
+
return this.cellFor(registration, session).state;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One consistent cut over every registered client-visible unit for one session, read from
|
|
118
|
+
* the watermark cache (missing cells fold lazily over the in-memory log).
|
|
119
|
+
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
|
120
|
+
* position. Each value passes its unit's `viewSchema` before leaving.
|
|
121
|
+
* @param session - the session whose projection values are read.
|
|
122
|
+
* @returns the snapshot; `values` is empty when no client-visible unit is registered.
|
|
123
|
+
*/
|
|
124
|
+
snapshot(session) {
|
|
125
|
+
const values = {};
|
|
126
|
+
for (const registration of this.registrations.values()) {
|
|
127
|
+
if (registration.def.wire === void 0) continue;
|
|
128
|
+
const cell = this.cellFor(registration, session);
|
|
129
|
+
values[registration.def.key] = registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state));
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
asOfSeq: session.seq - 1,
|
|
133
|
+
values
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* State-level checkpoint of every persisted unit for one session, read
|
|
138
|
+
* from the watermark cache (missing cells fold lazily over the in-memory
|
|
139
|
+
* log). This is the write side of the persisted projection cache: the
|
|
140
|
+
* returned rows are the `(key → {ver, seq, val})` part of the durable
|
|
141
|
+
* `(sessionId, key, ver, seq, val)`
|
|
142
|
+
* rows. Every `val` is a DETACHED structured clone — never the live
|
|
143
|
+
* cell reference: the watermark cache is this registry's authoritative
|
|
144
|
+
* mutable state, and a caller reaching the live reference could corrupt
|
|
145
|
+
* every subsequent snapshot and frame through it (plain JSON by the unit
|
|
146
|
+
* contract, so the clone is total).
|
|
147
|
+
* @param session - the session whose unit states are checkpointed.
|
|
148
|
+
* @returns one row per registered key.
|
|
149
|
+
*/
|
|
150
|
+
checkpoint(session) {
|
|
151
|
+
const rows = {};
|
|
152
|
+
for (const registration of this.registrations.values()) {
|
|
153
|
+
const cell = this.cellFor(registration, session);
|
|
154
|
+
rows[registration.def.key] = {
|
|
155
|
+
ver: registration.def.stateVersion,
|
|
156
|
+
seq: cell.observedSeq,
|
|
157
|
+
val: structuredClone(cell.state)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return rows;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The stored seq a {@link restore} tail read over `checkpoint` must start
|
|
164
|
+
* at: one event BELOW the lowest usable watermark (a row is usable when
|
|
165
|
+
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
|
|
166
|
+
* pulls the floor to `0` — that key must refold the full log). The
|
|
167
|
+
* one-below anchor is load-bearing: the tail then proves how far the
|
|
168
|
+
* stored log still extends, so {@link restore} can detect a log that
|
|
169
|
+
* shrank below a row's watermark (crash-repair truncation) instead of
|
|
170
|
+
* serving the stale row as current — an empty tail read from the anchor
|
|
171
|
+
* yields an end below every watermark and the restore rejects for a full
|
|
172
|
+
* re-read.
|
|
173
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
174
|
+
* @returns the seq to hand the persistence `readFrom`, or `undefined`
|
|
175
|
+
* when no unit is registered (no read needed — {@link restore} would
|
|
176
|
+
* serve empty values regardless).
|
|
177
|
+
*/
|
|
178
|
+
restoreFloor(checkpoint) {
|
|
179
|
+
let floor;
|
|
180
|
+
for (const registration of this.registrations.values()) {
|
|
181
|
+
const row = checkpoint[registration.def.key];
|
|
182
|
+
const need = row !== void 0 && row.ver === registration.def.stateVersion ? Math.max(row.seq + 1, 0) : 0;
|
|
183
|
+
floor = floor === void 0 ? need : Math.min(floor, need);
|
|
184
|
+
}
|
|
185
|
+
return floor === void 0 ? void 0 : Math.max(floor - 1, 0);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* View a checkpoint's rows without any log read: for every registered
|
|
189
|
+
* client-visible unit whose row's `ver` matches, serve the schema-validated
|
|
190
|
+
* `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key
|
|
191
|
+
* absent (a cold or listing consumer treats it as not-yet-available and a
|
|
192
|
+
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
|
193
|
+
* values are as stale as their rows, never wrong.
|
|
194
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
195
|
+
* @returns whole values per key with a usable row; empty when none.
|
|
196
|
+
*/
|
|
197
|
+
viewCheckpoint(checkpoint) {
|
|
198
|
+
const values = {};
|
|
199
|
+
for (const registration of this.registrations.values()) {
|
|
200
|
+
const def = registration.def;
|
|
201
|
+
if (def.wire === void 0) continue;
|
|
202
|
+
const row = checkpoint[def.key];
|
|
203
|
+
if (row === void 0 || row.ver !== def.stateVersion) continue;
|
|
204
|
+
let state;
|
|
205
|
+
try {
|
|
206
|
+
state = def.stateSchema.parse(row.val);
|
|
207
|
+
} catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
values[def.key] = def.wire.viewSchema.parse(def.wire.view(state));
|
|
211
|
+
}
|
|
212
|
+
return values;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Whether every registered client-visible unit has a usable checkpoint row.
|
|
216
|
+
* Host-only units do not affect client listing reads. A row is usable only
|
|
217
|
+
* when its version, persisted state, and rendered client value pass the
|
|
218
|
+
* current unit definitions.
|
|
219
|
+
* @param checkpoint - persisted rows for one session (possibly stale or incomplete).
|
|
220
|
+
* @returns `true` when {@link viewCheckpoint} can serve every current client value.
|
|
221
|
+
*/
|
|
222
|
+
checkpointHasCurrentViews(checkpoint) {
|
|
223
|
+
for (const registration of this.registrations.values()) {
|
|
224
|
+
const def = registration.def;
|
|
225
|
+
if (def.wire === void 0) continue;
|
|
226
|
+
const row = checkpoint[def.key];
|
|
227
|
+
if (row === void 0 || row.ver !== def.stateVersion) return false;
|
|
228
|
+
try {
|
|
229
|
+
const state = def.stateSchema.parse(row.val);
|
|
230
|
+
def.wire.viewSchema.parse(def.wire.view(state));
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Cold read: fold every persisted unit over a stored log suffix, seeding
|
|
239
|
+
* each from its checkpoint row when usable — the one read recipe (cached
|
|
240
|
+
* state + forward tail replay + `view`) applied without a live `Session`.
|
|
241
|
+
* Call with the events returned by a persistence
|
|
242
|
+
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
|
|
243
|
+
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
|
|
244
|
+
* so a shrunk log is detected here. A row is usable iff its
|
|
245
|
+
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
|
|
246
|
+
* (`seq >= baseSeq - 1`), and it does not claim events past the
|
|
247
|
+
* supplied end (`seq <= endSeq`); an unusable row is discarded
|
|
248
|
+
* and its key refolds from `init` — which is only sound over the full
|
|
249
|
+
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
|
|
250
|
+
* from seq 0, e.g. after a crash-repair truncation shrank the log below
|
|
251
|
+
* a row's watermark).
|
|
252
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
253
|
+
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
|
254
|
+
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
|
255
|
+
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
|
256
|
+
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
|
257
|
+
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
|
258
|
+
*/
|
|
259
|
+
restore(checkpoint, events, baseSeq) {
|
|
260
|
+
const endSeq = events.at(-1)?.seq ?? baseSeq - 1;
|
|
261
|
+
const values = {};
|
|
262
|
+
const refreshed = {};
|
|
263
|
+
for (const registration of this.registrations.values()) {
|
|
264
|
+
const def = registration.def;
|
|
265
|
+
const row = checkpoint[def.key];
|
|
266
|
+
const usable = row !== void 0 && row.ver === def.stateVersion && row.seq >= baseSeq - 1 && row.seq <= endSeq;
|
|
267
|
+
if (!usable && baseSeq > 0) throw new Error(`session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0`);
|
|
268
|
+
if (def.history === "active-version" && baseSeq > 0 && events.some((event) => event.type === "session/version" || event.type === "session/version-selected")) throw new Error("active-version projection requires a complete log after a path change; re-read from seq 0");
|
|
269
|
+
const active = def.history === "active-version" && baseSeq === 0 ? sessionVersions(events).events() : void 0;
|
|
270
|
+
let state = active === void 0 && usable ? def.stateSchema.parse(row.val) : def.init();
|
|
271
|
+
const from = usable ? row.seq : baseSeq - 1;
|
|
272
|
+
for (const event of active ?? events) if (active !== void 0 || event.seq > from) state = def.apply(state, event);
|
|
273
|
+
if (def.wire !== void 0) values[def.key] = def.wire.viewSchema.parse(def.wire.view(state));
|
|
274
|
+
refreshed[def.key] = {
|
|
275
|
+
ver: def.stateVersion,
|
|
276
|
+
seq: endSeq,
|
|
277
|
+
val: state
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
snapshot: {
|
|
282
|
+
asOfSeq: endSeq,
|
|
283
|
+
values
|
|
284
|
+
},
|
|
285
|
+
checkpoint: refreshed
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
|
289
|
+
buildCell(def, events) {
|
|
290
|
+
let state = def.init();
|
|
291
|
+
const source = def.history === "active-version" ? sessionVersions(events).events() : events;
|
|
292
|
+
for (const event of source) state = def.apply(state, event);
|
|
293
|
+
return {
|
|
294
|
+
state,
|
|
295
|
+
observedSeq: events.at(-1)?.seq ?? -1
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** Read (or lazily build, folding the full in-memory log) one unit's cell. */
|
|
299
|
+
cellFor(registration, session) {
|
|
300
|
+
let cell = registration.cells.get(session);
|
|
301
|
+
if (cell === void 0) {
|
|
302
|
+
cell = this.buildCell(registration.def, session.events);
|
|
303
|
+
registration.cells.set(session, cell);
|
|
304
|
+
}
|
|
305
|
+
return cell;
|
|
306
|
+
}
|
|
307
|
+
/** Eager drive: pass one committed event through every registered unit; notify on changed references. */
|
|
308
|
+
drive(session, event) {
|
|
309
|
+
for (const registration of this.registrations.values()) {
|
|
310
|
+
let cell = registration.cells.get(session);
|
|
311
|
+
if (cell === void 0) {
|
|
312
|
+
cell = this.buildCell(registration.def, session.events.slice(0, event.seq));
|
|
313
|
+
registration.cells.set(session, cell);
|
|
314
|
+
}
|
|
315
|
+
const next = registration.def.history === "active-version" && (event.type === "session/version" || event.type === "session/version-selected") ? this.buildCell(registration.def, session.events).state : registration.def.apply(cell.state, event);
|
|
316
|
+
const changed = !Object.is(next, cell.state);
|
|
317
|
+
cell.state = next;
|
|
318
|
+
cell.observedSeq = event.seq;
|
|
319
|
+
if (changed && registration.def.wire !== void 0 && this.listeners.size > 0) {
|
|
320
|
+
const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next));
|
|
321
|
+
for (const listener of this.listeners) listener(session, registration.def.key, value, event.seq);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
327
|
+
export { SessionProjectionRegistry, SessionProjectionRegistry as default };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-session-projection`.
|
|
4
|
+
* @module @hydraharness/harness-session-projection/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-session-projection";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "session-projection-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the registry's own contracts (duplicate-key and
|
|
13
|
+
* stateVersion rejection, effect-tied removal, the Object.is change gate) are
|
|
14
|
+
* enforced synchronously inside the service and proven by its spec, the
|
|
15
|
+
* drive relation (every committed `session/event` passes every unit) would
|
|
16
|
+
* require re-running the drive to check — duplicating the implementation
|
|
17
|
+
* rather than detecting drift — and the served-value relation (every served
|
|
18
|
+
* key has a live registration) lives on each carrier's wire path, which
|
|
19
|
+
* emits no cordis event this companion could observe; carrier specs assert
|
|
20
|
+
* it. Synchronous-unit discipline is enforced as far as practical by the
|
|
21
|
+
* boundary `schema.parse` (a Promise-returning view fails loudly).
|
|
22
|
+
*/
|
|
23
|
+
const install = () => {};
|
|
24
|
+
/**
|
|
25
|
+
* Register this package's invariant companion.
|
|
26
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
27
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
28
|
+
*/
|
|
29
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
30
|
+
//#endregion
|
|
31
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type
|
|
3
|
+
* tables, the `ProjectionDefinition` state-driven computation unit contract,
|
|
4
|
+
* and the `ctx.sessionProjections` registry that DRIVES every registered unit
|
|
5
|
+
* forward eagerly over committed session events. Domain host plugins
|
|
6
|
+
* contribute pure folds and optional client views; the framework owns the
|
|
7
|
+
* subscription, the per-session watermark cache, and change notification;
|
|
8
|
+
* carriers consume the snapshot read face and the change feed. Neither side
|
|
9
|
+
* knows the other
|
|
10
|
+
* (capability-seam three-way split). Design authority: the session-projection
|
|
11
|
+
* RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
|
12
|
+
*
|
|
13
|
+
* Whole-value event rule (load-bearing): a state-carrying log event MUST
|
|
14
|
+
* carry the complete post-change state, never a bare delta — it keeps every
|
|
15
|
+
* unit's transition trivially cheap and every served value self-describing.
|
|
16
|
+
*
|
|
17
|
+
* @module @hydraharness/harness-session-projection
|
|
18
|
+
*/
|
|
19
|
+
import { Context, Service } from '@hydraharness/cordis';
|
|
20
|
+
import type { ZodType } from 'zod';
|
|
21
|
+
import type { Session, SessionEvent } from '@hydraharness/harness-session';
|
|
22
|
+
declare module '@hydraharness/cordis' {
|
|
23
|
+
interface Context {
|
|
24
|
+
sessionProjections: SessionProjectionRegistry;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts';
|
|
28
|
+
export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts';
|
|
29
|
+
/**
|
|
30
|
+
* One domain's state-driven computation unit: a pure synchronous fold plus
|
|
31
|
+
* declarations and an optional client view — never an opaque getter. The framework drives
|
|
32
|
+
* `apply` on every committed session event; the domain holds no
|
|
33
|
+
* subscriptions and owns only the computation. All functions MUST be
|
|
34
|
+
* synchronous (an async unit would tear the carriers' consistency cut), and
|
|
35
|
+
* `state` MUST be plain JSON (the persisted-cache precondition).
|
|
36
|
+
*/
|
|
37
|
+
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {
|
|
38
|
+
/** Active-version units refold selected event references when a transcript path changes. */
|
|
39
|
+
history?: 'active-version';
|
|
40
|
+
/** The projection key this unit owns (its `SessionProjectionStateMap` entry). */
|
|
41
|
+
key: K;
|
|
42
|
+
/** Validates persisted state before it seeds a fold. */
|
|
43
|
+
stateSchema: ZodType<S>;
|
|
44
|
+
/**
|
|
45
|
+
* State for the empty log.
|
|
46
|
+
* @returns the initial state.
|
|
47
|
+
*/
|
|
48
|
+
init(): NoInfer<S>;
|
|
49
|
+
/**
|
|
50
|
+
* Pure transition: previous state + one committed event → next state. A
|
|
51
|
+
* unit uninterested in an event MUST return the same state reference — an
|
|
52
|
+
* unchanged reference (`Object.is`) produces zero downstream work.
|
|
53
|
+
* @param state - the state covering all prior events.
|
|
54
|
+
* @param event - the next committed session event.
|
|
55
|
+
* @returns the next state (same reference when the event is not the unit's).
|
|
56
|
+
*/
|
|
57
|
+
apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;
|
|
58
|
+
/** Client view. Omit for host-only units. */
|
|
59
|
+
wire?: K extends keyof SessionProjectionMap ? {
|
|
60
|
+
/** Validates the wire payload before it leaves the host. */
|
|
61
|
+
viewSchema: ZodType<SessionProjectionMap[K]>;
|
|
62
|
+
/**
|
|
63
|
+
* State → wire payload (the read-side projection).
|
|
64
|
+
* @param state - the current state.
|
|
65
|
+
* @returns the whole current value for this unit's key.
|
|
66
|
+
*/
|
|
67
|
+
view(state: NoInfer<S>): SessionProjectionMap[K];
|
|
68
|
+
} : never;
|
|
69
|
+
/**
|
|
70
|
+
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
|
|
71
|
+
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
|
|
72
|
+
* rows from an older unit are discarded instead of being forward-applied
|
|
73
|
+
* into garbage. Non-negative integer.
|
|
74
|
+
*/
|
|
75
|
+
stateVersion: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Change-feed listener: one unit's value changed for one session. `value` is
|
|
79
|
+
* the schema-validated `view` output; `seq` is the unit's watermark at
|
|
80
|
+
* emission (the seq of the event that caused the change).
|
|
81
|
+
*/
|
|
82
|
+
export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: number) => void;
|
|
83
|
+
/**
|
|
84
|
+
* One consistent read cut over every registered client-visible unit for one session.
|
|
85
|
+
* `asOfSeq` is the shared watermark — the seq of the last event every value
|
|
86
|
+
* reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
|
|
87
|
+
*/
|
|
88
|
+
export interface ProjectionSnapshot {
|
|
89
|
+
/** Seq of the last event the values reflect; -1 for an empty log. */
|
|
90
|
+
asOfSeq: number;
|
|
91
|
+
/** Whole current client value per registered key. */
|
|
92
|
+
values: Partial<SessionProjectionMap>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One unit's checkpoint: its internal state (plain JSON by the unit
|
|
96
|
+
* contract), the seq of the last event folded into it, and the unit
|
|
97
|
+
* `stateVersion` that produced it — the persisted projection-cache row
|
|
98
|
+
* `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is
|
|
99
|
+
* never authoritative, only a fold shortcut: `restore` discards it on a
|
|
100
|
+
* version mismatch or when it claims events past the stored log end.
|
|
101
|
+
*/
|
|
102
|
+
export interface ProjectionCheckpointRow {
|
|
103
|
+
/** The registering unit's `stateVersion` at fold time. */
|
|
104
|
+
ver: number;
|
|
105
|
+
/** Seq of the last event folded into `val`; -1 for the empty log. */
|
|
106
|
+
seq: number;
|
|
107
|
+
/** The unit's internal state — plain JSON per the unit contract. */
|
|
108
|
+
val: unknown;
|
|
109
|
+
}
|
|
110
|
+
/** Checkpoint rows keyed by projection key (one session's persisted cache value). */
|
|
111
|
+
export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>;
|
|
112
|
+
/**
|
|
113
|
+
* `ctx.sessionProjections`: the projection unit table and its drive. The
|
|
114
|
+
* service subscribes to `session/event` once; every committed event passes
|
|
115
|
+
* every registered unit's `apply` (eager drive), and a changed state
|
|
116
|
+
* reference in a client-visible unit notifies the change feed with the
|
|
117
|
+
* schema-validated view.
|
|
118
|
+
* Cells build lazily — a unit registered after events flowed, or a session
|
|
119
|
+
* older than the registry, folds `init` over the in-memory log on first
|
|
120
|
+
* touch (event or read). Registration is an effect (disposer rides the
|
|
121
|
+
* calling fiber): an unloaded domain plugin's key disappears from snapshots
|
|
122
|
+
* and clients read it as capability absence. Domain
|
|
123
|
+
* plugins register under `ctx.inject(['sessionProjections'], …)` so headless
|
|
124
|
+
* assemblies without the registry stay unaffected. Registrants sharing a key
|
|
125
|
+
* share one unit and are counted: the same tool package mounted in N agent
|
|
126
|
+
* presets registers N times, and the key survives until the last one
|
|
127
|
+
* unloads.
|
|
128
|
+
*/
|
|
129
|
+
export declare class SessionProjectionRegistry extends Service {
|
|
130
|
+
private readonly registrations;
|
|
131
|
+
private readonly listeners;
|
|
132
|
+
/**
|
|
133
|
+
* Create and install the registry as `ctx.sessionProjections`.
|
|
134
|
+
* @param ctx - Cordis context that owns the service.
|
|
135
|
+
*/
|
|
136
|
+
constructor(ctx: Context);
|
|
137
|
+
/**
|
|
138
|
+
* Register one domain's unit. The registration is an effect on the calling
|
|
139
|
+
* context's fiber: disposing the fiber (or calling the returned disposer)
|
|
140
|
+
* removes the key — and the unit's cached cells — from subsequent drives
|
|
141
|
+
* and snapshots.
|
|
142
|
+
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
|
143
|
+
* @returns the exact disposer that unregisters this unit.
|
|
144
|
+
*/
|
|
145
|
+
register<K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K]>(definition: Omit<ProjectionDefinition<K, S>, 'wire'> & {
|
|
146
|
+
wire: NonNullable<ProjectionDefinition<K, S>['wire']>;
|
|
147
|
+
}): () => void;
|
|
148
|
+
/**
|
|
149
|
+
* Register one host-only unit. Its state is omitted from client snapshots
|
|
150
|
+
* and always checkpointed like every other unit.
|
|
151
|
+
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
|
152
|
+
* @returns the exact disposer that unregisters this unit.
|
|
153
|
+
*/
|
|
154
|
+
register<K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K]>(definition: Omit<ProjectionDefinition<K, S>, 'wire'>): () => void;
|
|
155
|
+
/**
|
|
156
|
+
* Subscribe to the change feed. The registration is an effect on the
|
|
157
|
+
* calling context's fiber.
|
|
158
|
+
* @param listener - called once per client-visible unit whose state reference changed, per committed event.
|
|
159
|
+
* @returns the exact disposer that unsubscribes.
|
|
160
|
+
*/
|
|
161
|
+
onChanged(listener: ProjectionChangeListener): () => void;
|
|
162
|
+
/**
|
|
163
|
+
* Read one unit's current host state without computing unrelated views.
|
|
164
|
+
* The returned value is live; callers must not mutate it.
|
|
165
|
+
* @param session - the session whose state is read.
|
|
166
|
+
* @param key - the registered unit key.
|
|
167
|
+
* @returns current state, or `undefined` when the key is not registered.
|
|
168
|
+
*/
|
|
169
|
+
stateOf<K extends keyof SessionProjectionStateMap>(session: Session, key: K): SessionProjectionStateMap[K] | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* One consistent cut over every registered client-visible unit for one session, read from
|
|
172
|
+
* the watermark cache (missing cells fold lazily over the in-memory log).
|
|
173
|
+
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
|
174
|
+
* position. Each value passes its unit's `viewSchema` before leaving.
|
|
175
|
+
* @param session - the session whose projection values are read.
|
|
176
|
+
* @returns the snapshot; `values` is empty when no client-visible unit is registered.
|
|
177
|
+
*/
|
|
178
|
+
snapshot(session: Session): ProjectionSnapshot;
|
|
179
|
+
/**
|
|
180
|
+
* State-level checkpoint of every persisted unit for one session, read
|
|
181
|
+
* from the watermark cache (missing cells fold lazily over the in-memory
|
|
182
|
+
* log). This is the write side of the persisted projection cache: the
|
|
183
|
+
* returned rows are the `(key → {ver, seq, val})` part of the durable
|
|
184
|
+
* `(sessionId, key, ver, seq, val)`
|
|
185
|
+
* rows. Every `val` is a DETACHED structured clone — never the live
|
|
186
|
+
* cell reference: the watermark cache is this registry's authoritative
|
|
187
|
+
* mutable state, and a caller reaching the live reference could corrupt
|
|
188
|
+
* every subsequent snapshot and frame through it (plain JSON by the unit
|
|
189
|
+
* contract, so the clone is total).
|
|
190
|
+
* @param session - the session whose unit states are checkpointed.
|
|
191
|
+
* @returns one row per registered key.
|
|
192
|
+
*/
|
|
193
|
+
checkpoint(session: Session): ProjectionCheckpoint;
|
|
194
|
+
/**
|
|
195
|
+
* The stored seq a {@link restore} tail read over `checkpoint` must start
|
|
196
|
+
* at: one event BELOW the lowest usable watermark (a row is usable when
|
|
197
|
+
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
|
|
198
|
+
* pulls the floor to `0` — that key must refold the full log). The
|
|
199
|
+
* one-below anchor is load-bearing: the tail then proves how far the
|
|
200
|
+
* stored log still extends, so {@link restore} can detect a log that
|
|
201
|
+
* shrank below a row's watermark (crash-repair truncation) instead of
|
|
202
|
+
* serving the stale row as current — an empty tail read from the anchor
|
|
203
|
+
* yields an end below every watermark and the restore rejects for a full
|
|
204
|
+
* re-read.
|
|
205
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
206
|
+
* @returns the seq to hand the persistence `readFrom`, or `undefined`
|
|
207
|
+
* when no unit is registered (no read needed — {@link restore} would
|
|
208
|
+
* serve empty values regardless).
|
|
209
|
+
*/
|
|
210
|
+
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined;
|
|
211
|
+
/**
|
|
212
|
+
* View a checkpoint's rows without any log read: for every registered
|
|
213
|
+
* client-visible unit whose row's `ver` matches, serve the schema-validated
|
|
214
|
+
* `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key
|
|
215
|
+
* absent (a cold or listing consumer treats it as not-yet-available and a
|
|
216
|
+
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
|
217
|
+
* values are as stale as their rows, never wrong.
|
|
218
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
219
|
+
* @returns whole values per key with a usable row; empty when none.
|
|
220
|
+
*/
|
|
221
|
+
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>;
|
|
222
|
+
/**
|
|
223
|
+
* Whether every registered client-visible unit has a usable checkpoint row.
|
|
224
|
+
* Host-only units do not affect client listing reads. A row is usable only
|
|
225
|
+
* when its version, persisted state, and rendered client value pass the
|
|
226
|
+
* current unit definitions.
|
|
227
|
+
* @param checkpoint - persisted rows for one session (possibly stale or incomplete).
|
|
228
|
+
* @returns `true` when {@link viewCheckpoint} can serve every current client value.
|
|
229
|
+
*/
|
|
230
|
+
checkpointHasCurrentViews(checkpoint: ProjectionCheckpoint): boolean;
|
|
231
|
+
/**
|
|
232
|
+
* Cold read: fold every persisted unit over a stored log suffix, seeding
|
|
233
|
+
* each from its checkpoint row when usable — the one read recipe (cached
|
|
234
|
+
* state + forward tail replay + `view`) applied without a live `Session`.
|
|
235
|
+
* Call with the events returned by a persistence
|
|
236
|
+
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
|
|
237
|
+
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
|
|
238
|
+
* so a shrunk log is detected here. A row is usable iff its
|
|
239
|
+
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
|
|
240
|
+
* (`seq >= baseSeq - 1`), and it does not claim events past the
|
|
241
|
+
* supplied end (`seq <= endSeq`); an unusable row is discarded
|
|
242
|
+
* and its key refolds from `init` — which is only sound over the full
|
|
243
|
+
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
|
|
244
|
+
* from seq 0, e.g. after a crash-repair truncation shrank the log below
|
|
245
|
+
* a row's watermark).
|
|
246
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
247
|
+
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
|
248
|
+
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
|
249
|
+
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
|
250
|
+
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
|
251
|
+
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
|
252
|
+
*/
|
|
253
|
+
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): {
|
|
254
|
+
snapshot: ProjectionSnapshot;
|
|
255
|
+
checkpoint: ProjectionCheckpoint;
|
|
256
|
+
};
|
|
257
|
+
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
|
258
|
+
private buildCell;
|
|
259
|
+
/** Read (or lazily build, folding the full in-memory log) one unit's cell. */
|
|
260
|
+
private cellFor;
|
|
261
|
+
/** Eager drive: pass one committed event through every registered unit; notify on changed references. */
|
|
262
|
+
private drive;
|
|
263
|
+
}
|
|
264
|
+
export default SessionProjectionRegistry;
|
|
265
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type
|
|
3
|
+
* tables, the `ProjectionDefinition` state-driven computation unit contract,
|
|
4
|
+
* and the `ctx.sessionProjections` registry that DRIVES every registered unit
|
|
5
|
+
* forward eagerly over committed session events. Domain host plugins
|
|
6
|
+
* contribute pure folds and optional client views; the framework owns the
|
|
7
|
+
* subscription, the per-session watermark cache, and change notification;
|
|
8
|
+
* carriers consume the snapshot read face and the change feed. Neither side
|
|
9
|
+
* knows the other
|
|
10
|
+
* (capability-seam three-way split). Design authority: the session-projection
|
|
11
|
+
* RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
|
12
|
+
*
|
|
13
|
+
* Whole-value event rule (load-bearing): a state-carrying log event MUST
|
|
14
|
+
* carry the complete post-change state, never a bare delta — it keeps every
|
|
15
|
+
* unit's transition trivially cheap and every served value self-describing.
|
|
16
|
+
*
|
|
17
|
+
* @module @hydraharness/harness-session-projection
|
|
18
|
+
*/
|
|
19
|
+
import { Service } from '@hydraharness/cordis';
|
|
20
|
+
import { sessionVersions } from '@hydraharness/harness-session';
|
|
21
|
+
/**
|
|
22
|
+
* `ctx.sessionProjections`: the projection unit table and its drive. The
|
|
23
|
+
* service subscribes to `session/event` once; every committed event passes
|
|
24
|
+
* every registered unit's `apply` (eager drive), and a changed state
|
|
25
|
+
* reference in a client-visible unit notifies the change feed with the
|
|
26
|
+
* schema-validated view.
|
|
27
|
+
* Cells build lazily — a unit registered after events flowed, or a session
|
|
28
|
+
* older than the registry, folds `init` over the in-memory log on first
|
|
29
|
+
* touch (event or read). Registration is an effect (disposer rides the
|
|
30
|
+
* calling fiber): an unloaded domain plugin's key disappears from snapshots
|
|
31
|
+
* and clients read it as capability absence. Domain
|
|
32
|
+
* plugins register under `ctx.inject(['sessionProjections'], …)` so headless
|
|
33
|
+
* assemblies without the registry stay unaffected. Registrants sharing a key
|
|
34
|
+
* share one unit and are counted: the same tool package mounted in N agent
|
|
35
|
+
* presets registers N times, and the key survives until the last one
|
|
36
|
+
* unloads.
|
|
37
|
+
*/
|
|
38
|
+
export class SessionProjectionRegistry extends Service {
|
|
39
|
+
registrations = new Map();
|
|
40
|
+
listeners = new Set();
|
|
41
|
+
/**
|
|
42
|
+
* Create and install the registry as `ctx.sessionProjections`.
|
|
43
|
+
* @param ctx - Cordis context that owns the service.
|
|
44
|
+
*/
|
|
45
|
+
constructor(ctx) {
|
|
46
|
+
super(ctx, 'sessionProjections');
|
|
47
|
+
ctx.on('session/event', (session, event) => {
|
|
48
|
+
this.drive(session, event);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
register(definition) {
|
|
52
|
+
const wire = definition.wire;
|
|
53
|
+
const erased = {
|
|
54
|
+
history: definition.history,
|
|
55
|
+
key: definition.key,
|
|
56
|
+
stateSchema: definition.stateSchema,
|
|
57
|
+
init: () => definition.init(),
|
|
58
|
+
apply: (state, event) => definition.apply(state, event),
|
|
59
|
+
wire: wire === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: { viewSchema: wire.viewSchema, view: state => wire.view(state) },
|
|
62
|
+
stateVersion: definition.stateVersion,
|
|
63
|
+
};
|
|
64
|
+
if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
|
|
65
|
+
throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`);
|
|
66
|
+
}
|
|
67
|
+
const dispose = this.ctx.effect(function* () {
|
|
68
|
+
const key = erased.key;
|
|
69
|
+
const existing = this.registrations.get(key);
|
|
70
|
+
if (existing === undefined) {
|
|
71
|
+
this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (existing.def.stateVersion !== erased.stateVersion || existing.def.history !== erased.history) {
|
|
75
|
+
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`);
|
|
76
|
+
}
|
|
77
|
+
existing.refs += 1;
|
|
78
|
+
}
|
|
79
|
+
yield () => {
|
|
80
|
+
const live = this.registrations.get(key);
|
|
81
|
+
/* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */
|
|
82
|
+
if (live === undefined)
|
|
83
|
+
return;
|
|
84
|
+
live.refs -= 1;
|
|
85
|
+
if (live.refs === 0)
|
|
86
|
+
this.registrations.delete(key);
|
|
87
|
+
};
|
|
88
|
+
}.bind(this), 'sessionProjections.register()');
|
|
89
|
+
return () => void dispose();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Subscribe to the change feed. The registration is an effect on the
|
|
93
|
+
* calling context's fiber.
|
|
94
|
+
* @param listener - called once per client-visible unit whose state reference changed, per committed event.
|
|
95
|
+
* @returns the exact disposer that unsubscribes.
|
|
96
|
+
*/
|
|
97
|
+
onChanged(listener) {
|
|
98
|
+
const dispose = this.ctx.effect(() => {
|
|
99
|
+
this.listeners.add(listener);
|
|
100
|
+
return () => {
|
|
101
|
+
this.listeners.delete(listener);
|
|
102
|
+
};
|
|
103
|
+
}, 'sessionProjections.onChanged()');
|
|
104
|
+
return () => void dispose();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read one unit's current host state without computing unrelated views.
|
|
108
|
+
* The returned value is live; callers must not mutate it.
|
|
109
|
+
* @param session - the session whose state is read.
|
|
110
|
+
* @param key - the registered unit key.
|
|
111
|
+
* @returns current state, or `undefined` when the key is not registered.
|
|
112
|
+
*/
|
|
113
|
+
stateOf(session, key) {
|
|
114
|
+
const registration = this.registrations.get(key);
|
|
115
|
+
if (registration === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
return this.cellFor(registration, session).state;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* One consistent cut over every registered client-visible unit for one session, read from
|
|
121
|
+
* the watermark cache (missing cells fold lazily over the in-memory log).
|
|
122
|
+
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
|
123
|
+
* position. Each value passes its unit's `viewSchema` before leaving.
|
|
124
|
+
* @param session - the session whose projection values are read.
|
|
125
|
+
* @returns the snapshot; `values` is empty when no client-visible unit is registered.
|
|
126
|
+
*/
|
|
127
|
+
snapshot(session) {
|
|
128
|
+
const values = {};
|
|
129
|
+
for (const registration of this.registrations.values()) {
|
|
130
|
+
if (registration.def.wire === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
const cell = this.cellFor(registration, session);
|
|
133
|
+
values[registration.def.key] = registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state));
|
|
134
|
+
}
|
|
135
|
+
return { asOfSeq: session.seq - 1, values };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* State-level checkpoint of every persisted unit for one session, read
|
|
139
|
+
* from the watermark cache (missing cells fold lazily over the in-memory
|
|
140
|
+
* log). This is the write side of the persisted projection cache: the
|
|
141
|
+
* returned rows are the `(key → {ver, seq, val})` part of the durable
|
|
142
|
+
* `(sessionId, key, ver, seq, val)`
|
|
143
|
+
* rows. Every `val` is a DETACHED structured clone — never the live
|
|
144
|
+
* cell reference: the watermark cache is this registry's authoritative
|
|
145
|
+
* mutable state, and a caller reaching the live reference could corrupt
|
|
146
|
+
* every subsequent snapshot and frame through it (plain JSON by the unit
|
|
147
|
+
* contract, so the clone is total).
|
|
148
|
+
* @param session - the session whose unit states are checkpointed.
|
|
149
|
+
* @returns one row per registered key.
|
|
150
|
+
*/
|
|
151
|
+
checkpoint(session) {
|
|
152
|
+
const rows = {};
|
|
153
|
+
for (const registration of this.registrations.values()) {
|
|
154
|
+
const cell = this.cellFor(registration, session);
|
|
155
|
+
rows[registration.def.key] = {
|
|
156
|
+
ver: registration.def.stateVersion,
|
|
157
|
+
seq: cell.observedSeq,
|
|
158
|
+
val: structuredClone(cell.state),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return rows;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The stored seq a {@link restore} tail read over `checkpoint` must start
|
|
165
|
+
* at: one event BELOW the lowest usable watermark (a row is usable when
|
|
166
|
+
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
|
|
167
|
+
* pulls the floor to `0` — that key must refold the full log). The
|
|
168
|
+
* one-below anchor is load-bearing: the tail then proves how far the
|
|
169
|
+
* stored log still extends, so {@link restore} can detect a log that
|
|
170
|
+
* shrank below a row's watermark (crash-repair truncation) instead of
|
|
171
|
+
* serving the stale row as current — an empty tail read from the anchor
|
|
172
|
+
* yields an end below every watermark and the restore rejects for a full
|
|
173
|
+
* re-read.
|
|
174
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
175
|
+
* @returns the seq to hand the persistence `readFrom`, or `undefined`
|
|
176
|
+
* when no unit is registered (no read needed — {@link restore} would
|
|
177
|
+
* serve empty values regardless).
|
|
178
|
+
*/
|
|
179
|
+
restoreFloor(checkpoint) {
|
|
180
|
+
let floor;
|
|
181
|
+
for (const registration of this.registrations.values()) {
|
|
182
|
+
const row = checkpoint[registration.def.key];
|
|
183
|
+
const need = row !== undefined && row.ver === registration.def.stateVersion
|
|
184
|
+
? Math.max(row.seq + 1, 0)
|
|
185
|
+
: 0;
|
|
186
|
+
floor = floor === undefined ? need : Math.min(floor, need);
|
|
187
|
+
}
|
|
188
|
+
return floor === undefined ? undefined : Math.max(floor - 1, 0);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* View a checkpoint's rows without any log read: for every registered
|
|
192
|
+
* client-visible unit whose row's `ver` matches, serve the schema-validated
|
|
193
|
+
* `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key
|
|
194
|
+
* absent (a cold or listing consumer treats it as not-yet-available and a
|
|
195
|
+
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
|
196
|
+
* values are as stale as their rows, never wrong.
|
|
197
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
198
|
+
* @returns whole values per key with a usable row; empty when none.
|
|
199
|
+
*/
|
|
200
|
+
viewCheckpoint(checkpoint) {
|
|
201
|
+
const values = {};
|
|
202
|
+
for (const registration of this.registrations.values()) {
|
|
203
|
+
const def = registration.def;
|
|
204
|
+
if (def.wire === undefined)
|
|
205
|
+
continue;
|
|
206
|
+
const row = checkpoint[def.key];
|
|
207
|
+
if (row === undefined || row.ver !== def.stateVersion)
|
|
208
|
+
continue;
|
|
209
|
+
let state;
|
|
210
|
+
try {
|
|
211
|
+
state = def.stateSchema.parse(row.val);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
values[def.key] = def.wire.viewSchema.parse(def.wire.view(state));
|
|
217
|
+
}
|
|
218
|
+
return values;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Whether every registered client-visible unit has a usable checkpoint row.
|
|
222
|
+
* Host-only units do not affect client listing reads. A row is usable only
|
|
223
|
+
* when its version, persisted state, and rendered client value pass the
|
|
224
|
+
* current unit definitions.
|
|
225
|
+
* @param checkpoint - persisted rows for one session (possibly stale or incomplete).
|
|
226
|
+
* @returns `true` when {@link viewCheckpoint} can serve every current client value.
|
|
227
|
+
*/
|
|
228
|
+
checkpointHasCurrentViews(checkpoint) {
|
|
229
|
+
for (const registration of this.registrations.values()) {
|
|
230
|
+
const def = registration.def;
|
|
231
|
+
if (def.wire === undefined)
|
|
232
|
+
continue;
|
|
233
|
+
const row = checkpoint[def.key];
|
|
234
|
+
if (row === undefined || row.ver !== def.stateVersion)
|
|
235
|
+
return false;
|
|
236
|
+
try {
|
|
237
|
+
const state = def.stateSchema.parse(row.val);
|
|
238
|
+
def.wire.viewSchema.parse(def.wire.view(state));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Cold read: fold every persisted unit over a stored log suffix, seeding
|
|
248
|
+
* each from its checkpoint row when usable — the one read recipe (cached
|
|
249
|
+
* state + forward tail replay + `view`) applied without a live `Session`.
|
|
250
|
+
* Call with the events returned by a persistence
|
|
251
|
+
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
|
|
252
|
+
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
|
|
253
|
+
* so a shrunk log is detected here. A row is usable iff its
|
|
254
|
+
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
|
|
255
|
+
* (`seq >= baseSeq - 1`), and it does not claim events past the
|
|
256
|
+
* supplied end (`seq <= endSeq`); an unusable row is discarded
|
|
257
|
+
* and its key refolds from `init` — which is only sound over the full
|
|
258
|
+
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
|
|
259
|
+
* from seq 0, e.g. after a crash-repair truncation shrank the log below
|
|
260
|
+
* a row's watermark).
|
|
261
|
+
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
|
262
|
+
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
|
263
|
+
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
|
264
|
+
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
|
265
|
+
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
|
266
|
+
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
|
267
|
+
*/
|
|
268
|
+
restore(checkpoint, events, baseSeq) {
|
|
269
|
+
const endSeq = events.at(-1)?.seq ?? baseSeq - 1;
|
|
270
|
+
const values = {};
|
|
271
|
+
const refreshed = {};
|
|
272
|
+
for (const registration of this.registrations.values()) {
|
|
273
|
+
const def = registration.def;
|
|
274
|
+
const row = checkpoint[def.key];
|
|
275
|
+
const usable = row !== undefined
|
|
276
|
+
&& row.ver === def.stateVersion
|
|
277
|
+
&& row.seq >= baseSeq - 1
|
|
278
|
+
&& row.seq <= endSeq;
|
|
279
|
+
if (!usable && baseSeq > 0) {
|
|
280
|
+
throw new Error(`session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: `
|
|
281
|
+
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0');
|
|
282
|
+
}
|
|
283
|
+
if (def.history === 'active-version' && baseSeq > 0 && events.some(event => event.type === 'session/version' || event.type === 'session/version-selected')) {
|
|
284
|
+
throw new Error('active-version projection requires a complete log after a path change; re-read from seq 0');
|
|
285
|
+
}
|
|
286
|
+
const active = def.history === 'active-version' && baseSeq === 0 ? sessionVersions(events).events() : undefined;
|
|
287
|
+
let state = active === undefined && usable ? def.stateSchema.parse(row.val) : def.init();
|
|
288
|
+
const from = usable ? row.seq : baseSeq - 1;
|
|
289
|
+
for (const event of active ?? events) {
|
|
290
|
+
if (active !== undefined || event.seq > from)
|
|
291
|
+
state = def.apply(state, event);
|
|
292
|
+
}
|
|
293
|
+
if (def.wire !== undefined)
|
|
294
|
+
values[def.key] = def.wire.viewSchema.parse(def.wire.view(state));
|
|
295
|
+
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state };
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
snapshot: { asOfSeq: endSeq, values: values },
|
|
299
|
+
checkpoint: refreshed,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
|
303
|
+
buildCell(def, events) {
|
|
304
|
+
let state = def.init();
|
|
305
|
+
const source = def.history === 'active-version' ? sessionVersions(events).events() : events;
|
|
306
|
+
for (const event of source)
|
|
307
|
+
state = def.apply(state, event);
|
|
308
|
+
return { state, observedSeq: (events.at(-1)?.seq ?? -1) };
|
|
309
|
+
}
|
|
310
|
+
/** Read (or lazily build, folding the full in-memory log) one unit's cell. */
|
|
311
|
+
cellFor(registration, session) {
|
|
312
|
+
let cell = registration.cells.get(session);
|
|
313
|
+
if (cell === undefined) {
|
|
314
|
+
cell = this.buildCell(registration.def, session.events);
|
|
315
|
+
registration.cells.set(session, cell);
|
|
316
|
+
}
|
|
317
|
+
return cell;
|
|
318
|
+
}
|
|
319
|
+
/** Eager drive: pass one committed event through every registered unit; notify on changed references. */
|
|
320
|
+
drive(session, event) {
|
|
321
|
+
for (const registration of this.registrations.values()) {
|
|
322
|
+
let cell = registration.cells.get(session);
|
|
323
|
+
if (cell === undefined) {
|
|
324
|
+
// Late build mid-stream: fold history before this event (seq = log
|
|
325
|
+
// index, so the prefix slice is exact), then take the normal gate.
|
|
326
|
+
cell = this.buildCell(registration.def, session.events.slice(0, event.seq));
|
|
327
|
+
registration.cells.set(session, cell);
|
|
328
|
+
}
|
|
329
|
+
const next = registration.def.history === 'active-version' && (event.type === 'session/version' || event.type === 'session/version-selected')
|
|
330
|
+
? this.buildCell(registration.def, session.events).state : registration.def.apply(cell.state, event);
|
|
331
|
+
const changed = !Object.is(next, cell.state);
|
|
332
|
+
cell.state = next;
|
|
333
|
+
cell.observedSeq = event.seq;
|
|
334
|
+
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
|
|
335
|
+
const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next));
|
|
336
|
+
for (const listener of this.listeners) {
|
|
337
|
+
listener(session, registration.def.key, value, event.seq);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
export default SessionProjectionRegistry;
|
|
344
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-session-projection`.
|
|
3
|
+
* @module @hydraharness/harness-session-projection/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "session-projection-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-session-projection`.
|
|
3
|
+
* @module @hydraharness/harness-session-projection/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@hydraharness/harness-session-projection';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'session-projection-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: the registry's own contracts (duplicate-key and
|
|
12
|
+
* stateVersion rejection, effect-tied removal, the Object.is change gate) are
|
|
13
|
+
* enforced synchronously inside the service and proven by its spec, the
|
|
14
|
+
* drive relation (every committed `session/event` passes every unit) would
|
|
15
|
+
* require re-running the drive to check — duplicating the implementation
|
|
16
|
+
* rather than detecting drift — and the served-value relation (every served
|
|
17
|
+
* key has a live registration) lives on each carrier's wire path, which
|
|
18
|
+
* emits no cordis event this companion could observe; carrier specs assert
|
|
19
|
+
* it. Synchronous-unit discipline is enforced as far as practical by the
|
|
20
|
+
* boundary `schema.parse` (a Promise-returning view fails loudly).
|
|
21
|
+
*/
|
|
22
|
+
const install = () => { };
|
|
23
|
+
/**
|
|
24
|
+
* Register this package's invariant companion.
|
|
25
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
26
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
27
|
+
*/
|
|
28
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
29
|
+
/* jscpd:ignore-end */
|
|
30
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-type outlet of the session-projection Service Definition: the one projection type
|
|
3
|
+
* table, importable from client aggregates without dragging the host-side
|
|
4
|
+
* cordis Context merges of the package root (@hydraharness/harness-agent → @hydraharness/harness-session). Domain
|
|
5
|
+
* packages may declare-merge through either the package root or this outlet —
|
|
6
|
+
* re-export preserves symbol identity, so both land on the same table.
|
|
7
|
+
*
|
|
8
|
+
* @module @hydraharness/harness-session-projection/types
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The merge-extensible client projection table shared by wire blocks, client
|
|
12
|
+
* cells, and React hooks. Domain packages merge their client-visible key here;
|
|
13
|
+
* values are wire-JSON whole values. How a value is rendered is the slot
|
|
14
|
+
* system's business, never this layer's.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionProjectionMap {
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The merge-extensible host fold-state table. Each client-visible key also
|
|
20
|
+
* appears in {@link SessionProjectionMap}; host-only keys appear only here.
|
|
21
|
+
* Values must be plain JSON so the projection cache can persist them.
|
|
22
|
+
*/
|
|
23
|
+
export interface SessionProjectionStateMap {
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-type outlet of the session-projection Service Definition: the one projection type
|
|
3
|
+
* table, importable from client aggregates without dragging the host-side
|
|
4
|
+
* cordis Context merges of the package root (@hydraharness/harness-agent → @hydraharness/harness-session). Domain
|
|
5
|
+
* packages may declare-merge through either the package root or this outlet —
|
|
6
|
+
* re-export preserves symbol identity, so both land on the same table.
|
|
7
|
+
*
|
|
8
|
+
* @module @hydraharness/harness-session-projection/types
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-session-projection",
|
|
3
|
+
"description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Build current session views, such as goals and statistics, from recorded conversation events."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.10",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong2010/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/session/session-projection"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./types": {
|
|
31
|
+
"types": "./lib/types/types.d.ts",
|
|
32
|
+
"default": "./lib/types/types.js"
|
|
33
|
+
},
|
|
34
|
+
"./src/*": "./src/*",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"lib/index.js",
|
|
39
|
+
"lib/invariant.js",
|
|
40
|
+
"lib/types/**/*.js",
|
|
41
|
+
"lib/types/**/*.d.ts"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"zod": "^4.4.3"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@hydraharness/harness-session": "^0.1.1-rc.10",
|
|
49
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.10",
|
|
50
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.10",
|
|
54
|
+
"@hydraharness/harness-session": "^0.1.1-rc.10",
|
|
55
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
56
|
+
}
|
|
57
|
+
}
|