dsh-bettertaskgraphs 0.3.1-rc.35 → 0.3.1-rc.36

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.
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "updateRound": 3,
4
- "slot": 5,
5
- "branch": "0.2.1-alpha.2",
6
- "host": "0.2.1-alpha.2",
4
+ "slot": 6,
5
+ "branch": "0.2.1-alpha.1",
6
+ "host": "0.2.1-alpha.1",
7
7
  "hosts": [
8
- "0.2.1-alpha.2"
8
+ "0.2.1-alpha.1"
9
9
  ],
10
- "channel": "dsh-0.2.1-alpha.2",
11
- "taskgraph": "0.3.1-rc.35",
12
- "dynamicContext": "0.1.1-rc.35"
10
+ "channel": "dsh-0.2.1-alpha.1",
11
+ "taskgraph": "0.3.1-rc.36",
12
+ "dynamicContext": "0.1.1-rc.36"
13
13
  }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-bettertaskgraphs
2
2
 
3
- 本构建版本为 `0.3.1-rc.35`。适用宿主和配套版本见 [兼容清单](DSH-COMPATIBILITY.json)。
3
+ 本构建版本为 `0.3.1-rc.36`。适用宿主和配套版本见 [兼容清单](DSH-COMPATIBILITY.json)。
4
4
 
5
5
  独立运行保留动态上下文开发前 `0.2.3` 的五个工具、初始指引与事务语义;通用扩展接口供其他插件登记工作流和会话策略。动态上下文装配、修改审批及其提示词由扩展插件实现。
6
6
 
@@ -12,9 +12,9 @@ DeepSeek Harness 的事务型任务图 Bundle,包含模型侧的任务图工
12
12
 
13
13
  ## 兼容范围
14
14
 
15
- 分支 `0.2.1-alpha.2` 采用 npm 标签 `dsh-0.2.1-alpha.2`。配套动态上下文版本为 `0.1.1-rc.35`。
15
+ 分支 `0.2.1-alpha.1` 采用 npm 标签 `dsh-0.2.1-alpha.1`。配套动态上下文版本为 `0.1.1-rc.36`。
16
16
 
17
- - DSH `0.2.1-alpha.2`,使用 Web profile。
17
+ - DSH `0.2.1-alpha.1`,使用 Web profile。
18
18
  - Node.js `^22.19.0 || >=24.0.0`,开发用 npm。此包与 DSH 共享会话、工具和远程调用接口,升级 DSH 时需要重新验证这些接口。
19
19
 
20
20
  ## 构建和打包
@@ -26,7 +26,7 @@ npm test
26
26
  npm run pack:bundle
27
27
  ```
28
28
 
29
- `build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.3.1-rc.35.tgz`。包内包含服务端模块、Web 构建产物、对应源码和构建脚本,以及 Bundle 配置补丁。
29
+ `build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.3.1-rc.36.tgz`。包内包含服务端模块、Web 构建产物、对应源码和构建脚本,以及 Bundle 配置补丁。
30
30
 
31
31
  ## 安装到独立 Web profile
32
32
 
@@ -34,7 +34,7 @@ npm run pack:bundle
34
34
 
35
35
  ```sh
36
36
  dsh --profile bettertaskgraphs --from-default-profile web --dump-config
37
- dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.3.1-rc.35.tgz"
37
+ dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.3.1-rc.36.tgz"
38
38
  dsh --profile bettertaskgraphs --dump-config
39
39
  dsh --profile bettertaskgraphs
40
40
  ```
@@ -43,6 +43,8 @@ dsh --profile bettertaskgraphs
43
43
 
44
44
  ## 工作区与已有任务图
45
45
 
46
+ 在这条维护线上,宿主 JSONL 目录保存独立的插件会话记录与标记。备份和恢复会话时应一起保留,任务图事务仍以宿主持久化的原始工具记录校验证据。插件会话记录缺失时,先从完整备份恢复。
47
+
46
48
  Bundle 默认将会话创建时的工作目录作为项目根,账本位于根目录下的 `.dsh/transaction-graphs-v4/`。后续工作目录必须位于该根内,账本路径拒绝符号链接。也可以在 profile 的 `cordis.patch.yml` 中固定项目根。
47
49
 
48
50
  ```yaml
package/VALIDATION.md CHANGED
@@ -1,4 +1,4 @@
1
- # Candidate 0.3.1-rc.35
1
+ # Candidate 0.3.1-rc.36
2
2
 
