dsh-bettertaskgraphs 0.3.1-rc.34 → 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,14 +1,13 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "updateRound": 3,
4
- "slot": 4,
5
- "branch": "0.2.1-alpha.4",
6
- "host": "0.2.1-alpha.4",
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.4",
9
- "0.2.1-alpha.3"
8
+ "0.2.1-alpha.1"
10
9
  ],
11
- "channel": "dsh-0.2.1-alpha.4",
12
- "taskgraph": "0.3.1-rc.34",
13
- "dynamicContext": "0.1.1-rc.34"
10
+ "channel": "dsh-0.2.1-alpha.1",
11
+ "taskgraph": "0.3.1-rc.36",
12
+ "dynamicContext": "0.1.1-rc.36"
14
13
  }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-bettertaskgraphs
2
2
 
3
- 本构建版本为 `0.3.1-rc.34`。适用宿主和配套版本见 [兼容清单](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.4` 采用 npm 标签 `dsh-0.2.1-alpha.4`。配套动态上下文版本为 `0.1.1-rc.34`。
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.4`、`0.2.1-alpha.3`,使用 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.34.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.34.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.34
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.34
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
@@ -7,7 +7,6 @@ window.__ModuleLoader__.load({id:"dsh-bettertaskgraphs",factory(require){
7
7
  function createRemoteContribution(z) {
8
8
  const packageName = 'dsh-bettertaskgraphs'
9
9
  const id = z.string().min(1).max(128)
10
- const input = z.object({ sessionId: id }).strict()
11
10
  const node = z.object({
12
11
  id, label: z.string().min(1).max(512), kind: z.enum(['container', 'leaf']),
13
12
  parent: id.nullable(),
@@ -44,9 +43,8 @@ function createRemoteContribution(z) {
44
43
  id: `${packageName}#transactionGraphView/status`,
45
44
  service: 'transactionGraph', namespace: 'transactionGraphView', method: 'status', invocation: { kind: 'direct' },
46
45
  parameters: [{
47
- name: 'request', wire: 'request', source: 'json',
48
- codec: codec('TransactionGraphStatusRequest', input),
49
- strictCodec: codec('TransactionGraphStatusStrictRequest', input),
46
+ name: 'session', wire: 'sessionId', source: 'lookup', lookup: 'session',
47
+ codec: { mode: 'strict', typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', create: () => id },
50
48
  }],
51
49
  cancellation: { parameter: 'signal' },
52
50
  result: codec('TransactionGraphStatusResult', output),
@@ -477,15 +475,14 @@ function createClientPlugin(React, z, createRemoteContribution, graphPresentatio
477
475
  }
478
476
 
479
477
  function makeDock(api) {
480
- return function TransactionGraphDock({ sessionId, useSession, useAccess, t }) {
481
- const canRead = useAccess('transactionGraphView/status')
478
+ return function TransactionGraphDock({ sessionId, useSession, t }) {
482
479
  const opened = useSession(session => session.openState === 'open' && !session.removed)
483
480
  const running = useSession(session => session.running)
484
481
  const [result, setResult] = useState(null)
485
482
  const [refreshCount, requestRefresh] = useState(0)
486
483
  const refresh = () => requestRefresh(value => value + 1)
487
484
  useEffect(() => {
488
- if (!canRead || !opened) return
485
+ if (!opened) return
489
486
  const controller = new AbortController()
490
487
  let timer
491
488
  async function refreshStatus() {
@@ -495,7 +492,7 @@ function createClientPlugin(React, z, createRemoteContribution, graphPresentatio
495
492
  busy: true, failed: value?.sessionId === sessionId && value.failed,
496
493
  }))
497
494
  try {
498
- const response = await api.status({ sessionId }, signal)
495
+ const response = await api.status(sessionId, signal)
499
496
  if (controller.signal.aborted) return
500
497
  const parsed = response?.ok ? statusResult.safeParse(response.value) : null
501
498
  if (!parsed?.success || parsed.data.sessionId !== sessionId || parsed.data.status === 'unavailable') throw new Error('Unavailable')
@@ -509,8 +506,8 @@ function createClientPlugin(React, z, createRemoteContribution, graphPresentatio
509
506
  }
510
507
  void refreshStatus()
511
508
  return () => { controller.abort(); clearTimeout(timer) }
512
- }, [canRead, opened, running, sessionId, refreshCount])
513
- if (!canRead || !opened || result?.sessionId !== sessionId || !result.graph) return null
509
+ }, [opened, running, sessionId, refreshCount])
510
+ if (!opened || result?.sessionId !== sessionId || !result.graph) return null
514
511
  return h(GraphPanel, { key: sessionId, graph: result.graph, t, busy: result.busy, failed: result.failed, refresh })
515
512
  }
516
513
  }
package/client.source.mjs CHANGED
@@ -263,15 +263,14 @@ export function createClientPlugin(React, z, createRemoteContribution, graphPres
263
263
  }
264
264
 
265
265
  function makeDock(api) {
266
- return function TransactionGraphDock({ sessionId, useSession, useAccess, t }) {
267
- const canRead = useAccess('transactionGraphView/status')
266
+ return function TransactionGraphDock({ sessionId, useSession, t }) {
268
267
  const opened = useSession(session => session.openState === 'open' && !session.removed)
269
268
  const running = useSession(session => session.running)
270
269
  const [result, setResult] = useState(null)
271
270
  const [refreshCount, requestRefresh] = useState(0)
272
271
  const refresh = () => requestRefresh(value => value + 1)
273
272
  useEffect(() => {
274
- if (!canRead || !opened) return
273
+ if (!opened) return
275
274
  const controller = new AbortController()
276
275
  let timer
277
276
  async function refreshStatus() {
@@ -281,7 +280,7 @@ export function createClientPlugin(React, z, createRemoteContribution, graphPres
281
280
  busy: true, failed: value?.sessionId === sessionId && value.failed,
282
281
  }))
283
282
  try {
284
- const response = await api.status({ sessionId }, signal)
283
+ const response = await api.status(sessionId, signal)
285
284
  if (controller.signal.aborted) return
286
285
  const parsed = response?.ok ? statusResult.safeParse(response.value) : null
287
286
  if (!parsed?.success || parsed.data.sessionId !== sessionId || parsed.data.status === 'unavailable') throw new Error('Unavailable')
@@ -295,8 +294,8 @@ export function createClientPlugin(React, z, createRemoteContribution, graphPres
295
294
  }
296
295
  void refreshStatus()
297
296
  return () => { controller.abort(); clearTimeout(timer) }
298
- }, [canRead, opened, running, sessionId, refreshCount])
299
- if (!canRead || !opened || result?.sessionId !== sessionId || !result.graph) return null
297
+ }, [opened, running, sessionId, refreshCount])
298
+ if (!opened || result?.sessionId !== sessionId || !result.graph) return null
300
299
  return h(GraphPanel, { key: sessionId, graph: result.graph, t, busy: result.busy, failed: result.failed, refresh })
301
300
  }
302
301
  }
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'
@@ -13,26 +12,27 @@ import { FileGraphStore } from './store.mjs'
13
12
 
14
13
  export const name = 'dsh-bettertaskgraphs'
15
14
  let bindSessionResource
16
- Remote({ resources: { session: ({ sessionId }) => sessionId } })(undefined, {
15
+ Remote('status')(undefined, {
17
16
  kind: 'method', name: 'status', addInitializer: callback => { bindSessionResource = callback },
18
17
  })
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)
@@ -43,7 +43,7 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
43
43
  text: ({ agent }) => this.host.inProject(agent?.session) && this.sessionPolicies.allowClassic(agent) ? this.host.context(agent) : '' })
44
44
  ctx.inject(['typert', 'sessionController'], remoteCtx => {
45
45
  const typert = remoteCtx.typert
46
- if (typert.resources.get('session')?.field !== 'sessionId') throw new Error('Transaction graph requires the native Session RPC resource')
46
+ if (typert.lookups.get('session')?.wire !== 'sessionId') throw new Error('Transaction graph requires the native Session RPC lookup')
47
47
  const contribution = createRemoteContribution(z)
48
48
  remoteCtx.effect(() => typert.register({ package: contribution.package, face: 'host', schemas: [],
49
49
  model: { services: [], events: [], objects: [] }, invocations: contribution.descriptors }))
@@ -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) })
@@ -64,14 +66,10 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
64
66
  })
65
67
  ctx.effect(() => () => { this.workflows.close(); this.sessionPolicies.close(); return this.host.close() })
66
68
  }
67
- async status({ sessionId }, signal) {
69
+ async status(session, signal) {
68
70
  signal?.throwIfAborted()
69
- const controller = this.ctx.get('sessionController')
70
- if (!controller) throw new RemoteError('session/not-found', 'Session controller unavailable', { sessionId })
71
- const result = await controller.resolveAgent(sessionId)
72
- if (result.error) throw result.error
73
- signal?.throwIfAborted()
74
- return this.host.snapshot(result.agent.session)
71
+ if (!this.host.inProject(session)) throw new RemoteError('session/not-found', 'Session unavailable')
72
+ return this.host.snapshot(session)
75
73
  }
76
74
  registerRecoveryVerifier(adapter) { return this.host.registerRecoveryVerifier(adapter) }
77
75
  recoverInterrupted(agent, request) { return this.host.recoverInterrupted(agent, request) }
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.34",
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.4 || 0.2.1-alpha.3"
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.4",
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.4 || 0.2.1-alpha.3",
73
- "@deepseek-ai/dsh-tools": "0.2.1-alpha.4 || 0.2.1-alpha.3",
74
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.4 || 0.2.1-alpha.3"
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.4",
79
- "@deepseek-ai/dsh-session-projection": "0.2.1-alpha.4",
80
- "@deepseek-ai/dsh-tools": "0.2.1-alpha.4",
81
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.4"
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": {
package/projection.mjs CHANGED
@@ -59,7 +59,14 @@ export function outcomeOfContent(content = []) {
59
59
  }
60
60
  function toolOutcome(state, tool, rootCallId, callId, content) {
61
61
  const outcome = outcomeOfContent(content)
62
- if (tool !== 'bash' || !state.bashFailures.some(row => row.rootCallId === rootCallId && row.callId === callId)) return outcome
62
+ if (tool !== 'bash') return outcome
63
+ // These DSH hosts deliver the native Bash status as renderer suffixes.
64
+ const text = content?.length === 1 && content[0].type === 'text' ? content[0].text.trimEnd() : ''
65
+ if (/(?:^|\n)started background job [^\n]+$|\[still running after \d+ms; moved to background job [^\]\n]+\]/.test(text))
66
+ return 'UNKNOWN'
67
+ const nativeFailure = /(?:^|\n)\[(?:timed out after \d+ms|stopped: [^\]\n]+|sandbox: file access denied under [^\]\n]+ mode)\](?:\n|$)/.test(text)
68
+ || /(?:^|\n)\[(?:exit code: (?!0\])-?\d+|killed by signal: [^\]\n]+)\]$/.test(text)
69
+ if (!nativeFailure && !state.bashFailures.some(row => row.rootCallId === rootCallId && row.callId === callId)) return outcome
63
70
  return outcome === 'UNKNOWN' || outcome === 'NOT_RUN' ? 'UNKNOWN' : 'FAIL'
