dsh-bettertaskgraphs 0.3.1-rc.35 → 0.3.1-rc.37
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/DSH-COMPATIBILITY.json +8 -7
- package/README.md +7 -5
- package/VALIDATION.md +1 -1
- package/client.js +1 -1
- package/host.mjs +10 -6
- package/index.mjs +9 -7
- package/interop.mjs +1 -1
- package/package.json +11 -10
- package/session-records.mjs +153 -0
package/DSH-COMPATIBILITY.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"updateRound": 3,
|
|
4
|
-
"slot":
|
|
5
|
-
"branch": "0.2.
|
|
6
|
-
"host": "0.2.
|
|
4
|
+
"slot": 7,
|
|
5
|
+
"branch": "0.2.0-rc.2",
|
|
6
|
+
"host": "0.2.0-rc.2",
|
|
7
7
|
"hosts": [
|
|
8
|
-
"0.2.
|
|
8
|
+
"0.2.0-rc.2",
|
|
9
|
+
"0.2.0-rc.1"
|
|
9
10
|
],
|
|
10
|
-
"channel": "dsh-0.2.
|
|
11
|
-
"taskgraph": "0.3.1-rc.
|
|
12
|
-
"dynamicContext": "0.1.1-rc.
|
|
11
|
+
"channel": "dsh-0.2.0-rc.2",
|
|
12
|
+
"taskgraph": "0.3.1-rc.37",
|
|
13
|
+
"dynamicContext": "0.1.1-rc.37"
|
|
13
14
|
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-bettertaskgraphs
|
|
2
2
|
|
|
3
|
-
本构建版本为 `0.3.1-rc.
|
|
3
|
+
本构建版本为 `0.3.1-rc.37`。适用宿主和配套版本见 [兼容清单](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.
|
|
15
|
+
分支 `0.2.0-rc.2` 采用 npm 标签 `dsh-0.2.0-rc.2`。配套动态上下文版本为 `0.1.1-rc.37`。
|
|
16
16
|
|
|
17
|
-
- DSH `0.2.
|
|
17
|
+
- DSH `0.2.0-rc.2`、`0.2.0-rc.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.
|
|
29
|
+
`build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.3.1-rc.37.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.
|
|
37
|
+
dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.3.1-rc.37.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
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.
|
|
2
|
+
// Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.3.1-rc.37
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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', '
|
|
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.
|
|
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 (
|
|
59
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.3.1-rc.37",
|
|
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.
|
|
54
|
+
"dsh": "0.2.0-rc.2 || 0.2.0-rc.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.
|
|
66
|
+
"tag": "dsh-0.2.0-rc.2",
|
|
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.
|
|
73
|
-
"@deepseek-ai/dsh-tools": "0.2.
|
|
74
|
-
"@deepseek-ai/dsh-typert-protocol": "0.2.
|
|
73
|
+
"@deepseek-ai/dsh-session": "0.2.0-rc.2 || 0.2.0-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2 || 0.2.0-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2 || 0.2.0-rc.1"
|
|
75
76
|
},
|
|
76
77
|
"devDependencies": {
|
|
77
78
|
"@deepseek-ai/cordis": "4.0.5",
|
|
78
|
-
"@deepseek-ai/dsh-session": "0.2.
|
|
79
|
-
"@deepseek-ai/dsh-session-projection": "0.2.
|
|
80
|
-
"@deepseek-ai/dsh-tools": "0.2.
|
|
81
|
-
"@deepseek-ai/dsh-typert-protocol": "0.2.
|
|
79
|
+
"@deepseek-ai/dsh-session": "0.2.0-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
|
|
82
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2"
|
|
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
|
+
}
|