3
3
  See [the compatibility manifest](DSH-COMPATIBILITY.json) for the exact host versions and package pairing.
4
4
 
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
- // Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.3.1-rc.35
2
+ // Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.3.1-rc.36
3
3
  // Generated by scripts/build-client.mjs.
4
4
  window.__ModuleLoader__.load({id:"dsh-bettertaskgraphs",factory(require){
5
5
  // SPDX-License-Identifier: GPL-3.0-only
package/host.mjs CHANGED
@@ -109,7 +109,7 @@ export function resolveEvidence({ references, state, binding, activeProof, outco
109
109
 
110
110
  export class DshTransactionHost {
111
111
  constructor(ctx, { projectRoot, storageRelative = '.dsh/transaction-graphs-v4', enableSwitch = false,
112
- appendPluginRecord, journalFactory = options => new HostJournal(options), owner = `owner_${randomUUID()}` }) {
112
+ appendPluginRecord, sessionRecords, journalFactory = options => new HostJournal(options), owner = `owner_${randomUUID()}` }) {
113
113
  if (typeof appendPluginRecord !== 'function') fail('SESSION_WRITER_REQUIRED')
114
114
  if (projectRoot !== undefined && (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)
115
115
  || fs.realpathSync(projectRoot) !== path.resolve(projectRoot) || !fs.lstatSync(projectRoot).isDirectory()))
@@ -119,6 +119,7 @@ export class DshTransactionHost {
119
119
  if (typeof enableSwitch !== 'boolean') fail('INVALID_SWITCH_POLICY')
120
120
  this.ctx = ctx; this.projectRoot = projectRoot === undefined ? undefined : path.resolve(projectRoot)
121
121
  this.storageRelative = storageRelative; this.enableSwitch = enableSwitch
122
+ this.sessionRecords = sessionRecords
122
123
  this.append = appendPluginRecord; this.journalFactory = journalFactory; this.owner = owner
123
124
  this.journals = new Map(); this.serial = new WeakMap(); this.tasks = new Set(); this.definitions = new Map(); this.closed = false
124
125
  this.recoveryVerifier = null
@@ -126,7 +127,7 @@ export class DshTransactionHost {
126
127
  this.lifecycle = new AbortController()
127
128
  }
128
129
  projection(session) {
129
- const state = this.ctx.sessionProjections.stateOf(session, KEY)
130
+ const state = this.sessionRecords ? this.sessionRecords.projection(session) : this.ctx.sessionProjections.stateOf(session, KEY)
130
131
  if (!state || state.failure) fail('TRANSACTION_PROJECTION_UNAVAILABLE', state?.failure ?? 'Required projection missing')
131
132
  return state
132
133
  }
@@ -134,7 +135,7 @@ export class DshTransactionHost {
134
135
  if (this.ctx.sessions.get(session.id) !== session || session.header.id !== session.id || !Number.isSafeInteger(session.header.createdAt)) fail('SESSION_NOT_EXACT_LIVE_OWNER')
135
136
  const original = realProjectPath(this.projectRoot ?? session.header.cwd, session.header.cwd)
136
137
  const projectRoot = this.projectRoot ?? original
137
- realProjectPath(projectRoot, this.ctx.workingDirectory.get(session))
138
+ realProjectPath(projectRoot, this.ctx.get('workingDirectory')?.get(session) ?? original)
138
139
  return { contract: 'DSH_TRANSACTION_GRAPH_HOST_V4', projectRoot, sessionId: session.id,
139
140
  sessionCreatedAt: session.header.createdAt, originalCwd: original, enableSwitch: this.enableSwitch }
140
141
  }
@@ -204,9 +205,10 @@ export class DshTransactionHost {
204
205
  }
205
206
  presence(session) {
206
207
  if (!this.inProject(session)) return { state: 'OUTSIDE_SCOPE', previouslyCommitted: false }
207
- const projected = this.projection(session)
208
- const previouslyCommitted = projected.receipts.some(row => row.status === 'COMMITTED')
208
+ let previouslyCommitted = false
209
209
  try {
210
+ const projected = this.projection(session)
211
+ previouslyCommitted = projected.receipts.some(row => row.status === 'COMMITTED')
210
212
  const binding = this.binding(session)
211
213
  const file = path.join(binding.projectRoot, this.storageRelative, digest(binding), 'transaction-host-journal.json')
212
214
  if (!fs.existsSync(file)) {
@@ -235,7 +237,8 @@ export class DshTransactionHost {
235
237
  async authorize(exec) {
236
238
  if (this.allowClassic && !this.allowClassic(exec.agent)) fail('TRANSACTION_GRAPH_SESSION_EXTENSION_DENIED')
237
239
  if (!exec.agent || this.ctx.agents.get(exec.agent.id) !== exec.agent || this.ctx.agents.currentInitiator() !== exec.agent) fail('EXACT_CALLING_AGENT_REQUIRED')
238
- await this.ctx.workingDirectory.ensure(exec.agent, exec.signal)
240
+ exec.signal?.throwIfAborted()
241
+ await this.ctx.get('workingDirectory')?.ensure(exec.agent, exec.signal)
239
242
  const { binding, key } = this.journal(exec.agent.session)
240
243
  const authority = { agent: exec.agent, session: exec.agent.session, agentId: exec.agent.id,
241
244
  sessionScope: key, currentInitiatorVerified: true, binding }
@@ -291,6 +294,7 @@ export class DshTransactionHost {
291
294
  if (handle.access !== 'read' || handle.id !== session.id || !sameStoredSessionHeader(handle.header, session.header)
292
295
  || handle.inheritedEventCount !== session.inheritedEventCount) fail('DURABLE_SESSION_HEADER_OR_SCOPE_MISMATCH')
293
296
  for (const proof of proofs) {
297
+ if (this.sessionRecords && await this.sessionRecords.verify(session, handle, proof)) continue
294
298
  const result = await handle.read(proof.seq, 1)
295
299
  if (result.events.length !== 1 || result.events[0].seq !== proof.seq || digest(result.events[0]) !== proof.digest)
296
300
  fail('DURABLE_SESSION_EVENT_PROOF_REQUIRED')
package/index.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  import { z } from 'zod'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
- import { appendPluginRecord } from '@deepseek-ai/dsh-session'
4
+ import { LegacySessionRecords } from './session-records.mjs'
5
5
  import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
6
6
  import { createToolDefinitions } from './tools.mjs'
7
7
  import { DshTransactionHost } from './host.mjs'
8
- import { projection, KEY } from './projection.mjs'
9
8
  import { createRemoteContribution } from './remote.mjs'
10
9
  import { WorkflowRegistry, WORKFLOW_CONTRACT } from './interop.mjs'
11
10
  import { EXTENSION_CONTRACT, SessionPolicies } from './extensions.mjs'
@@ -19,20 +18,21 @@ Remote('status')(undefined, {
19
18
  const guidance = 'For work the user asks to track in a transaction graph, read the program-generated revision and choices before creating or resuming it. One leaf may be active. Begin must commit before work starts. Pending or UNKNOWN work blocks finish and dependent work. Never replay blindly. Complete requires a qualified successful tool result after begin. Incomplete requires known failure, confirmed non-execution, or an unsatisfied outcome. Wait for the enclosing tool call to finish and read the committed state before continuing. Containers aggregate. Transaction completion does not complete Goal.'
20
19
 
21
20
  export default class TransactionGraphPlugin extends TypertRemoteService {
22
- static inject = ['agents', 'sessions', 'sessionProjections', 'sessionPersistence', 'workingDirectory', 'tools', 'systemPrompt']
21
+ static inject = ['agents', 'sessions', 'sessionProjections', 'sessionPersistence', 'tools', 'systemPrompt']
23
22
  constructor(ctx, config = {}) {
24
23
  super(ctx, 'transactionGraph', { namespace: 'transactionGraphView' })
25
24
  bindSessionResource.call(this)
26
25
  if (!config || typeof config !== 'object' || Array.isArray(config)
27
26
  || Object.keys(config).some(key => !['projectRoot', 'storageRelative', 'enableSwitch'].includes(key)))
28
27
  throw new Error('Invalid transaction graph configuration')
29
- this.host = new DshTransactionHost(ctx, { ...config, appendPluginRecord })
28
+ this.sessionRecords = new LegacySessionRecords(ctx)
29
+ this.host = new DshTransactionHost(ctx, { ...config, sessionRecords: this.sessionRecords,
30
+ appendPluginRecord: (session, type, data) => this.sessionRecords.append(session, type, data) })
30
31
  this.workflows = new WorkflowRegistry(ctx)
31
32
  this.sessionPolicies = new SessionPolicies()
32
33
  this.host.allowClassic = agent => this.sessionPolicies.allowClassic(agent)
33
34
  Object.defineProperty(this, 'workflowContract', { value: WORKFLOW_CONTRACT })
34
35
  Object.defineProperty(this, 'extensionContract', { value: EXTENSION_CONTRACT })
35
- ctx.sessionProjections.register(projection)
36
36
  for (const definition of createToolDefinitions({ defineTool, hostPort: this.host })) {
37
37
  this.host.definitions.set(definition.name, definition)
38
38
  ctx.tools.register(definition)
@@ -55,8 +55,10 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
55
55
  try { return await next() } finally { running.delete(exec) }
56
56
  })
57
57
  ctx.on('session/event', (session, event) => {
58
- if ((event.type === 'tool/result' || event.type === 'turn/end')
59
- && ctx.sessionProjections.stateOf(session, KEY)?.intents.length) {
58
+ if (event.type === 'tool/result' || event.type === 'turn/end') {
59
+ if (!this.host.inProject(session)) return
60
+ try { if (!this.host.projection(session).intents.length) return }
61
+ catch (error) { ctx.logger.warn('Legacy transaction projection unavailable', error); return }
60
62
  // Appending during publication is forbidden by Session; defer the
61
63
  // durability barrier and journal CAS until publication has unwound.
62
64
  queueMicrotask(() => { if (!this.host.closed) this.host.schedule(session) })
package/interop.mjs CHANGED
@@ -42,7 +42,7 @@ export class WorkflowRegistry {
42
42
  authorize(entry, agent) {
43
43
  if (this.closed || !agent || this.ctx.agents.get(agent.id) !== agent || this.ctx.sessions.get(agent.session.id) !== agent.session)
44
44
  fail('WORKFLOW_EXACT_CONTROLLER_REQUIRED')
45
- const cwd = this.ctx.workingDirectory.get(agent.session)
45
+ const cwd = this.ctx.get?.('workingDirectory')?.get(agent.session) ?? agent.session.header.cwd
46
46
  if (typeof cwd !== 'string' || !within(entry.projectRoot, fs.realpathSync(cwd))
47
47
  || !within(entry.projectRoot, fs.realpathSync(agent.session.header.cwd))) fail('WORKFLOW_CONTROLLER_OUTSIDE_PROJECT')
48
48
  if (entry.authorize(agent) !== true) fail('WORKFLOW_CONTROLLER_NOT_AUTHORIZED')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bettertaskgraphs",
3
- "version": "0.3.1-rc.35",
3
+ "version": "0.3.1-rc.36",
4
4
  "description": "Transactional task graphs with a conversation panel for DeepSeek Harness",
5
5
  "license": "GPL-3.0-only",
6
6
  "keywords": [
@@ -46,11 +46,12 @@
46
46
  "work-policy.mjs",
47
47
  "extensions.mjs",
48
48
  "VALIDATION.md",
49
+ "session-records.mjs",
49
50
  "DSH-COMPATIBILITY.json"
50
51
  ],
51
52
  "engines": {
52
53
  "node": "^22.19.0 || >=24.0.0",
53
- "dsh": "0.2.1-alpha.2"
54
+ "dsh": "0.2.1-alpha.1"
54
55
  },
55
56
  "scripts": {
56
57
  "build": "node scripts/build-client.mjs",
@@ -62,23 +63,23 @@
62
63
  },
63
64
  "publishConfig": {
64
65
  "access": "public",
65
- "tag": "dsh-0.2.1-alpha.2",
66
+ "tag": "dsh-0.2.1-alpha.1",
66
67
  "registry": "https://registry.npmjs.org/"
67
68
  },
68
69
  "dependencies": {
69
70
  "zod": "4.6.7"
70
71
  },
71
72
  "peerDependencies": {
72
- "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
73
- "@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
74
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.2"
73
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.1",
74
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.1",
75
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.1"
75
76
  },
76
77
  "devDependencies": {
77
78
  "@deepseek-ai/cordis": "4.0.5",
78
- "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
79
- "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.2",
80
- "@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
81
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.2"
79
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.1",
80
+ "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.1",
81
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.1",
82
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.1"
82
83
  },
83
84
  "dsh": {
84
85
  "bundle": {
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import path from 'node:path'
3
+ import fs from 'node:fs'
4
+ import { canonical, copy, digest, GraphError } from './core.mjs'
5
+ import { FileGraphStore } from './store.mjs'
6
+ import { applyEvent, initialState } from './projection.mjs'
7
+
8
+ const firstRecord = 2 ** 52
9
+ const isPluginType = type => typeof type === 'string' && /^plugin:[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(type)
10
+ const abort = code => { throw new GraphError(code) }
11
+ const recordLimit = 4096
12
+ const normalizedHeader = header => ({ ...header, delegationDepth: header.delegationDepth ?? 0 })
13
+
14
+ /** For DSH releases before appendPluginRecord. Native log positions and bytes
15
+ * stay intact. A separate, exact-owner journal supplies plugin facts only. */
16
+ export class LegacySessionRecords {
17
+ constructor(ctx) {
18
+ this.ctx = ctx
19
+ this.projected = new WeakMap()
20
+ }
21
+ binding(session) {
22
+ if (this.ctx.sessions.get(session.id) !== session || session.header.id !== session.id
23
+ || !Number.isSafeInteger(session.header.createdAt)) abort('SESSION_NOT_EXACT_LIVE_OWNER')
24
+ return digest({ header: normalizedHeader(session.header), inheritedEventCount: session.inheritedEventCount })
25
+ }
26
+ root(create = false) {
27
+ const configured = this.ctx.sessionPersistence.config?.root
28
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) abort('LEGACY_RECORDS_REQUIRE_JSONL_ROOT')
29
+ if (create) fs.mkdirSync(configured, { recursive: true, mode: 0o700 })
30
+ return fs.realpathSync(configured)
31
+ }
32
+ directory(session, root = this.root()) { return path.join(root, '.taskgraph-session-records-v1', digest(session.id)) }
33
+ marker(session, root) { return path.join(root, `.taskgraph-record-${digest(session.id)}`) }
34
+ validate(value, binding) {
35
+ if (!value || value.schema !== 1 || value.binding !== binding || !Array.isArray(value.rows)
36
+ || value.revision !== value.rows.length || value.revision < 1 || value.revision > recordLimit
37
+ || digest({ schema: value.schema, binding: value.binding, revision: value.revision, rows: value.rows }) !== value.checksum)
38
+ abort('LEGACY_SESSION_RECORD_JOURNAL_INVALID')
39
+ let last = -1
40
+ for (const [index, row] of value.rows.entries()) {
41
+ if (!row?.event || !Number.isSafeInteger(row.event.time) || !Object.hasOwn(row.event, 'data')
42
+ || row.event.seq !== firstRecord + index || row.event.ignorable !== true || !isPluginType(row.event.type)
43
+ || !Number.isSafeInteger(row.anchor) || row.anchor < 0 || row.anchor >= firstRecord
44
+ || row.anchor < last || (row.anchor === 0 ? row.anchorDigest !== null : !/^[a-f0-9]{64}$/.test(row.anchorDigest)))
45
+ abort('LEGACY_SESSION_RECORD_ANCHOR_INVALID')
46
+ last = row.anchor
47
+ }
48
+ }
49
+ store(session, readOnly) {
50
+ const binding = this.binding(session)
51
+ const root = this.root(!readOnly)
52
+ return new FileGraphStore({ projectRoot: root, directory: this.directory(session, root), readOnly,
53
+ fileName: 'records.json', maximumBytes: 12 * 1024 * 1024,
54
+ validator: value => this.validate(value, binding),
55
+ updateValidator(previous, next) {
56
+ if (next.revision !== (previous?.revision ?? 0) + 1
57
+ || previous && canonical(next.rows.slice(0, -1)) !== canonical(previous.rows))
58
+ abort('LEGACY_SESSION_RECORD_HISTORY_CHANGED')
59
+ },
60
+ })
61
+ }
62
+ read(session, events = session.snapshotEvents()) {
63
+ let journal
64
+ try { journal = this.store(session, true).read() } catch (error) {
65
+ if (error.code !== 'ENOENT') throw error
66
+ try {
67
+ if (fs.existsSync(this.marker(session, this.root()))) abort('LEGACY_SESSION_RECORD_JOURNAL_MISSING')
68
+ } catch (missingRoot) { if (missingRoot.code !== 'ENOENT') throw missingRoot }
69
+ return []
70
+ }
71
+ if (!journal) {
72
+ // The independently written admission marker distinguishes an absent
73
+ // record stream from a session that has never used the plugin.
74
+ if (fs.existsSync(this.marker(session, this.root()))) abort('LEGACY_SESSION_RECORD_JOURNAL_MISSING')
75
+ return []
76
+ }
77
+ const marker = this.marker(session, this.root())
78
+ const fd = fs.openSync(marker, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
79
+ try {
80
+ const info = fs.fstatSync(fd)
81
+ if (!info.isFile() || info.nlink !== 1 || info.size !== 65 || fs.readFileSync(fd, 'utf8') !== journal.binding + '\n')
82
+ abort('LEGACY_SESSION_RECORD_BINDING_INVALID')
83
+ } finally { fs.closeSync(fd) }
84
+ const rows = journal.rows
85
+ for (const { anchor, anchorDigest } of rows) {
86
+ if (anchor < session.inheritedEventCount || anchor > events.length
87
+ || anchor && digest(events[anchor - 1]) !== anchorDigest)
88
+ abort('LEGACY_SESSION_NATIVE_ANCHOR_CHANGED')
89
+ }
90
+ return rows
91
+ }
92
+ append(session, type, data) {
93
+ if (!isPluginType(type)) abort('INVALID_PLUGIN_RECORD_TYPE')
94
+ const events = session.snapshotEvents(), anchor = events.length
95
+ const anchorDigest = anchor ? digest(events[anchor - 1]) : null
96
+ const prior = this.read(session, events)
97
+ if (prior.length >= recordLimit) abort('LEGACY_SESSION_RECORD_LIMIT')
98
+ const serialized = JSON.stringify(data)
99
+ if (serialized === undefined || canonical(JSON.parse(serialized)) !== canonical(data))
100
+ abort('LEGACY_SESSION_RECORD_DATA_INVALID')
101
+ const seq = firstRecord + prior.length, binding = this.binding(session)
102
+ const store = this.store(session, false)
103
+ const root = this.root()
104
+ const marker = this.marker(session, root)
105
+ if (!prior.length) {
106
+ const fd = fs.openSync(marker, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
107
+ try { fs.writeFileSync(fd, binding + '\n'); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
108
+ const directory = fs.openSync(root, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY)
109
+ try { fs.fsyncSync(directory) } finally { fs.closeSync(directory) }
110
+ }
111
+ // Compare JSON round-trip bytes before reserving a durable record.
112
+ // Replay consumes the same data shape that the writer observed.
113
+ const row = { anchor, anchorDigest, event: { type, seq, time: Date.now(), ignorable: true, data: copy(data) } }
114
+ store.compareAndSwap(prior.length, previous => {
115
+ const document = { schema: 1, binding, revision: prior.length + 1, rows: [...(previous?.rows ?? []), row] }
116
+ return { ...document, checksum: digest(document) }
117
+ })
118
+ return seq
119
+ }
120
+ projection(session) {
121
+ const events = session.snapshotEvents()
122
+ const rows = this.read(session, events)
123
+ const prior = this.projected.get(session)
124
+ const samePrefix = prior && prior.events.length <= events.length
125
+ && events[prior.events.length - 1] === prior.events.at(-1)
126
+ && prior.rows.length <= rows.length
127
+ && prior.rows.every((row, i) => canonical(row) === canonical(rows[i]))
128
+ && (rows.length === prior.rows.length || rows[prior.rows.length].anchor >= prior.events.length)
129
+ let position = samePrefix ? prior.events.length : session.inheritedEventCount
130
+ let state = samePrefix ? prior.state : initialState(session.header, session.inheritedEventCount)
131
+ for (const { anchor, event } of rows.slice(samePrefix ? prior.rows.length : 0)) {
132
+ while (position < anchor) state = applyEvent(state, events[position++])
133
+ // A synthetic record id must never widen the admitted native evidence range.
134
+ const lastSeq = state.lastSeq
135
+ state = { ...applyEvent(state, event), lastSeq }
136
+ }
137
+ while (position < events.length) state = applyEvent(state, events[position++])
138
+ this.projected.set(session, { events, rows, state })
139
+ return state
140
+ }
141
+ async verify(session, handle, proof) {
142
+ if (proof.seq < firstRecord) return false
143
+ const row = this.read(session).at(proof.seq - firstRecord)
144
+ if (!row || row.event.seq !== proof.seq || digest(row.event) !== proof.digest)
145
+ abort('LEGACY_SESSION_RECORD_PROOF_REQUIRED')
146
+ if (row.anchor) {
147
+ const { events } = await handle.read(row.anchor - 1, 1)
148
+ if (events.length !== 1 || events[0].seq !== row.anchor - 1 || digest(events[0]) !== row.anchorDigest)
149
+ abort('DURABLE_SESSION_EVENT_PROOF_REQUIRED')
150
+ }
151
+ return true
152
+ }
153
+ }