64
71
  }
65
72
  export function chainQualification(start, root, dispatches) {
package/remote.mjs CHANGED
@@ -3,7 +3,6 @@
3
3
  export function createRemoteContribution(z) {
4
4
  const packageName = 'dsh-bettertaskgraphs'
5
5
  const id = z.string().min(1).max(128)
6
- const input = z.object({ sessionId: id }).strict()
7
6
  const node = z.object({
8
7
  id, label: z.string().min(1).max(512), kind: z.enum(['container', 'leaf']),
9
8
  parent: id.nullable(),
@@ -40,9 +39,8 @@ export function createRemoteContribution(z) {
40
39
  id: `${packageName}#transactionGraphView/status`,
41
40
  service: 'transactionGraph', namespace: 'transactionGraphView', method: 'status', invocation: { kind: 'direct' },
42
41
  parameters: [{
43
- name: 'request', wire: 'request', source: 'json',
44
- codec: codec('TransactionGraphStatusRequest', input),
45
- strictCodec: codec('TransactionGraphStatusStrictRequest', input),
42
+ name: 'session', wire: 'sessionId', source: 'lookup', lookup: 'session',
43
+ codec: { mode: 'strict', typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', create: () => id },
46
44
  }],
47
45
  cancellation: { parameter: 'signal' },
48
46
  result: codec('TransactionGraphStatusResult', output),
@@ -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
+ }