@qilin-agent/session-persistence 0.0.0-stage → 3.1.3
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.i18n.yaml +79 -0
- package/README.md +162 -2
- package/README.zh.md +163 -0
- package/lib/index.js +269 -0
- package/lib/types/errors.d.ts +105 -0
- package/lib/types/handle.d.ts +107 -0
- package/lib/types/index.d.ts +160 -0
- package/lib/types/revision.d.ts +14 -0
- package/lib/types/storage-contract.d.ts +62 -0
- package/package.json +45 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kkutysllb
|
|
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.i18n.yaml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
|
2
|
+
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
|
3
|
+
# After editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
|
|
5
|
+
/:
|
|
6
|
+
en: 99acbd5c1e2a16ce
|
|
7
|
+
zh: 31342508a4e1bf09
|
|
8
|
+
/qilin-session-persistence:
|
|
9
|
+
en: 1d719c9c7d11ca70
|
|
10
|
+
zh: b0a828b200cf9bcd
|
|
11
|
+
/qilin-session-persistence/summary:
|
|
12
|
+
en: 34c7ee5a75c547e2
|
|
13
|
+
zh: 3e2e6dfd7819e6cc
|
|
14
|
+
/qilin-session-persistence/table-of-contents:
|
|
15
|
+
en: d152484eb41ac6b4
|
|
16
|
+
zh: 09388d293f9be9cb
|
|
17
|
+
/qilin-session-persistence/use-this-package:
|
|
18
|
+
en: bd48e96d14122170
|
|
19
|
+
zh: f8a06e7246b55a6f
|
|
20
|
+
/qilin-session-persistence/use-this-package/choosing-a-backend:
|
|
21
|
+
en: 5ad706685920c2f8
|
|
22
|
+
zh: ee5f922ae0a95852
|
|
23
|
+
/qilin-session-persistence/use-this-package/what-the-service-provides:
|
|
24
|
+
en: c10b9c7d9bad305c
|
|
25
|
+
zh: 0e9c391e9df37da6
|
|
26
|
+
/qilin-session-persistence/use-this-package/ownership-and-visibility:
|
|
27
|
+
en: 16d4abbb05a67360
|
|
28
|
+
zh: fc91863169570795
|
|
29
|
+
/qilin-session-persistence/use-this-package/the-live-write-path-and-shutdown-drain:
|
|
30
|
+
en: 282c5bc8a391159f
|
|
31
|
+
zh: 96682e345ef301f6
|
|
32
|
+
/qilin-session-persistence/use-this-package/resuming-and-crash-recovery:
|
|
33
|
+
en: 08b09dce0bcf47c8
|
|
34
|
+
zh: b6c5926d6e440a7b
|
|
35
|
+
/qilin-session-persistence/use-this-package/failures-and-recovery:
|
|
36
|
+
en: 6d8beec038b1121a
|
|
37
|
+
zh: 1f02952eb2a0b386
|
|
38
|
+
/qilin-session-persistence/understand-the-implementation:
|
|
39
|
+
en: fc8325413af9c66e
|
|
40
|
+
zh: accf0758248ae9be
|
|
41
|
+
/qilin-session-persistence/understand-the-implementation/design-concept:
|
|
42
|
+
en: 3d21cded1f7e74ff
|
|
43
|
+
zh: 5f624aefebb1f63d
|
|
44
|
+
/qilin-session-persistence/understand-the-implementation/the-invariants-every-backend-honors:
|
|
45
|
+
en: a3b5e0e6747f5ab4
|
|
46
|
+
zh: fcbcc16c04e15edb
|
|
47
|
+
/qilin-session-persistence/understand-the-implementation/source-map:
|
|
48
|
+
en: 206eaa18456c97ff
|
|
49
|
+
zh: 40afc55b378ec11b
|
|
50
|
+
/qilin-session-persistence/understand-the-implementation/the-write-path-at-a-glance:
|
|
51
|
+
en: c920cf9262f4a563
|
|
52
|
+
zh: 0f8e3ff17e39c861
|
|
53
|
+
/qilin-session-persistence/understand-the-implementation/stored-record-validation:
|
|
54
|
+
en: 15c4ae1366d07659
|
|
55
|
+
zh: d9d3fdea8484df9a
|
|
56
|
+
/qilin-session-persistence/further-exploration:
|
|
57
|
+
en: a0864feb3bb4b814
|
|
58
|
+
zh: b1196e3f0100500f
|
|
59
|
+
/qilin-session-persistence/model-experience:
|
|
60
|
+
en: 215e7ba838619b7b
|
|
61
|
+
zh: a311da3843709f90
|
|
62
|
+
/qilin-session-persistence/model-experience/resumed-conversation-history:
|
|
63
|
+
en: 062875aab7f3f138
|
|
64
|
+
zh: c7285918227e6f5c
|
|
65
|
+
/qilin-session-persistence/model-experience/resumed-conversation-history/what-the-model-sees:
|
|
66
|
+
en: f697ddcf1a13c439
|
|
67
|
+
zh: a3b5050484be4b9b
|
|
68
|
+
/qilin-session-persistence/model-experience/resumed-conversation-history/token-effect:
|
|
69
|
+
en: 375253768652244e
|
|
70
|
+
zh: c5bf6c3dcf707ab2
|
|
71
|
+
/qilin-session-persistence/model-experience/resumed-conversation-history/kv-cache-effect:
|
|
72
|
+
en: 70a8139de5fd1cec
|
|
73
|
+
zh: 66dfba4b93e63c55
|
|
74
|
+
/qilin-session-persistence/known-limitations-and-deferred-work:
|
|
75
|
+
en: 97296c31399996e4
|
|
76
|
+
zh: 86a5c1e771415e99
|
|
77
|
+
/qilin-session-persistence/known-limitations-and-deferred-work/dev-note:
|
|
78
|
+
en: e67f633dc6fe7773
|
|
79
|
+
zh: 3bb0c20c93f16f98
|
package/README.md
CHANGED
|
@@ -1,3 +1,163 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
description: "The durable session-storage seam for users and maintainers choosing a persistence backend, resuming sessions, or building a backend against the shared service contract."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
# @qilin-agent/session-persistence
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
This package lets applications persist and resume session event logs through a backend-independent API. Readers can create, open, inspect, list, append to, read, flush, and close stored sessions while preserving contiguous append-only history. A completed flush is the durability barrier; readers never receive torn tails or invalid records, and only one writer per session is allowed within a backend instance. Use the shipped [JSONL backend](../session-persistence-jsonl/README.md) for one compressed log per session, or implement another backend with the same observable guarantees.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
27
|
+
|
|
28
|
+
Mount one persistence backend to make sessions durable. The backend registers itself as `ctx.sessionPersistence` and routes every published session's live events into that session's active write handle; agent-loop — the production publication point for sessions — acquires each session's write handle before publication, so nothing else in the composition changes.
|
|
29
|
+
|
|
30
|
+
### Choosing a backend
|
|
31
|
+
|
|
32
|
+
The seam ships the [JSONL](../session-persistence-jsonl/README.md) backend: one append-only `.jsonl.zstd` log per session. A third-party backend may implement the service directly; the [backend contract](#understand-the-implementation) below is what it must honor.
|
|
33
|
+
|
|
34
|
+
### What the service provides
|
|
35
|
+
|
|
36
|
+
With a backend mounted, five service methods address stored sessions:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
const handle = await ctx.sessionPersistence.create(header) // store a new session, take write ownership
|
|
40
|
+
const handle = await ctx.sessionPersistence.open(id, 'write') // claim single-writer ownership of an existing session
|
|
41
|
+
const reader = await ctx.sessionPersistence.open(id, 'read') // observe without ownership
|
|
42
|
+
const snap = await ctx.sessionPersistence.stat(id) // header + revision (+ eventCount / sizeBytes) without a log read
|
|
43
|
+
const all = await ctx.sessionPersistence.list() // one snapshot per visible stored session
|
|
44
|
+
await ctx.sessionPersistence.flush() // backend-wide durability barrier over every active write handle
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Service-level `flush()` drains every active write handle's routed events and materializes its session, exactly as each handle's own `flush` would; failures aggregate per session as an `AggregateError` without abandoning the sweep, and a handle closed mid-sweep counts as flushed because close itself drains durably.
|
|
48
|
+
|
|
49
|
+
Every log read and write flows through the returned `SessionHandle`; there are no id-addressed append or load methods. `handle.read(offset?, length?)` returns `{ eventState, events }`: the outer slice belongs to the caller, while `eventState` distinguishes an exclusively `detached` event graph from a `shared-frozen` graph that may also reside in a backend cache. The producer establishes this state and slices preserve it even when empty. Both states are safe to adopt without copying; a consumer that needs mutable events clones them first. Reads never include a torn tail, repeated reads on one handle never observe an older state than a prior read, and a write handle reads its own successful appends. `handle.append(events)` appends a contiguous batch whose first `seq` equals the stored next-seq; persistence is best-effort on resolution — the batch is accepted, ordered, and visible to reads on this backend instance, and only a resolved `flush` promises it survives a crash (the shipped JSONL backend happens to persist each batch immediately). `handle.flush()` is the durability barrier and also materializes an empty created session so it becomes durably listable. `handle.close()` is idempotent and uncancellable: a read handle frees local resources; a write handle completes pending durability and releases write ownership. Once an `append` or `flush` resolves, reads started afterwards on the same backend instance — on any handle, or through `stat`/`list` — observe at least that prefix.
|
|
50
|
+
|
|
51
|
+
### Ownership and visibility
|
|
52
|
+
|
|
53
|
+
`create` and `open(id, 'write')` take in-process single-writer ownership: a second write open while an owner is active rejects with `SessionAlreadyOwnedError`, `create` on an occupied id rejects with `SessionAlreadyExistsError`, and a mutation on a `read` handle rejects with `SessionReadOnlyError` — one handle type, runtime refusal. Any operation on a closed handle rejects with `SessionHandleClosedError`, and `SessionOwnershipLostError` marks a write handle whose ownership is permanently gone (close it and reopen). A created session is observable in this process from the moment `create` resolves, while the backend may defer physical materialization until the first `append` or `flush`; other processes see only materialized sessions, and a session that never materialized before a crash never existed.
|
|
54
|
+
|
|
55
|
+
### The live write path and shutdown drain
|
|
56
|
+
|
|
57
|
+
The backend owns the live write path: it installs the session listeners once and routes every published session's events by id to that session's active write handle — `session/event` copies into a bounded internal batching window, `session/flush` is the immediate durability and error-observation barrier, and `session/disposed` runs the final drain and closes the handle. A published session without an active write handle persists nothing. A background write failure retains its events in order, pauses the automatic path, and is logged; the next explicit flush retries and rejects loudly. `close()` itself drains the routed buffer through the still-open storage before releasing ownership, so backend teardown's close sweep keeps application shutdown lossless even though root-fiber disposal runs fibers' disposers concurrently.
|
|
58
|
+
|
|
59
|
+
### Resuming and crash recovery
|
|
60
|
+
|
|
61
|
+
Persistence returns the physically valid log; semantic repair belongs to the reader. A session that crashed mid-turn keeps its open final turn — a single turn can be large, and those events were durably appended before the crash; only the incomplete fragment of a never-acknowledged torn tail is discarded — complete records recovered from it are durably rewritten by the write path before the handle's first new append. Resume (agent-loop) reads the stored log through its write handle, computes `interruptedTurnClosers` — synthetic `tool/result` errors, any open `step/end`, and `turn/end {interrupted}` — and appends them through the same handle as an ordinary batch. Read-only observers (session-query) balance an interrupted cold log with the same closers in memory only.
|
|
62
|
+
|
|
63
|
+
### Failures and recovery
|
|
64
|
+
|
|
65
|
+
A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SessionHandle` exposes only current logical records identified by `SESSION_FORMAT_VERSION`; a provider must convert any supported historical storage before returning a handle, and the shipped JSONL provider migrates supported historical generations through its static catalog. A newer format instructs the operator to upgrade the harness. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`.
|
|
66
|
+
|
|
67
|
+
-----
|
|
68
|
+
|
|
69
|
+
<a id="understand-the-implementation"></a>
|
|
70
|
+
## Understand the implementation
|
|
71
|
+
|
|
72
|
+
<details>
|
|
73
|
+
<summary>Implementation internals — click to expand</summary>
|
|
74
|
+
|
|
75
|
+
This section explains how the seam realizes durable storage and how backends plug in; the observable contract is covered in [Use this package](#use-this-package) and the generated [Kylin API](../../../docs/subsystems/persistence.md#kylin-surface).
|
|
76
|
+
|
|
77
|
+
### Design concept
|
|
78
|
+
|
|
79
|
+
The package is a seam, not a backend framework: it exports the abstract `SessionPersistence` service, the `SessionHandle` contract, the stable error classes consumers catch, the pure stored-record validation helpers (`storage-contract`), and the branded revision — nothing else. Each provider owns its complete storage runtime (handle class, mutation ordering, single-writer bookkeeping, live-event routing, teardown), and two shared test suites — `runPersistenceContract` and `runLiveWritePathContract` under `tests/` — pin the observable behavior every provider must agree on. Deliberate consequence: providers may resemble each other where their storage happens to be similar, but no implementation machinery crosses the package boundary.
|
|
80
|
+
|
|
81
|
+
### The invariants every backend honors
|
|
82
|
+
|
|
83
|
+
- **Append-only, contiguous `seq`.** Committed events are never rewritten; `append`'s first `seq` must equal the stored next-seq, and a gap rejects.
|
|
84
|
+
- **A torn physical tail never reaches a reader.** It belongs to an append that never resolved; the write path truncates it durably before its first new append.
|
|
85
|
+
- **Lossless JSON data.** Batches and headers pass the shared one-pass validate-and-snapshot boundary (`materializeAppendBatch`/`materializeCreateHeader`); non-serializable payloads reject at the call site.
|
|
86
|
+
- **Durability.** `append` persists best-effort; `flush` — per handle or service-wide — is the barrier that promises storage and also materializes an empty session.
|
|
87
|
+
- **Fail-closed reads.** `validateStoredEvents` refuses unknown event vocabulary and retired pre-release shapes; `assertVersion` refuses foreign format versions.
|
|
88
|
+
- **Single writer per backend instance.** The provider's in-process claim is taken at `create`/`open('write')` and released at handle close.
|
|
89
|
+
|
|
90
|
+
### Source map
|
|
91
|
+
|
|
92
|
+
| File | Role |
|
|
93
|
+
|---|---|
|
|
94
|
+
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `SessionPersistence` service and re-exported seam vocabulary |
|
|
95
|
+
| [`src/handle.ts`](src/handle.ts) | The `SessionHandle` contract: read/append/flush/close semantics and freshness rules |
|
|
96
|
+
| [`src/storage-contract.ts`](src/storage-contract.ts) | Shared validation: version gate, fail-closed vocabulary, batch materialization, contiguity |
|
|
97
|
+
| [`src/errors.ts`](src/errors.ts) | Stable handle/ownership failures and format refusals |
|
|
98
|
+
| [`src/revision.ts`](src/revision.ts) | The branded opaque revision token |
|
|
99
|
+
|
|
100
|
+
### The write path at a glance
|
|
101
|
+
|
|
102
|
+
Each `session/event` for the writer's session copies into that handle's internal buffer. The first pending event starts a fixed batching window; later events join without resetting its deadline. Expiry drains the pending prefix through the handle's mutation chain; events admitted during a drain coalesce into the next chained batch, in order. `session/flush` cancels the wait and drains through quiescence, then runs `handle.flush()`, so the loop uses it as the ordering and error-observation checkpoint before the next turn. A rejected background drain retains its events and pauses the automatic timer; explicit flush, writer close, or backend teardown retries immediately and rejects loudly. Constructor seed events never emit `session/event`, so a seed appended through the handle before publication is never re-enqueued.
|
|
103
|
+
|
|
104
|
+
### Stored-record validation
|
|
105
|
+
|
|
106
|
+
The seam's shared helpers validate current logical records identified by `SESSION_FORMAT_VERSION`, and appends write only the current format ([rationale](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). Historical decoding and immutable successor publication belong inside each provider before it returns a handle. Every backend runs `storage-contract` validation on handle reads and write-open priming, refusing an unknown event type as `SessionFormatUnsupportedError` and a malformed current record as `SessionPersistenceCorruptionError`, with the raw-log `SessionLocation` attached when the backend keeps one artifact per session.
|
|
107
|
+
|
|
108
|
+
</details>
|
|
109
|
+
-----
|
|
110
|
+
|
|
111
|
+
<a id="further-exploration"></a>
|
|
112
|
+
## Further Exploration
|
|
113
|
+
|
|
114
|
+
Read these pages when the package-level contract is not enough. They move from the shared durability model to the shipped backends and the decision evidence.
|
|
115
|
+
|
|
116
|
+
- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — the full service contract, handle semantics, flush checkpoint, crash recovery, and generated Kylin API.
|
|
117
|
+
- [Handle-based persistence Agent Note](../../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) — the seam design and its ownership model.
|
|
118
|
+
- [JSONL persistence backend](../session-persistence-jsonl/README.md) — the shipped per-session-file backend.
|
|
119
|
+
- [Session checkpoint policy](../session-checkpoint-policy/README.md) — the plugin that flushes through `session/flush` at semantic boundaries.
|
|
120
|
+
- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
|
|
121
|
+
|
|
122
|
+
-----
|
|
123
|
+
|
|
124
|
+
<a id="model-experience"></a>
|
|
125
|
+
## Model Experience
|
|
126
|
+
|
|
127
|
+
### Resumed conversation history
|
|
128
|
+
|
|
129
|
+
#### What the model sees
|
|
130
|
+
|
|
131
|
+
The seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly.
|
|
132
|
+
|
|
133
|
+
#### Token effect
|
|
134
|
+
|
|
135
|
+
Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text.
|
|
136
|
+
|
|
137
|
+
#### KV Cache effect
|
|
138
|
+
|
|
139
|
+
Persistence does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append without rewriting earlier history.
|
|
140
|
+
|
|
141
|
+
## Known Limitations and Deferred Work
|
|
142
|
+
|
|
143
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
These limits define where the seam's guarantees stop. They are current package constraints, not a task backlog.
|
|
147
|
+
|
|
148
|
+
- **The seam guarantees write ownership only within one backend instance** — cross-process exclusion is provider-specific. The shipped JSONL provider adds a kernel-backed lease across instances and processes; another provider must document an equivalent guarantee or require deployments to prevent concurrent writers.
|
|
149
|
+
- **A backend plugin reload under live sessions fails their writers loudly** — a reloaded backend cannot serve handles the old instance issued; writes fail until the sessions restart, and nothing silently re-adopts the logs.
|
|
150
|
+
- **Only handle-acquired sessions persist** — `ctx.sessions.create` + `session/flush` alone stores nothing; agent-loop is the production acquisition point, and tests seed storage through `create`/`append`/`close`.
|
|
151
|
+
- **No deletion or retention API** — pruning stored sessions is out-of-band backend maintenance.
|
|
152
|
+
- **`list()` is unpaginated and unfiltered** — it returns every stored session's snapshot; fine for local stores, unindexed at scale.
|
|
153
|
+
- **Synthetic closers are the only crash story** — resume appends `interruptedTurnClosers` through the write handle; there is no partial-turn resume that continues an interrupted turn instead of closing it.
|
|
154
|
+
|
|
155
|
+
<a id="dev-note"></a>
|
|
156
|
+
### Dev Note
|
|
157
|
+
|
|
158
|
+
<details>
|
|
159
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
160
|
+
|
|
161
|
+
None.
|
|
162
|
+
|
|
163
|
+
</details>
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "面向用户与维护者的持久会话存储 seam 说明,用于选择持久化后端、恢复会话,或按共享服务约定构建后端。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @qilin-agent/session-persistence
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
本包让应用通过后端无关的 API 持久存储并恢复会话事件日志。读者可以创建、打开、检查、列出、追加、读取、刷新和关闭已存储会话,同时保持连续且仅追加的历史记录。只有完成 flush 才构成持久性屏障;读取方不会收到撕裂尾部或无效记录,并且每个后端实例内每个会话只允许一个写入方。若希望每个会话使用一份压缩日志,可选用随产品交付的 [JSONL 后端](../session-persistence-jsonl/README.zh.md);也可以实现具备相同可观察保证的其他后端。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
27
|
+
|
|
28
|
+
挂载一个持久化后端即可让会话持久化。后端把自己注册为 `ctx.sessionPersistence`,并把每个已发布会话的实时事件路由进该会话的活跃写句柄;agent-loop——会话在生产环境中的发布点——在发布之前获取每个会话的写句柄,因此组合中的其他部分不变。
|
|
29
|
+
|
|
30
|
+
### 选择后端
|
|
31
|
+
|
|
32
|
+
seam 随产品交付 [JSONL](../session-persistence-jsonl/README.zh.md) 后端。它为每个会话存储一份仅追加的 `.jsonl.zstd` 日志。第三方后端可以直接实现该服务;必须遵守的[后端约定](#understand-the-implementation)见下文。
|
|
33
|
+
|
|
34
|
+
### 服务提供什么
|
|
35
|
+
|
|
36
|
+
挂载后端后,五个服务方法寻址已存储会话:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
const handle = await ctx.sessionPersistence.create(header) // store a new session, take write ownership
|
|
40
|
+
const handle = await ctx.sessionPersistence.open(id, 'write') // claim single-writer ownership of an existing session
|
|
41
|
+
const reader = await ctx.sessionPersistence.open(id, 'read') // observe without ownership
|
|
42
|
+
const snap = await ctx.sessionPersistence.stat(id) // header + revision (+ eventCount / sizeBytes) without a log read
|
|
43
|
+
const all = await ctx.sessionPersistence.list() // one snapshot per visible stored session
|
|
44
|
+
await ctx.sessionPersistence.flush() // backend-wide durability barrier over every active write handle
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
服务级 `flush()` 排空每个活跃写句柄已路由的事件并把其会话实体化,效果与各句柄自己的 `flush` 完全相同;失败按会话聚合为一个 `AggregateError` 而不中途放弃清扫,清扫途中被关闭的句柄视同已 flush,因为 close 本身会持久排空。
|
|
48
|
+
|
|
49
|
+
每一次日志读写都流经返回的 `SessionHandle`;不存在按 id 寻址的 append 或 load 方法。`handle.read(offset?, length?)` 返回 `{ eventState, events }`:外层 slice 属于调用方,`eventState` 则区分由调用方独占的 `detached` 事件图与可能同时位于后端缓存中的 `shared-frozen` 事件图。该状态由生成方确定,即使切片为空也会保留。两种状态都能直接接管而无需复制;需要可变事件的消费方必须先克隆事件。读取绝不包含撕裂尾部,同一句柄上的重复读取绝不会观察到比先前读取更旧的状态,写句柄也能读到自己成功的 append。`handle.append(events)` 追加一个连续批次,其第一个 `seq` 等于已存储 next-seq;完成时的持久化是尽力而为的——批次被接受、有序,并对同一后端实例上的读取可见,只有完成的 `flush` 才承诺它在崩溃后依然存在(交付的 JSONL 后端恰好会立即持久化每个批次)。`handle.flush()` 是持久性屏障,同时把空的已创建会话实体化,使其可被持久列出。`handle.close()` 幂等且不可取消:读句柄释放本地资源;写句柄完成待处理的持久化并释放写所有权。一旦某次 `append` 或 `flush` 完成,其后在同一后端实例上开始的读取——无论经由任何句柄,还是经由 `stat`/`list`——至少能观察到该前缀。
|
|
50
|
+
|
|
51
|
+
### 所有权与可见性
|
|
52
|
+
|
|
53
|
+
`create` 与 `open(id, 'write')` 取得进程内单写者所有权:在持有者活跃期间第二次以写模式打开会以 `SessionAlreadyOwnedError` 拒绝,对已占用 id 执行 `create` 会以 `SessionAlreadyExistsError` 拒绝,在 `read` 句柄上执行修改会以 `SessionReadOnlyError` 拒绝——一种句柄类型,运行时拒绝。对已关闭句柄的任何操作会以 `SessionHandleClosedError` 拒绝,`SessionOwnershipLostError` 标记写所有权已永久丢失的写句柄(关闭并重新打开)。已创建的会话自 `create` 完成之刻起即可在本进程内被观察到,而后端可以把物理实体化推迟到第一次 `append` 或 `flush`;其他进程只能看到已实体化的会话,一个在崩溃前从未实体化的会话等于从未存在。
|
|
54
|
+
|
|
55
|
+
### 实时写路径与关闭排空
|
|
56
|
+
|
|
57
|
+
实时写路径由后端自持:它一次性安装会话监听器,把每个已发布会话的事件按 id 路由到该会话的活跃写句柄——`session/event` 复制进有界的内部批处理窗口,`session/flush` 是即时的持久性与错误观察屏障,`session/disposed` 执行最终排空并关闭句柄。没有活跃写句柄的已发布会话不做任何持久化。后台写入失败时按序保留其事件、暂停自动路径并记入日志;下一次显式 flush 会重试,并在再次失败时明确返回拒绝。`close()` 本身会先经由仍然打开的存储排空路由缓冲区再释放所有权,因此即便根 fiber 的 dispose(资源释放)并发运行各 fiber 的 disposer,后端拆卸时的关闭清扫也能保证应用关闭不丢数据。
|
|
58
|
+
|
|
59
|
+
### 恢复与崩溃恢复
|
|
60
|
+
|
|
61
|
+
持久化返回物理上有效的日志;语义修复属于读方。中途崩溃的会话保留其未闭合的最终轮次——单个轮次可能很大,而这些事件在崩溃前已持久追加;只有从未确认的撕裂尾部中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在句柄的第一次新 append 之前持久重写。恢复(agent-loop)通过其写句柄读取已存储日志,计算 `interruptedTurnClosers`——合成 `tool/result` 错误、任何未闭合的 `step/end`,以及 `turn/end {interrupted}`——并把它们作为普通批次通过同一句柄追加。只读观察方(session-query)仅在内存中用同样的 closer 配平被中断的冷日志。
|
|
62
|
+
|
|
63
|
+
### 失败与恢复
|
|
64
|
+
|
|
65
|
+
当前构建无法忠实解读的存储日志会被拒绝,并返回指明拒绝方向的错误,绝不会被误读。`SessionHandle` 只暴露由 `SESSION_FORMAT_VERSION` 标识的当前逻辑记录;提供方必须在返回句柄前转换任何受支持的历史存储,随产品交付的 JSONL 提供方会通过静态 catalog 迁移受支持的历史代际。更新的格式会要求操作者升级 harness。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。
|
|
66
|
+
|
|
67
|
+
-----
|
|
68
|
+
|
|
69
|
+
<a id="understand-the-implementation"></a>
|
|
70
|
+
## 理解实现
|
|
71
|
+
|
|
72
|
+
<details>
|
|
73
|
+
<summary>实现细节——点击展开</summary>
|
|
74
|
+
|
|
75
|
+
本节说明 seam 如何实现持久存储以及后端如何接入;可观察约定见[使用本包](#use-this-package)与生成的 [Kylin API](../../../docs/subsystems/persistence.zh.md#kylin-surface)。
|
|
76
|
+
|
|
77
|
+
### 设计理念
|
|
78
|
+
|
|
79
|
+
本包是 seam,而不是后端框架:它只导出抽象 `SessionPersistence` 服务、`SessionHandle` 约定、消费方捕获的稳定错误类、纯函数的存储记录校验辅助(`storage-contract`)以及带品牌类型的修订值——再无其他。每个提供方拥有自己完整的存储运行时(句柄类、修改排序、单写者记账、实时事件路由、拆卸),`tests/` 下的两套共享测试套件——`runPersistenceContract` 与 `runLiveWritePathContract`——固定所有提供方都必须一致的可观察行为。有意为之的后果:各提供方在存储恰好相似之处可以彼此相像,但没有任何实现机制跨越包边界。
|
|
80
|
+
|
|
81
|
+
### 每个后端必须遵守的不变量
|
|
82
|
+
|
|
83
|
+
- **仅追加,连续 `seq`。** 已提交事件绝不重写;`append` 的第一个 `seq` 必须等于已存储 next-seq,缺口会被拒绝。
|
|
84
|
+
- **撕裂的物理尾部绝不到达读取方。** 它属于一次从未完成的 append;写路径在第一次新 append 之前将其持久截断。
|
|
85
|
+
- **无损 JSON 数据。** 批次与 header 经过共享的单遍校验并快照边界(`materializeAppendBatch`/`materializeCreateHeader`);无法序列化的载荷在调用处被拒绝。
|
|
86
|
+
- **持久性。** `append` 尽力而为地持久化;`flush`——逐句柄或服务级——是承诺存储并同时把空会话实体化的屏障。
|
|
87
|
+
- **遇到未知或无效格式时拒绝读取。** `validateStoredEvents` 拒绝未知事件词汇与已废弃的预发布形态;`assertVersion` 拒绝外来格式版本。
|
|
88
|
+
- **每个后端实例单写者。** 提供方的进程内认领在 `create`/`open('write')` 时取得,在句柄关闭时释放。
|
|
89
|
+
|
|
90
|
+
### 源码地图
|
|
91
|
+
|
|
92
|
+
| 文件 | 职责 |
|
|
93
|
+
|---|---|
|
|
94
|
+
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `SessionPersistence` 服务与重新导出的 seam 词汇 |
|
|
95
|
+
| [`src/handle.ts`](src/handle.ts) | `SessionHandle` 约定:read/append/flush/close 语义与新鲜度规则 |
|
|
96
|
+
| [`src/storage-contract.ts`](src/storage-contract.ts) | 共享校验:版本门禁、未知事件词汇拒绝、批次实体化、连续性 |
|
|
97
|
+
| [`src/errors.ts`](src/errors.ts) | 稳定的句柄/所有权失败与格式拒绝 |
|
|
98
|
+
| [`src/revision.ts`](src/revision.ts) | 带品牌类型的不透明修订值 token |
|
|
99
|
+
|
|
100
|
+
### 写入路径概览
|
|
101
|
+
|
|
102
|
+
写入器会话的每个 `session/event` 都复制进该句柄的内部缓冲。第一个待处理事件开启固定批处理窗口;后续事件加入但不重置截止时间。窗口到期后经由句柄的修改链排空待处理前缀;排空期间接纳的事件按顺序合并进下一个链上的批次。`session/flush` 取消等待并排空至完全停稳,随后运行 `handle.flush()`,因此 loop 在下一轮次前把它用作排序与错误观察检查点。失败的后台排空保留其事件并暂停自动计时器;显式 flush、写入器 close 或后端拆卸会立即重试,并在再次失败时明确返回拒绝。构造 seed 事件绝不发出 `session/event`,因此发布前通过句柄追加的 seed 绝不会被重新入队。
|
|
103
|
+
|
|
104
|
+
### 存储记录校验
|
|
105
|
+
|
|
106
|
+
seam 的共享辅助函数校验由 `SESSION_FORMAT_VERSION` 标识的当前逻辑记录,append 只写当前格式([理由](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。历史解码与不可变后继发布属于各提供方内部,并在其返回句柄前完成。每个后端都在句柄读取与写 open 预热时运行 `storage-contract` 校验,把未知事件类型作为 `SessionFormatUnsupportedError` 拒绝,把格式错误的当前记录作为 `SessionPersistenceCorruptionError` 拒绝,并在后端为每个会话保留一份产物时附上原始日志的 `SessionLocation`。
|
|
107
|
+
|
|
108
|
+
</details>
|
|
109
|
+
-----
|
|
110
|
+
|
|
111
|
+
<a id="further-exploration"></a>
|
|
112
|
+
## 进一步探索
|
|
113
|
+
|
|
114
|
+
当包级约定不够用时阅读以下页面。它们从共享持久性模型逐步进入随产品交付的后端与决策证据。
|
|
115
|
+
|
|
116
|
+
- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——完整服务约定、句柄语义、flush 检查点、崩溃恢复与生成的 Kylin API。
|
|
117
|
+
- [基于句柄的持久化 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)——seam 设计及其所有权模型。
|
|
118
|
+
- [JSONL 持久化后端](../session-persistence-jsonl/README.zh.md)——随产品交付、按会话存储文件的后端。
|
|
119
|
+
- [会话检查点策略](../session-checkpoint-policy/README.zh.md)——在语义边界上经由 `session/flush` 刷新的插件。
|
|
120
|
+
- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
|
|
121
|
+
|
|
122
|
+
-----
|
|
123
|
+
|
|
124
|
+
<a id="model-experience"></a>
|
|
125
|
+
## 模型体验
|
|
126
|
+
|
|
127
|
+
### 恢复的对话历史
|
|
128
|
+
|
|
129
|
+
#### 模型看到什么
|
|
130
|
+
|
|
131
|
+
seam 不添加提示词或 schema。恢复会将已存储的表层事件还原为消息历史;已存储请求 header 重建较早调用,新 loop 则为下一次请求组合当前系统提示词、工具与会话前缀。崩溃修复将没有持久调用的 assistant 请求标记为 `TOOL_NOT_STARTED`;有持久调用但无结果时变为 `TOOL_OUTCOME_UNKNOWN`,其文本允许模型重试只读或幂等工作,但要求验证副作用或询问用户,而不是盲目重试。
|
|
132
|
+
|
|
133
|
+
#### Token 影响
|
|
134
|
+
|
|
135
|
+
普通持久化期间为零 token。恢复后会重新计入保留历史的 token 用量,并照常计入当前请求 envelope 的 token 用量;每个已修复调用都会增加一段以引用形式保留的错误文本。
|
|
136
|
+
|
|
137
|
+
#### KV Cache 影响
|
|
138
|
+
|
|
139
|
+
持久化不修改当前请求前缀。只有当重建历史、当前 envelope 与模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果仅追加,不重写较早历史。
|
|
140
|
+
|
|
141
|
+
## 已知限制与延期工作
|
|
142
|
+
|
|
143
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
这些限制界定 seam 保证的终点。它们是当前包约束,不是待办事项。
|
|
147
|
+
|
|
148
|
+
- **seam 只保证单个后端实例内的写所有权**——跨进程排他由具体提供方负责。随产品交付的 JSONL 提供方通过内核锁在不同实例和进程之间提供租约;其他提供方必须记录等效保证,或要求部署方阻止并发写入。
|
|
149
|
+
- **在有活跃会话时重载后端插件会使其写入器明确报错**——重载后的后端无法服务旧实例签发的句柄;写入会持续失败直到会话重启,没有任何机制静默重新接管日志。
|
|
150
|
+
- **只有通过句柄获取的会话才会持久化**——仅靠 `ctx.sessions.create` + `session/flush` 不存储任何内容;agent-loop 是生产环境的获取点,测试通过 `create`/`append`/`close` 写入初始存储数据。
|
|
151
|
+
- **无删除或保留接口**——剪枝已存储会话属于带外后端维护。
|
|
152
|
+
- **`list()` 无分页且无过滤**——它返回每个已存储会话的快照;适合本地存储,大规模时无索引。
|
|
153
|
+
- **合成 closer 是唯一崩溃方案**——恢复通过写句柄追加 `interruptedTurnClosers`;没有继续中断轮次而不先关闭它的部分轮次恢复。
|
|
154
|
+
|
|
155
|
+
<a id="dev-note"></a>
|
|
156
|
+
### 开发备注
|
|
157
|
+
|
|
158
|
+
<details>
|
|
159
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
160
|
+
|
|
161
|
+
无。
|
|
162
|
+
|
|
163
|
+
</details>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { Service } from "@qilin-agent/kylin";
|
|
2
|
+
import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, adoptSessionEvent } from "@qilin-agent/session";
|
|
3
|
+
import { snapshotJsonValue } from "@qilin-agent/util-values";
|
|
4
|
+
//#region lib/types/revision.js
|
|
5
|
+
/** Opaque revision identity for lightweight persistence observations. */
|
|
6
|
+
/**
|
|
7
|
+
* Brand a backend revision for the provider-neutral persistence contract.
|
|
8
|
+
* @param value - backend-owned opaque revision representation.
|
|
9
|
+
* @returns the same runtime string with persistence-revision identity.
|
|
10
|
+
*/
|
|
11
|
+
function SessionPersistenceRevision(value) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region lib/types/errors.js
|
|
16
|
+
/**
|
|
17
|
+
* Stable failures exposed by the session-persistence service and its handles,
|
|
18
|
+
* including the format refusals shared by every backend: a stored log this
|
|
19
|
+
* build cannot faithfully interpret is refused, never misread, and the
|
|
20
|
+
* refusal points at the raw artifact when the backend keeps one per session.
|
|
21
|
+
* @module @qilin-agent/session-persistence/errors
|
|
22
|
+
*/
|
|
23
|
+
/** The requested Session identity has no durable log visible to this caller. */
|
|
24
|
+
var SessionPersistenceNotFoundError = class extends Error {
|
|
25
|
+
sessionId;
|
|
26
|
+
/** @param sessionId - absent durable Session identity. */
|
|
27
|
+
constructor(sessionId) {
|
|
28
|
+
super(`session "${sessionId}" not found`);
|
|
29
|
+
this.sessionId = sessionId;
|
|
30
|
+
this.name = "SessionPersistenceNotFoundError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** `create` targeted a Session identity that already exists in this backend. */
|
|
34
|
+
var SessionAlreadyExistsError = class extends Error {
|
|
35
|
+
sessionId;
|
|
36
|
+
/** @param sessionId - the occupied durable Session identity. */
|
|
37
|
+
constructor(sessionId) {
|
|
38
|
+
super(`session "${sessionId}" already exists`);
|
|
39
|
+
this.sessionId = sessionId;
|
|
40
|
+
this.name = "SessionAlreadyExistsError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/** A write open found the session already bound to an active write handle. */
|
|
44
|
+
var SessionAlreadyOwnedError = class extends Error {
|
|
45
|
+
sessionId;
|
|
46
|
+
/** @param sessionId - the session whose write ownership is taken. */
|
|
47
|
+
constructor(sessionId) {
|
|
48
|
+
super(`session "${sessionId}" is already owned by an active write handle`);
|
|
49
|
+
this.sessionId = sessionId;
|
|
50
|
+
this.name = "SessionAlreadyOwnedError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/** A mutation (`append`/`flush`) was called on a read handle. */
|
|
54
|
+
var SessionReadOnlyError = class extends Error {
|
|
55
|
+
sessionId;
|
|
56
|
+
/**
|
|
57
|
+
* @param sessionId - the session the read handle observes.
|
|
58
|
+
* @param operation - the refused mutating operation name.
|
|
59
|
+
*/
|
|
60
|
+
constructor(sessionId, operation) {
|
|
61
|
+
super(`session "${sessionId}": ${operation} is not available on a read handle`);
|
|
62
|
+
this.sessionId = sessionId;
|
|
63
|
+
this.name = "SessionReadOnlyError";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* A write handle's ownership is permanently gone: its lease expired, a renewal
|
|
68
|
+
* failed, or the durable ownership record no longer names this handle. The
|
|
69
|
+
* handle never re-acquires ownership — close it and reopen for write.
|
|
70
|
+
*
|
|
71
|
+
* Declared for the cross-process lease layer; the shipped in-process backends
|
|
72
|
+
* never throw it yet.
|
|
73
|
+
*/
|
|
74
|
+
var SessionOwnershipLostError = class extends Error {
|
|
75
|
+
sessionId;
|
|
76
|
+
/** @param sessionId - the session whose write ownership this handle lost. */
|
|
77
|
+
constructor(sessionId) {
|
|
78
|
+
super(`session "${sessionId}": write ownership was lost; close this handle and reopen`);
|
|
79
|
+
this.sessionId = sessionId;
|
|
80
|
+
this.name = "SessionOwnershipLostError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** An operation was called on a handle after `close()` was called. */
|
|
84
|
+
var SessionHandleClosedError = class extends Error {
|
|
85
|
+
sessionId;
|
|
86
|
+
/**
|
|
87
|
+
* @param sessionId - the session the closed handle addressed.
|
|
88
|
+
* @param operation - the refused operation name.
|
|
89
|
+
*/
|
|
90
|
+
constructor(sessionId, operation) {
|
|
91
|
+
super(`session "${sessionId}": ${operation} on a closed handle`);
|
|
92
|
+
this.sessionId = sessionId;
|
|
93
|
+
this.name = "SessionHandleClosedError";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** Durable session contents failed validation after a successful backend read. */
|
|
97
|
+
var SessionPersistenceCorruptionError = class extends Error {
|
|
98
|
+
/**
|
|
99
|
+
* @param message - stable corruption context.
|
|
100
|
+
* @param options - original validation failure.
|
|
101
|
+
*/
|
|
102
|
+
constructor(message, options) {
|
|
103
|
+
super(message, options);
|
|
104
|
+
this.name = "SessionPersistenceCorruptionError";
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The stored log is intact but this runtime cannot faithfully interpret it:
|
|
109
|
+
* the header carries an unsupported format version, or an event's type is
|
|
110
|
+
* unknown to this build. Distinct from {@link SessionPersistenceCorruptionError}
|
|
111
|
+
* — nothing is damaged; the raw log remains readable at {@link location} when
|
|
112
|
+
* the backend keeps one artifact per session.
|
|
113
|
+
*/
|
|
114
|
+
var SessionFormatUnsupportedError = class extends Error {
|
|
115
|
+
location;
|
|
116
|
+
/**
|
|
117
|
+
* @param message - stable reason the log cannot be interpreted, already
|
|
118
|
+
* including the raw-log path when one exists.
|
|
119
|
+
* @param location - the backend's artifact location, when one exists.
|
|
120
|
+
*/
|
|
121
|
+
constructor(message, location) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.location = location;
|
|
124
|
+
this.name = "SessionFormatUnsupportedError";
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Direction-aware refusal text for a stored session whose format version this
|
|
129
|
+
* build does not read. Shared by load-time checks and by backends that must
|
|
130
|
+
* refuse BEFORE decoding version-dependent structure (a future format may not
|
|
131
|
+
* satisfy this build's structural checks at all, and the user must see
|
|
132
|
+
* "upgrade the harness", never "corrupt").
|
|
133
|
+
* @param id - the stored session id, for message context.
|
|
134
|
+
* @param version - the stored format version.
|
|
135
|
+
* @returns the stable refusal text, without a raw-log path suffix.
|
|
136
|
+
*/
|
|
137
|
+
function sessionFormatVersionRefusal(id, version) {
|
|
138
|
+
return version > SESSION_FORMAT_VERSION ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region lib/types/storage-contract.js
|
|
142
|
+
/**
|
|
143
|
+
* Backend-shared storage validation: the version gate, the fail-closed event
|
|
144
|
+
* vocabulary, append-batch materialization, and contiguity — one place so
|
|
145
|
+
* every backend refuses the same inputs identically.
|
|
146
|
+
* @module @qilin-agent/session-persistence/storage-contract
|
|
147
|
+
*/
|
|
148
|
+
/** Build a format refusal that points at the raw artifact when the backend has one. */
|
|
149
|
+
function unsupported(reason, location) {
|
|
150
|
+
return new SessionFormatUnsupportedError(location === void 0 ? reason : `${reason} (raw log: ${location.path})`, location);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Refuse stored metadata that is not bound to the requested session id.
|
|
154
|
+
* @param id - the requested session id.
|
|
155
|
+
* @param meta - the stored header.
|
|
156
|
+
*/
|
|
157
|
+
function assertStoredId(id, meta) {
|
|
158
|
+
if (meta.id !== id) throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Refuse a header that has not been restored to the current logical format.
|
|
162
|
+
* @param meta - the stored header.
|
|
163
|
+
* @param location - the backend's artifact location for the refusal, when one exists.
|
|
164
|
+
*/
|
|
165
|
+
function assertVersion(meta, location) {
|
|
166
|
+
if (meta.version !== SESSION_FORMAT_VERSION) throw unsupported(sessionFormatVersionRefusal(meta.id, meta.version), location);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Validate one exclusively owned stored event array in place: adopt each
|
|
170
|
+
* record (validating and freezing it) and refuse any event type this build
|
|
171
|
+
* does not know, unless its writer marked it `ignorable: true` — silently
|
|
172
|
+
* skipping an unknown required event could reconstruct a wrong session (the
|
|
173
|
+
* envelope contract on `SessionEvent.ignorable`). Unknown required types and
|
|
174
|
+
* retired pre-release shapes refuse here; this validator performs no migration.
|
|
175
|
+
* @param meta - the stored header the events belong to.
|
|
176
|
+
* @param events - exclusively owned decoded events; validated in place.
|
|
177
|
+
* @param location - the backend's artifact location for refusals, when one exists.
|
|
178
|
+
* @returns the same array, validated and frozen.
|
|
179
|
+
* @throws {SessionFormatUnsupportedError} for unknown event types.
|
|
180
|
+
* @throws {SessionPersistenceCorruptionError} for records that fail validation.
|
|
181
|
+
*/
|
|
182
|
+
function validateStoredEvents(meta, events, location) {
|
|
183
|
+
for (const event of events) {
|
|
184
|
+
if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) throw unsupported(`session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, location);
|
|
185
|
+
if (event.type === "request/header") {
|
|
186
|
+
const data = event.data;
|
|
187
|
+
if (typeof data === "object" && data !== null && data["reason"] === "fallback") throw unsupported(`session "${meta.id}" contains a request/header event (seq ${event.seq}) with the unsupported legacy reason "fallback"; refusing to interpret the log — it was written by a retired pre-release harness`, location);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
for (const [index, event] of events.entries()) events[index] = adoptSessionEvent(event);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof SessionFormatUnsupportedError) throw error;
|
|
194
|
+
throw new SessionPersistenceCorruptionError(`stored session "${meta.id}" failed validation: ${String(error)}`, { cause: error });
|
|
195
|
+
}
|
|
196
|
+
return events;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Validate and deep-snapshot a header passed to `create` in one traversal.
|
|
200
|
+
* @param header - the caller's header.
|
|
201
|
+
* @returns the detached lossless-JSON header.
|
|
202
|
+
* @throws {TypeError} for non-JSON metadata or an invalid `createdAt`.
|
|
203
|
+
*/
|
|
204
|
+
function materializeCreateHeader(header) {
|
|
205
|
+
const snapshot = snapshotJsonValue(header);
|
|
206
|
+
if (snapshot === void 0) throw new TypeError("session metadata must be losslessly JSON-serializable");
|
|
207
|
+
if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) throw new TypeError("session metadata createdAt must be a non-negative safe integer");
|
|
208
|
+
return snapshot;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Validate and deep-snapshot one append batch in a single traversal, so the
|
|
212
|
+
* checked value is exactly the value persisted (a check followed by a copy
|
|
213
|
+
* could reread accessors into a different record).
|
|
214
|
+
* @param events - the caller's batch.
|
|
215
|
+
* @returns the detached lossless-JSON batch.
|
|
216
|
+
* @throws {TypeError} when any event data is not losslessly JSON-serializable.
|
|
217
|
+
*/
|
|
218
|
+
function materializeAppendBatch(events) {
|
|
219
|
+
const batch = snapshotJsonValue(events);
|
|
220
|
+
if (batch === void 0) throw new TypeError("session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data");
|
|
221
|
+
return batch;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Refuse a batch that does not contiguously continue the stored log.
|
|
225
|
+
* @param id - the session the batch belongs to.
|
|
226
|
+
* @param events - the batch, in seq order.
|
|
227
|
+
* @param cursor - the stored next-seq.
|
|
228
|
+
*/
|
|
229
|
+
function assertContiguous(id, events, cursor) {
|
|
230
|
+
for (const [index, event] of events.entries()) if (event.seq !== cursor + index) throw new Error(`append seq mismatch for "${id}": expected ${cursor + index} at index ${index}, got ${event.seq}`);
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region lib/types/index.js
|
|
234
|
+
/**
|
|
235
|
+
* Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store
|
|
236
|
+
* {@link SessionEvent}s as the event-sourced log and carry non-replayable
|
|
237
|
+
* {@link SessionHeader} metadata separately; callers address one stored
|
|
238
|
+
* session through a {@link SessionHandle} obtained from `create`/`open`.
|
|
239
|
+
* @module @qilin-agent/session-persistence
|
|
240
|
+
*/
|
|
241
|
+
/**
|
|
242
|
+
* Durable append-only session storage addressed through per-session handles.
|
|
243
|
+
*
|
|
244
|
+
* Storage semantics shared by every backend: events are contiguous from seq 0
|
|
245
|
+
* and never rewritten; a torn physical tail is never returned to a reader and
|
|
246
|
+
* is truncated by the write path before its first append; reads validate
|
|
247
|
+
* current-format records only and refuse unknown vocabulary fail-closed.
|
|
248
|
+
* `append` persists best-effort; `flush` — per handle or service-wide — is
|
|
249
|
+
* the durability barrier.
|
|
250
|
+
*
|
|
251
|
+
* Visibility: a created session is observable through `stat`/`list`/`open`
|
|
252
|
+
* in this process from the moment `create` resolves, even while a backend
|
|
253
|
+
* defers physical materialization (a pure optimization); other processes see
|
|
254
|
+
* the session only once it materializes, and a session that never
|
|
255
|
+
* materialized before a crash never existed. `SessionHandle.flush` forces
|
|
256
|
+
* materialization.
|
|
257
|
+
*
|
|
258
|
+
* Freshness: once an `append` or `flush` resolves, reads started afterwards
|
|
259
|
+
* on this backend instance observe at least that prefix.
|
|
260
|
+
*/
|
|
261
|
+
var SessionPersistence = class extends Service {
|
|
262
|
+
/** Process-local instance identity, stable through Context proxies and distinct after service replacement. */
|
|
263
|
+
identity = Symbol("sessionPersistence");
|
|
264
|
+
constructor(ctx) {
|
|
265
|
+
super(ctx, "sessionPersistence");
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
//#endregion
|
|
269
|
+
export { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionFormatUnsupportedError, SessionHandleClosedError, SessionOwnershipLostError, SessionPersistence, SessionPersistence as default, SessionPersistenceCorruptionError, SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, assertContiguous, assertStoredId, assertVersion, materializeAppendBatch, materializeCreateHeader, sessionFormatVersionRefusal, validateStoredEvents };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable failures exposed by the session-persistence service and its handles,
|
|
3
|
+
* including the format refusals shared by every backend: a stored log this
|
|
4
|
+
* build cannot faithfully interpret is refused, never misread, and the
|
|
5
|
+
* refusal points at the raw artifact when the backend keeps one per session.
|
|
6
|
+
* @module @qilin-agent/session-persistence/errors
|
|
7
|
+
*/
|
|
8
|
+
import type { SessionId } from '@qilin-agent/session';
|
|
9
|
+
/** The requested Session identity has no durable log visible to this caller. */
|
|
10
|
+
export declare class SessionPersistenceNotFoundError extends Error {
|
|
11
|
+
readonly sessionId: SessionId;
|
|
12
|
+
/** @param sessionId - absent durable Session identity. */
|
|
13
|
+
constructor(sessionId: SessionId);
|
|
14
|
+
}
|
|
15
|
+
/** `create` targeted a Session identity that already exists in this backend. */
|
|
16
|
+
export declare class SessionAlreadyExistsError extends Error {
|
|
17
|
+
readonly sessionId: SessionId;
|
|
18
|
+
/** @param sessionId - the occupied durable Session identity. */
|
|
19
|
+
constructor(sessionId: SessionId);
|
|
20
|
+
}
|
|
21
|
+
/** A write open found the session already bound to an active write handle. */
|
|
22
|
+
export declare class SessionAlreadyOwnedError extends Error {
|
|
23
|
+
readonly sessionId: SessionId;
|
|
24
|
+
/** @param sessionId - the session whose write ownership is taken. */
|
|
25
|
+
constructor(sessionId: SessionId);
|
|
26
|
+
}
|
|
27
|
+
/** A mutation (`append`/`flush`) was called on a read handle. */
|
|
28
|
+
export declare class SessionReadOnlyError extends Error {
|
|
29
|
+
readonly sessionId: SessionId;
|
|
30
|
+
/**
|
|
31
|
+
* @param sessionId - the session the read handle observes.
|
|
32
|
+
* @param operation - the refused mutating operation name.
|
|
33
|
+
*/
|
|
34
|
+
constructor(sessionId: SessionId, operation: string);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A write handle's ownership is permanently gone: its lease expired, a renewal
|
|
38
|
+
* failed, or the durable ownership record no longer names this handle. The
|
|
39
|
+
* handle never re-acquires ownership — close it and reopen for write.
|
|
40
|
+
*
|
|
41
|
+
* Declared for the cross-process lease layer; the shipped in-process backends
|
|
42
|
+
* never throw it yet.
|
|
43
|
+
*/
|
|
44
|
+
export declare class SessionOwnershipLostError extends Error {
|
|
45
|
+
readonly sessionId: SessionId;
|
|
46
|
+
/** @param sessionId - the session whose write ownership this handle lost. */
|
|
47
|
+
constructor(sessionId: SessionId);
|
|
48
|
+
}
|
|
49
|
+
/** An operation was called on a handle after `close()` was called. */
|
|
50
|
+
export declare class SessionHandleClosedError extends Error {
|
|
51
|
+
readonly sessionId: SessionId;
|
|
52
|
+
/**
|
|
53
|
+
* @param sessionId - the session the closed handle addressed.
|
|
54
|
+
* @param operation - the refused operation name.
|
|
55
|
+
*/
|
|
56
|
+
constructor(sessionId: SessionId, operation: string);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A backend-resolved, per-session local artifact location. Carried only by
|
|
60
|
+
* refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can
|
|
61
|
+
* find the raw log a build refused to interpret; it is not a consumer-facing
|
|
62
|
+
* query — log access goes through a session handle's `read`.
|
|
63
|
+
*/
|
|
64
|
+
export interface SessionLocation {
|
|
65
|
+
/** Backend-specific artifact kind, for example `jsonl`. */
|
|
66
|
+
readonly kind: string;
|
|
67
|
+
/** Absolute path to this session's backend-owned artifact. */
|
|
68
|
+
readonly path: string;
|
|
69
|
+
}
|
|
70
|
+
/** Durable session contents failed validation after a successful backend read. */
|
|
71
|
+
export declare class SessionPersistenceCorruptionError extends Error {
|
|
72
|
+
/**
|
|
73
|
+
* @param message - stable corruption context.
|
|
74
|
+
* @param options - original validation failure.
|
|
75
|
+
*/
|
|
76
|
+
constructor(message: string, options: ErrorOptions);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The stored log is intact but this runtime cannot faithfully interpret it:
|
|
80
|
+
* the header carries an unsupported format version, or an event's type is
|
|
81
|
+
* unknown to this build. Distinct from {@link SessionPersistenceCorruptionError}
|
|
82
|
+
* — nothing is damaged; the raw log remains readable at {@link location} when
|
|
83
|
+
* the backend keeps one artifact per session.
|
|
84
|
+
*/
|
|
85
|
+
export declare class SessionFormatUnsupportedError extends Error {
|
|
86
|
+
readonly location?: SessionLocation | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* @param message - stable reason the log cannot be interpreted, already
|
|
89
|
+
* including the raw-log path when one exists.
|
|
90
|
+
* @param location - the backend's artifact location, when one exists.
|
|
91
|
+
*/
|
|
92
|
+
constructor(message: string, location?: SessionLocation | undefined);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Direction-aware refusal text for a stored session whose format version this
|
|
96
|
+
* build does not read. Shared by load-time checks and by backends that must
|
|
97
|
+
* refuse BEFORE decoding version-dependent structure (a future format may not
|
|
98
|
+
* satisfy this build's structural checks at all, and the user must see
|
|
99
|
+
* "upgrade the harness", never "corrupt").
|
|
100
|
+
* @param id - the stored session id, for message context.
|
|
101
|
+
* @param version - the stored format version.
|
|
102
|
+
* @returns the stable refusal text, without a raw-log path suffix.
|
|
103
|
+
*/
|
|
104
|
+
export declare function sessionFormatVersionRefusal(id: string, version: number): string;
|
|
105
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-session storage handle: one open channel onto a stored session's
|
|
3
|
+
* append-only event log, returned by `SessionPersistence.create`/`open`.
|
|
4
|
+
* @module @qilin-agent/session-persistence/handle
|
|
5
|
+
*/
|
|
6
|
+
import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset, SessionSeedEventState } from '@qilin-agent/session';
|
|
7
|
+
/**
|
|
8
|
+
* Log access granted by an open. `write` is read-write: the session's single
|
|
9
|
+
* mutator, which also reads its own log. `read` only observes — it never
|
|
10
|
+
* takes ownership and works while another handle or process holds `write`.
|
|
11
|
+
*/
|
|
12
|
+
export type SessionAccess = 'read' | 'write';
|
|
13
|
+
/** Options for {@link SessionHandle.read}. */
|
|
14
|
+
export interface SessionHandleReadOptions {
|
|
15
|
+
/** Optional cancellation for backend read work. */
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
/** One persistence event slice returned by {@link SessionHandle.read}. */
|
|
19
|
+
export interface SessionHandleReadResult {
|
|
20
|
+
/**
|
|
21
|
+
* Whether event values are exclusively owned or shared only after deep
|
|
22
|
+
* freezing. Slicing preserves the producer's state even when no events remain.
|
|
23
|
+
*/
|
|
24
|
+
readonly eventState: SessionSeedEventState;
|
|
25
|
+
/** Event values in a caller-owned outer array. */
|
|
26
|
+
readonly events: readonly SessionEvent[];
|
|
27
|
+
}
|
|
28
|
+
/** Options for {@link SessionHandle.append}. */
|
|
29
|
+
export interface SessionHandleAppendOptions {
|
|
30
|
+
/** Optional cancellation observed before the write starts. */
|
|
31
|
+
readonly signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
/** Options for {@link SessionHandle.flush}. */
|
|
34
|
+
export interface SessionHandleFlushOptions {
|
|
35
|
+
/** Optional cancellation observed before the barrier starts. */
|
|
36
|
+
readonly signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One open channel onto a stored session. A handle is single-owner state, not
|
|
40
|
+
* a shared service: `read` never backtracks below what this handle already
|
|
41
|
+
* observed, a `write` handle reads its own successful appends, and `close()`
|
|
42
|
+
* is the one teardown (idempotent, uncancellable; `Symbol.asyncDispose`
|
|
43
|
+
* delegates to it). Every operation on a closed handle rejects with
|
|
44
|
+
* `SessionHandleClosedError`.
|
|
45
|
+
*
|
|
46
|
+
* Freshness across handles: once an `append` or `flush` resolves on a write
|
|
47
|
+
* handle, every read STARTED afterwards on the same backend instance — on any
|
|
48
|
+
* handle, or through `stat`/`list` — observes at least that prefix.
|
|
49
|
+
* Reads concurrent with a mutation carry no ordering promise beyond the valid
|
|
50
|
+
* contiguous prefix.
|
|
51
|
+
*/
|
|
52
|
+
export interface SessionHandle extends AsyncDisposable {
|
|
53
|
+
/** The stored session this handle addresses. */
|
|
54
|
+
readonly id: SessionId;
|
|
55
|
+
/** The immutable stored header, fixed at `create`/`open`. */
|
|
56
|
+
readonly header: SessionHeader;
|
|
57
|
+
/**
|
|
58
|
+
* Exact fork-inherited prefix length stored with the log; `0` when
|
|
59
|
+
* `header.isSeeded` is false. Storage metadata paired with the header for
|
|
60
|
+
* every body read, never part of the replayable event log.
|
|
61
|
+
*/
|
|
62
|
+
readonly inheritedEventCount: SessionLogOffset;
|
|
63
|
+
/** Whether this handle may mutate the log. */
|
|
64
|
+
readonly access: SessionAccess;
|
|
65
|
+
/**
|
|
66
|
+
* Read a slice of the valid contiguous logical log. The slice is a legal log
|
|
67
|
+
* prefix segment: a torn physical tail is never returned, and repeated reads
|
|
68
|
+
* on this handle never observe an older state than a prior read.
|
|
69
|
+
* @param offset - first logical event seq to include; defaults to `0`.
|
|
70
|
+
* @param length - maximum number of events to return; defaults to the rest
|
|
71
|
+
* of the log. An offset at or past the end returns an empty list.
|
|
72
|
+
* @param options - optional cancellation.
|
|
73
|
+
* @returns the caller-owned outer slice plus the ownership state of its event values.
|
|
74
|
+
*/
|
|
75
|
+
read(offset?: number, length?: number, options?: SessionHandleReadOptions): Promise<SessionHandleReadResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Append a contiguous batch continuing the current logical end. The first
|
|
78
|
+
* event's `seq` MUST equal the stored next-seq; committed events are never
|
|
79
|
+
* rewritten. Persistence is best-effort: on resolution the batch is
|
|
80
|
+
* accepted, ordered, and visible to reads on this backend instance, but
|
|
81
|
+
* only a resolved {@link flush} promises it survives a crash — a backend
|
|
82
|
+
* may buffer or batch physical writes behind append. Rejects with
|
|
83
|
+
* `SessionReadOnlyError` on a read handle and `SessionOwnershipLostError`
|
|
84
|
+
* when write ownership is gone.
|
|
85
|
+
* @param events - the contiguous batch, in seq order.
|
|
86
|
+
* @param options - optional cancellation observed before the write starts.
|
|
87
|
+
*/
|
|
88
|
+
append(events: readonly SessionEvent[], options?: SessionHandleAppendOptions): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* The durability barrier — the one operation that promises storage: on
|
|
91
|
+
* resolution every acknowledged append is durable and the session is
|
|
92
|
+
* materialized for other processes; an empty created session becomes
|
|
93
|
+
* durably listable here. Callers that must survive a crash flush; a backend
|
|
94
|
+
* whose `append` already persists on resolution treats this as
|
|
95
|
+
* materialize-if-needed. Rejects with `SessionReadOnlyError` on a read
|
|
96
|
+
* handle.
|
|
97
|
+
* @param options - optional cancellation observed before the barrier starts.
|
|
98
|
+
*/
|
|
99
|
+
flush(options?: SessionHandleFlushOptions): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Release the handle: a read handle frees local resources; a write handle
|
|
102
|
+
* completes pending durability and releases write ownership. Idempotent,
|
|
103
|
+
* asynchronous, and deliberately not cancellable.
|
|
104
|
+
*/
|
|
105
|
+
close(): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=handle.d.ts.map
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store
|
|
3
|
+
* {@link SessionEvent}s as the event-sourced log and carry non-replayable
|
|
4
|
+
* {@link SessionHeader} metadata separately; callers address one stored
|
|
5
|
+
* session through a {@link SessionHandle} obtained from `create`/`open`.
|
|
6
|
+
* @module @qilin-agent/session-persistence
|
|
7
|
+
*/
|
|
8
|
+
import { Context, Service } from '@qilin-agent/kylin';
|
|
9
|
+
import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@qilin-agent/session';
|
|
10
|
+
import type { SessionHandle, SessionAccess } from './handle.ts';
|
|
11
|
+
import type { SessionPersistenceRevision } from './revision.ts';
|
|
12
|
+
export type { SessionHeader } from '@qilin-agent/session';
|
|
13
|
+
export { SessionPersistenceRevision } from './revision.ts';
|
|
14
|
+
export type { SessionAccess, SessionHandle, SessionHandleAppendOptions, SessionHandleFlushOptions, SessionHandleReadOptions, SessionHandleReadResult, } from './handle.ts';
|
|
15
|
+
export { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionFormatUnsupportedError, SessionHandleClosedError, SessionOwnershipLostError, SessionPersistenceCorruptionError, SessionPersistenceNotFoundError, SessionReadOnlyError, sessionFormatVersionRefusal, } from './errors.ts';
|
|
16
|
+
export type { SessionLocation } from './errors.ts';
|
|
17
|
+
export { assertContiguous, assertStoredId, assertVersion, materializeAppendBatch, materializeCreateHeader, validateStoredEvents, } from './storage-contract.ts';
|
|
18
|
+
/**
|
|
19
|
+
* Lightweight stored-session observation returned by {@link SessionPersistence.stat}
|
|
20
|
+
* and {@link SessionPersistence.list} without reading the full event log.
|
|
21
|
+
*/
|
|
22
|
+
export interface SessionPersistenceSnapshot {
|
|
23
|
+
/** Detached metadata for one stored session. */
|
|
24
|
+
readonly header: SessionHeader;
|
|
25
|
+
/** Opaque change token; see {@link SessionPersistence.stat}. */
|
|
26
|
+
readonly revision: SessionPersistenceRevision;
|
|
27
|
+
/** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */
|
|
28
|
+
readonly eventCount?: number;
|
|
29
|
+
/** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */
|
|
30
|
+
readonly sizeBytes?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Options for {@link SessionPersistence.create}. */
|
|
33
|
+
export interface SessionPersistenceCreateOptions {
|
|
34
|
+
/** Optional cancellation observed before backend work starts. */
|
|
35
|
+
readonly signal?: AbortSignal;
|
|
36
|
+
/**
|
|
37
|
+
* Exact fork-inherited prefix length. Required when `header.isSeeded` is
|
|
38
|
+
* true and must be omitted (or `0`) otherwise; the backend refuses a
|
|
39
|
+
* mismatch at create.
|
|
40
|
+
*/
|
|
41
|
+
readonly inheritedEventCount?: SessionLogOffset;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Logical Session header paired with its exact inherited cut for body-bearing
|
|
45
|
+
* storage operations. `isSeeded` marks fork lineage on the header; the
|
|
46
|
+
* numeric cut travels beside it, never inside the replayable event log.
|
|
47
|
+
*/
|
|
48
|
+
export interface SessionStorageMetadata {
|
|
49
|
+
/** Validated immutable Session header. */
|
|
50
|
+
readonly meta: SessionHeader;
|
|
51
|
+
/** Number of leading events inherited from the Session's fork parent. */
|
|
52
|
+
readonly inheritedEventCount: SessionLogOffset;
|
|
53
|
+
}
|
|
54
|
+
/** Immutable logical session read: storage metadata plus the complete validated event log. */
|
|
55
|
+
export interface SessionInspection extends SessionStorageMetadata {
|
|
56
|
+
/** Contiguous validated events from seq 0. */
|
|
57
|
+
readonly events: readonly SessionEvent[];
|
|
58
|
+
}
|
|
59
|
+
/** Options for {@link SessionPersistence.open}. */
|
|
60
|
+
export interface SessionPersistenceOpenOptions {
|
|
61
|
+
/** Optional cancellation observed before backend work starts. */
|
|
62
|
+
readonly signal?: AbortSignal;
|
|
63
|
+
}
|
|
64
|
+
/** Options for {@link SessionPersistence.stat}. */
|
|
65
|
+
export interface SessionPersistenceStatOptions {
|
|
66
|
+
/** Optional cancellation for backend metadata reads. */
|
|
67
|
+
readonly signal?: AbortSignal;
|
|
68
|
+
}
|
|
69
|
+
/** Options for {@link SessionPersistence.list}. */
|
|
70
|
+
export interface SessionPersistenceListOptions {
|
|
71
|
+
/** Optional cancellation for backend listing work. */
|
|
72
|
+
readonly signal?: AbortSignal;
|
|
73
|
+
}
|
|
74
|
+
declare module '@qilin-agent/kylin' {
|
|
75
|
+
interface Context {
|
|
76
|
+
sessionPersistence: SessionPersistence;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Durable append-only session storage addressed through per-session handles.
|
|
81
|
+
*
|
|
82
|
+
* Storage semantics shared by every backend: events are contiguous from seq 0
|
|
83
|
+
* and never rewritten; a torn physical tail is never returned to a reader and
|
|
84
|
+
* is truncated by the write path before its first append; reads validate
|
|
85
|
+
* current-format records only and refuse unknown vocabulary fail-closed.
|
|
86
|
+
* `append` persists best-effort; `flush` — per handle or service-wide — is
|
|
87
|
+
* the durability barrier.
|
|
88
|
+
*
|
|
89
|
+
* Visibility: a created session is observable through `stat`/`list`/`open`
|
|
90
|
+
* in this process from the moment `create` resolves, even while a backend
|
|
91
|
+
* defers physical materialization (a pure optimization); other processes see
|
|
92
|
+
* the session only once it materializes, and a session that never
|
|
93
|
+
* materialized before a crash never existed. `SessionHandle.flush` forces
|
|
94
|
+
* materialization.
|
|
95
|
+
*
|
|
96
|
+
* Freshness: once an `append` or `flush` resolves, reads started afterwards
|
|
97
|
+
* on this backend instance observe at least that prefix.
|
|
98
|
+
*/
|
|
99
|
+
export declare abstract class SessionPersistence extends Service {
|
|
100
|
+
/** Process-local instance identity, stable through Context proxies and distinct after service replacement. */
|
|
101
|
+
readonly identity: symbol;
|
|
102
|
+
constructor(ctx: Context);
|
|
103
|
+
/**
|
|
104
|
+
* Create a new stored session and take its write ownership.
|
|
105
|
+
* @param header - the immutable header (id, version, cwd, lineage) to store.
|
|
106
|
+
* @param options - optional cancellation.
|
|
107
|
+
* @returns a `write` handle owned by the caller; close it to release ownership.
|
|
108
|
+
* @throws {SessionAlreadyExistsError} when the id already exists.
|
|
109
|
+
*/
|
|
110
|
+
abstract create(header: SessionHeader, options?: SessionPersistenceCreateOptions): Promise<SessionHandle>;
|
|
111
|
+
/**
|
|
112
|
+
* Open an existing stored session.
|
|
113
|
+
*
|
|
114
|
+
* `read` never takes ownership and works while another handle (or process)
|
|
115
|
+
* holds write ownership. `write` atomically claims single-writer ownership;
|
|
116
|
+
* an existing active owner rejects.
|
|
117
|
+
* @param id - the stored session to open.
|
|
118
|
+
* @param access - `read` or `write`.
|
|
119
|
+
* @param options - optional cancellation.
|
|
120
|
+
* @returns the open handle.
|
|
121
|
+
* @throws {SessionPersistenceNotFoundError} when the session does not exist.
|
|
122
|
+
* @throws {SessionAlreadyOwnedError} for `write` when ownership is taken.
|
|
123
|
+
*/
|
|
124
|
+
abstract open(id: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions): Promise<SessionHandle>;
|
|
125
|
+
/**
|
|
126
|
+
* Flush every active write handle owned by this service instance in one
|
|
127
|
+
* durability barrier: each handle's routed live events drain durably and
|
|
128
|
+
* its session materializes, exactly as that handle's own
|
|
129
|
+
* `SessionHandle.flush` would. Read handles buffer nothing and are
|
|
130
|
+
* untouched. A handle closed concurrently counts as flushed — close itself
|
|
131
|
+
* drains durably.
|
|
132
|
+
* @returns resolution once every write handle active at the call has flushed.
|
|
133
|
+
* @throws {AggregateError} naming each session whose flush failed; the
|
|
134
|
+
* remaining handles still flush.
|
|
135
|
+
*/
|
|
136
|
+
abstract flush(): Promise<void>;
|
|
137
|
+
/**
|
|
138
|
+
* Observe one stored session without reading its event log or taking
|
|
139
|
+
* ownership.
|
|
140
|
+
*
|
|
141
|
+
* The snapshot's `revision` is an opaque change token comparable only
|
|
142
|
+
* against revisions from the same service instance and session id: equal
|
|
143
|
+
* revisions may be treated as an unchanged log; unequal revisions promise
|
|
144
|
+
* nothing. Write-ownership churn does not change a revision. It exists for
|
|
145
|
+
* derived read-model caches keyed off `stat`/`list`; it plays no part in
|
|
146
|
+
* open, read, or resume.
|
|
147
|
+
* @param id - the stored session to observe.
|
|
148
|
+
* @param options - optional cancellation.
|
|
149
|
+
* @returns the snapshot, or `undefined` when the session does not exist.
|
|
150
|
+
*/
|
|
151
|
+
abstract stat(id: SessionId, options?: SessionPersistenceStatOptions): Promise<SessionPersistenceSnapshot | undefined>;
|
|
152
|
+
/**
|
|
153
|
+
* List every stored session visible to this process, in no promised order.
|
|
154
|
+
* @param options - optional cancellation.
|
|
155
|
+
* @returns one snapshot per stored session.
|
|
156
|
+
*/
|
|
157
|
+
abstract list(options?: SessionPersistenceListOptions): Promise<readonly SessionPersistenceSnapshot[]>;
|
|
158
|
+
}
|
|
159
|
+
export default SessionPersistence;
|
|
160
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Opaque revision identity for lightweight persistence observations. */
|
|
2
|
+
import type { Branded } from '@qilin-agent/brand';
|
|
3
|
+
/**
|
|
4
|
+
* Backend-owned token that identifies both one storage source and one revision
|
|
5
|
+
* of a persisted session log.
|
|
6
|
+
*/
|
|
7
|
+
export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>;
|
|
8
|
+
/**
|
|
9
|
+
* Brand a backend revision for the provider-neutral persistence contract.
|
|
10
|
+
* @param value - backend-owned opaque revision representation.
|
|
11
|
+
* @returns the same runtime string with persistence-revision identity.
|
|
12
|
+
*/
|
|
13
|
+
export declare function SessionPersistenceRevision(value: string): SessionPersistenceRevision;
|
|
14
|
+
//# sourceMappingURL=revision.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-shared storage validation: the version gate, the fail-closed event
|
|
3
|
+
* vocabulary, append-batch materialization, and contiguity — one place so
|
|
4
|
+
* every backend refuses the same inputs identically.
|
|
5
|
+
* @module @qilin-agent/session-persistence/storage-contract
|
|
6
|
+
*/
|
|
7
|
+
import type { SessionEvent, SessionHeader, SessionId } from '@qilin-agent/session';
|
|
8
|
+
import { type SessionLocation } from './errors.ts';
|
|
9
|
+
/**
|
|
10
|
+
* Refuse stored metadata that is not bound to the requested session id.
|
|
11
|
+
* @param id - the requested session id.
|
|
12
|
+
* @param meta - the stored header.
|
|
13
|
+
*/
|
|
14
|
+
export declare function assertStoredId(id: SessionId, meta: SessionHeader): void;
|
|
15
|
+
/**
|
|
16
|
+
* Refuse a header that has not been restored to the current logical format.
|
|
17
|
+
* @param meta - the stored header.
|
|
18
|
+
* @param location - the backend's artifact location for the refusal, when one exists.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertVersion(meta: {
|
|
21
|
+
readonly id: SessionId;
|
|
22
|
+
readonly version: number;
|
|
23
|
+
}, location?: SessionLocation): void;
|
|
24
|
+
/**
|
|
25
|
+
* Validate one exclusively owned stored event array in place: adopt each
|
|
26
|
+
* record (validating and freezing it) and refuse any event type this build
|
|
27
|
+
* does not know, unless its writer marked it `ignorable: true` — silently
|
|
28
|
+
* skipping an unknown required event could reconstruct a wrong session (the
|
|
29
|
+
* envelope contract on `SessionEvent.ignorable`). Unknown required types and
|
|
30
|
+
* retired pre-release shapes refuse here; this validator performs no migration.
|
|
31
|
+
* @param meta - the stored header the events belong to.
|
|
32
|
+
* @param events - exclusively owned decoded events; validated in place.
|
|
33
|
+
* @param location - the backend's artifact location for refusals, when one exists.
|
|
34
|
+
* @returns the same array, validated and frozen.
|
|
35
|
+
* @throws {SessionFormatUnsupportedError} for unknown event types.
|
|
36
|
+
* @throws {SessionPersistenceCorruptionError} for records that fail validation.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateStoredEvents(meta: SessionHeader, events: SessionEvent[], location?: SessionLocation): SessionEvent[];
|
|
39
|
+
/**
|
|
40
|
+
* Validate and deep-snapshot a header passed to `create` in one traversal.
|
|
41
|
+
* @param header - the caller's header.
|
|
42
|
+
* @returns the detached lossless-JSON header.
|
|
43
|
+
* @throws {TypeError} for non-JSON metadata or an invalid `createdAt`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function materializeCreateHeader(header: SessionHeader): SessionHeader;
|
|
46
|
+
/**
|
|
47
|
+
* Validate and deep-snapshot one append batch in a single traversal, so the
|
|
48
|
+
* checked value is exactly the value persisted (a check followed by a copy
|
|
49
|
+
* could reread accessors into a different record).
|
|
50
|
+
* @param events - the caller's batch.
|
|
51
|
+
* @returns the detached lossless-JSON batch.
|
|
52
|
+
* @throws {TypeError} when any event data is not losslessly JSON-serializable.
|
|
53
|
+
*/
|
|
54
|
+
export declare function materializeAppendBatch(events: readonly SessionEvent[]): readonly SessionEvent[];
|
|
55
|
+
/**
|
|
56
|
+
* Refuse a batch that does not contiguously continue the stored log.
|
|
57
|
+
* @param id - the session the batch belongs to.
|
|
58
|
+
* @param events - the batch, in seq order.
|
|
59
|
+
* @param cursor - the stored next-seq.
|
|
60
|
+
*/
|
|
61
|
+
export declare function assertContiguous(id: SessionId, events: readonly SessionEvent[], cursor: number): void;
|
|
62
|
+
//# sourceMappingURL=storage-contract.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qilin-agent/session-persistence",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the QiLin",
|
|
4
|
+
"version": "3.1.3",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kkutysllb/QiLin.git",
|
|
11
|
+
"directory": "packages/session/session-persistence"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"types": "lib/types/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/types/index.d.ts",
|
|
22
|
+
"default": "./lib/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./src/*": "./src/*",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"lib/types/**/*.d.ts"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
34
|
+
"@qilin-agent/brand": "3.1.3",
|
|
35
|
+
"@qilin-agent/timeout": "3.1.3",
|
|
36
|
+
"@qilin-agent/session": "3.1.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
40
|
+
"@qilin-agent/scope": "3.1.3",
|
|
41
|
+
"@qilin-agent/session": "3.1.3",
|
|
42
|
+
"@qilin-agent/timeout": "3.1.3",
|
|
43
|
+
"@qilin-agent/brand": "3.1.3"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@qilin-agent/util-values": "3.1.3"
|
|
47
|
+
}
|
|
6
48
|
}
|