dsh-bettertaskgraphs 0.2.2 → 0.3.0-rc.11
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/README.md +45 -2
- package/VALIDATION.md +22 -0
- package/client.js +2 -6
- package/client.source.mjs +1 -5
- package/core.mjs +65 -0
- package/extensions.mjs +24 -0
- package/host.mjs +45 -4
- package/index.mjs +24 -5
- package/interop.mjs +91 -0
- package/journal.mjs +2 -2
- package/package.json +18 -4
- package/projection.mjs +28 -8
- package/store.mjs +5 -5
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# dsh-bettertaskgraphs
|
|
2
2
|
|
|
3
|
+
当前候选版本为 `0.3.0-rc.11`。独立运行保留动态上下文开发前 `0.2.3` 的五个工具、初始指引与事务语义;通用扩展接口供其他插件登记工作流和会话策略。动态上下文装配、修改审批及其提示词由扩展插件实现。
|
|
4
|
+
|
|
5
|
+
配套扩展:[dsh-dynamic-context](https://github.com/oldcitynight/dsh-dynamic-context)。本候选的验证范围与复现方式见 [VALIDATION.md](VALIDATION.md)。
|
|
6
|
+
|
|
3
7
|
DeepSeek Harness 的事务型任务图 Bundle,包含模型侧的任务图工具和会话输入区的任务图面板。支持嵌套容器、条件分支、单一活动叶节点,以及基于 DSH 会话证据的提交与对账。
|
|
4
8
|
|
|
5
9
|
面板的事务关系区列出进入条件及对应任务状态。点击「全屏」后,沿连线从左往右阅读。可沿同一条前置链推出的重复条件会合并显示。独立的跨列前置留在目标卡片的进入条件中,并在卡片上标记数量。点击节点可切换所选事务,按 Esc 返回会话。
|
|
@@ -18,7 +22,7 @@ npm test
|
|
|
18
22
|
npm run pack:bundle
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
`build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.
|
|
25
|
+
`build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.3.0-rc.11.tgz`。包内包含服务端模块、Web 构建产物、对应源码和构建脚本,以及 Bundle 配置补丁。
|
|
22
26
|
|
|
23
27
|
## 安装到独立 Web profile
|
|
24
28
|
|
|
@@ -26,7 +30,7 @@ npm run pack:bundle
|
|
|
26
30
|
|
|
27
31
|
```sh
|
|
28
32
|
dsh --profile bettertaskgraphs --from-default-profile web --dump-config
|
|
29
|
-
dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.
|
|
33
|
+
dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.3.0-rc.11.tgz"
|
|
30
34
|
dsh --profile bettertaskgraphs --dump-config
|
|
31
35
|
dsh --profile bettertaskgraphs
|
|
32
36
|
```
|
|
@@ -49,6 +53,45 @@ Bundle 默认将会话创建时的工作目录作为项目根,账本位于根
|
|
|
49
53
|
|
|
50
54
|
[DSH 官方 Bundle 打包文档](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.1-rc.5/docs/user/develop/basic/publish.zh.md)
|
|
51
55
|
|
|
56
|
+
## 插件间联动 API v1
|
|
57
|
+
|
|
58
|
+
公共模块导出为 `dsh-bettertaskgraphs/core`、`/store`、`/host`、`/interop`。消费者无需再访问包内私有路径。`ctx.transactionGraph.workflowContract` 固定为 `dsh-taskgraph-workflow-v1`。
|
|
59
|
+
|
|
60
|
+
宿主插件通过 `registerWorkflowProvider(adapter)` 登记一个联动工作流,返回取消登记函数。adapter 必须给出 contract、version:1、kind、workflowId、真实 projectRoot、operations、authorize、read、execute;subscribe 可发布已提交后的变更通知。重复标识拒绝覆盖,取消登记会取消该入口发起的在途执行。
|
|
61
|
+
|
|
62
|
+
控制器端接口:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const graph = ctx.transactionGraph
|
|
66
|
+
const ref = { kind: 'dynamic-context', workflowId: 'project-workflow' }
|
|
67
|
+
const workflows = graph.listWorkflows(controllerAgent)
|
|
68
|
+
const snapshot = graph.readWorkflow(controllerAgent, ref)
|
|
69
|
+
const result = await graph.dispatchWorkflow(controllerAgent, ref, 'run_next', {
|
|
70
|
+
expectedRevision: snapshot.revision,
|
|
71
|
+
}, signal)
|
|
72
|
+
const unsubscribe = graph.subscribeWorkflows(notice => {
|
|
73
|
+
// notice 只含 contract、kind、workflowId、type、revision;重新读取提交后的状态。
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
动态上下文提供 `submit`、`status`、`run_next`;durable 模式另提供 `pause`、`resume`、`reconcile`、`maintenance`。submit 的 request 为 `{proposal:{taskGraph,contextGraph}}`,两张图仍由动态上下文的一个 CAS 文档原子提交。read 返回同一真实任务图、contextVersion、报告引用和执行状态;不返回原始上下文和报告正文。恢复/对账仍须精确 revision、attempt 与检查点票据,不能通过联动接口绕过。
|
|
78
|
+
|
|
79
|
+
入口只接受当前注册的真实控制器、原始与当前工作目录均在本项目内;worker 不能提升成控制器。参数由对应 provider 严格校验,来源 Agent 和取消信号由宿主绑定,模型参数不能替换。通知监听器异常不会回滚已提交状态。
|
|
80
|
+
|
|
81
|
+
联动工作流和原生事务图有明确的存储域。现有 `transaction_graph_*` 工具及 Web 面板仍读取其原有会话账本,不镜像动态图,不把模型报告转成正式事务成功。联动 API 也不会自动完成 Goal 或接管 Web 聊天入口。
|
|
82
|
+
|
|
83
|
+
## 通用会话扩展接口
|
|
84
|
+
|
|
85
|
+
`extensionContract` 为 `dsh-taskgraph-session-extension-v1`,公共入口为 `/extensions`。
|
|
86
|
+
|
|
87
|
+
- `registerSessionPolicy({id, allowClassic(agent)})` 返回注销函数。无策略时原有工具与指引保持启用;扩展可对单个会话禁止经典图写入与提示,工具可见性由扩展的会话作用域控制。
|
|
88
|
+
- `createExtensionStore(options)` 创建通用 CAS 存储,不实现扩展业务。默认容量保持原值,调用方可以显式选择容量。
|
|
89
|
+
- `inspectGraphPresence(agent)` 根据原生持久化日志和提交收据,区分从未创建、创建待提交、已存在、提交后丢失/损坏以及不确定的创建记录。缺失历史图不能当作空白会话重建。
|
|
90
|
+
|
|
91
|
+
独立图依然由五个原有工具推进。rc.11 移除了早期候选中的 `workflowOnly`、`revision` 配置、`transaction_graph_revise` 和 `/revision*` 导出;使用这些早期候选接口的宿主需要调整为扩展插件。结构性 `REVISED` 事件仍可由受信扩展使用,旧已提交 V4 事件可读;遗留待提交修订不会跳过原扩展审批自动落盘。
|
|
92
|
+
|
|
93
|
+
`artifactProof` 默认上限恢复为 16 MiB;扩展可通过第四参数 `{maxBytes}` 明确扩大自身证据范围,不改变独立任务图默认值。
|
|
94
|
+
|
|
52
95
|
## 许可证
|
|
53
96
|
|
|
54
97
|
Copyright (C) 2026 oldcitynight and contributors.
|
package/VALIDATION.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Candidate validation: 0.3.0-rc.11
|
|
2
|
+
|
|
3
|
+
Validated on 2026-11-13 with Node.js 24.22.2 and DSH 0.2.1-rc.5.
|
|
4
|
+
|
|
5
|
+
- 103 task-graph tests passed with dynamic-context imports forbidden.
|
|
6
|
+
- A real DSH service mounted independently, exposing the original five tools and guidance.
|
|
7
|
+
- Tool definitions and work policy match the 0.2.3 baseline except for the file header; initial guidance is byte-identical.
|
|
8
|
+
- Client source/build parity passed.
|
|
9
|
+
- The packaged service mounted after an isolated offline installation.
|
|
10
|
+
- The companion dynamic-context candidate passed 348 source tests and 347 installed tests. One upstream-source comparison was skipped only in the installed package; it passed with the upstream source present.
|
|
11
|
+
|
|
12
|
+
These checks establish the tested program boundaries and fake-backend behavior. They do not certify real-model planning quality, browser interaction, or npm publication. The prior experiment records and frozen artifacts were retained unchanged.
|
|
13
|
+
|
|
14
|
+
To reproduce this repository's checks:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm ci
|
|
18
|
+
npm test
|
|
19
|
+
npm pack --dry-run
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The base package has no dependency on dynamic context. Optional extension logic must register through the public workflow/session interfaces and retain its own model prompts and approval policy.
|
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.
|
|
2
|
+
// Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.3.0-rc.11
|
|
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
|
|
@@ -289,7 +289,6 @@ function createClientPlugin(React, z, createRemoteContribution, graphPresentatio
|
|
|
289
289
|
.tg-badge[data-state=failed]{background:#d452522f}
|
|
290
290
|
.tg-current,.tg-conditions{font-size:12px;line-height:1.4;overflow-wrap:anywhere}.tg-current{margin-bottom:7px}.tg-current .tg-badge{margin-inline-start:6px}
|
|
291
291
|
.tg-conditions{list-style:decimal;margin:4px 0 0;padding-inline-start:1.7em}.tg-conditions li+li{margin-top:4px}.tg-no-conditions{margin:4px 0 0;color:var(--tg-muted);font-size:12px}
|
|
292
|
-
.tg-error-only{padding:6px 9px;font-size:12px}
|
|
293
292
|
|
|
294
293
|
.tg-viewer{
|
|
295
294
|
--tg-panel:var(--dsw-alias-bg-base,Canvas);
|
|
@@ -511,10 +510,7 @@ function createClientPlugin(React, z, createRemoteContribution, graphPresentatio
|
|
|
511
510
|
void refreshStatus()
|
|
512
511
|
return () => { controller.abort(); clearTimeout(timer) }
|
|
513
512
|
}, [canRead, opened, running, sessionId, refreshCount])
|
|
514
|
-
if (!canRead || !opened || result?.sessionId !== sessionId) return null
|
|
515
|
-
if (!result.graph) return result.failed ? h('section', { className: 'tg-dock tg-error-only', 'aria-label': t('title') },
|
|
516
|
-
h('style', null, styles), h('span', { role: 'status' }, t('unavailable')), ' ',
|
|
517
|
-
h('button', { type: 'button', onClick: refresh }, t('retry'))) : null
|
|
513
|
+
if (!canRead || !opened || result?.sessionId !== sessionId || !result.graph) return null
|
|
518
514
|
return h(GraphPanel, { key: sessionId, graph: result.graph, t, busy: result.busy, failed: result.failed, refresh })
|
|
519
515
|
}
|
|
520
516
|
}
|
package/client.source.mjs
CHANGED
|
@@ -75,7 +75,6 @@ export function createClientPlugin(React, z, createRemoteContribution, graphPres
|
|
|
75
75
|
.tg-badge[data-state=failed]{background:#d452522f}
|
|
76
76
|
.tg-current,.tg-conditions{font-size:12px;line-height:1.4;overflow-wrap:anywhere}.tg-current{margin-bottom:7px}.tg-current .tg-badge{margin-inline-start:6px}
|
|
77
77
|
.tg-conditions{list-style:decimal;margin:4px 0 0;padding-inline-start:1.7em}.tg-conditions li+li{margin-top:4px}.tg-no-conditions{margin:4px 0 0;color:var(--tg-muted);font-size:12px}
|
|
78
|
-
.tg-error-only{padding:6px 9px;font-size:12px}
|
|
79
78
|
|
|
80
79
|
.tg-viewer{
|
|
81
80
|
--tg-panel:var(--dsw-alias-bg-base,Canvas);
|
|
@@ -297,10 +296,7 @@ export function createClientPlugin(React, z, createRemoteContribution, graphPres
|
|
|
297
296
|
void refreshStatus()
|
|
298
297
|
return () => { controller.abort(); clearTimeout(timer) }
|
|
299
298
|
}, [canRead, opened, running, sessionId, refreshCount])
|
|
300
|
-
if (!canRead || !opened || result?.sessionId !== sessionId) return null
|
|
301
|
-
if (!result.graph) return result.failed ? h('section', { className: 'tg-dock tg-error-only', 'aria-label': t('title') },
|
|
302
|
-
h('style', null, styles), h('span', { role: 'status' }, t('unavailable')), ' ',
|
|
303
|
-
h('button', { type: 'button', onClick: refresh }, t('retry'))) : null
|
|
299
|
+
if (!canRead || !opened || result?.sessionId !== sessionId || !result.graph) return null
|
|
304
300
|
return h(GraphPanel, { key: sessionId, graph: result.graph, t, busy: result.busy, failed: result.failed, refresh })
|
|
305
301
|
}
|
|
306
302
|
}
|
package/core.mjs
CHANGED
|
@@ -266,6 +266,62 @@ function finishLeaf(state, data) {
|
|
|
266
266
|
activate(state, active.nodeId, data.outcome, data.selectedCase)
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/** Normalize a new plan while retaining durable identities and all started work. */
|
|
270
|
+
export function revisedPlan(document, definition) {
|
|
271
|
+
validateDocument(document)
|
|
272
|
+
const previous = document.state.plan, plan = normalizePlan(definition, { enableSwitch: previous.enableSwitch })
|
|
273
|
+
const old = new Map(previous.nodes.map(node => [node.key, node]))
|
|
274
|
+
const historical = new Set(document.events.flatMap(event => event.data.plan?.nodes.map(node => node.key) ?? []))
|
|
275
|
+
for (const node of plan.nodes) if (!old.has(node.key) && historical.has(node.key)) fail('RETIRED_TASK_KEY_REUSE')
|
|
276
|
+
const ids = new Map(plan.nodes.map(node => [node.id, old.get(node.key)?.id ?? node.id]))
|
|
277
|
+
plan.id = previous.id
|
|
278
|
+
plan.nodes = plan.nodes.map(node => ({ ...node, id: ids.get(node.id), parent: ids.get(node.parent) ?? null }))
|
|
279
|
+
plan.edges = plan.edges.map(edge => {
|
|
280
|
+
const result = { ...edge, from: ids.get(edge.from), to: ids.get(edge.to) }
|
|
281
|
+
const match = previous.edges.find(row => canonical({ ...row, id: null }) === canonical({ ...result, id: null }))
|
|
282
|
+
return match ? copy(match) : result
|
|
283
|
+
})
|
|
284
|
+
validateRevisionTransition(document.state, plan)
|
|
285
|
+
return plan
|
|
286
|
+
}
|
|
287
|
+
export function validateRevisionTransition(state, plan) {
|
|
288
|
+
if (state.active) fail('REVISION_REQUIRES_NO_ACTIVE_ATTEMPT')
|
|
289
|
+
validatePlan(plan)
|
|
290
|
+
if (state.plan.id !== plan.id || state.plan.enableSwitch !== plan.enableSwitch) fail('REVISION_GRAPH_IDENTITY_CHANGED')
|
|
291
|
+
if (canonical(state.plan.edges.filter(edge => edge.kind !== 'prerequisite')) !== canonical(plan.edges.filter(edge => edge.kind !== 'prerequisite')))
|
|
292
|
+
fail('REVISION_BRANCH_POLICY_IMMUTABLE')
|
|
293
|
+
const next = new Map(plan.nodes.map(node => [node.id, node]))
|
|
294
|
+
for (const node of state.plan.nodes) {
|
|
295
|
+
const leaf = state.leaves[node.id]
|
|
296
|
+
if (node.parent === null && (!next.has(node.id)
|
|
297
|
+
|| canonical({ ...next.get(node.id), label: node.label }) !== canonical(node))) fail('REVISION_ROOT_IMMUTABLE')
|
|
298
|
+
if (leaf && !['PENDING', 'INACTIVE'].includes(leaf.status)) {
|
|
299
|
+
if (canonical(next.get(node.id)) !== canonical(node)) fail('STARTED_TASK_IMMUTABLE')
|
|
300
|
+
const incoming = p => p.edges.filter(edge => edge.to === node.id)
|
|
301
|
+
if (canonical(incoming(state.plan)) !== canonical(incoming(plan))) fail('STARTED_TASK_PREREQUISITES_IMMUTABLE')
|
|
302
|
+
}
|
|
303
|
+
if (next.has(node.id) && (next.get(node.id).key !== node.key || next.get(node.id).kind !== node.kind)) fail('REVISION_TASK_IDENTITY_CHANGED')
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function reviseState(state, data) {
|
|
307
|
+
object(data, ['plan', 'review'], 'revision event')
|
|
308
|
+
validateRevisionTransition(state, data.plan)
|
|
309
|
+
const review = data.review
|
|
310
|
+
if (!review || review.verdict !== 'APPROVE' || review.baseRevision !== state.revision
|
|
311
|
+
|| review.planChecksum !== digest(data.plan) || !/^[a-f0-9]{64}$/.test(review.proposalHash ?? '')
|
|
312
|
+
|| typeof review.updaterRunId !== 'string' || typeof review.reviewerRunId !== 'string'
|
|
313
|
+
|| !review.updaterRunId || !review.reviewerRunId || review.updaterRunId === review.reviewerRunId) fail('INDEPENDENT_REVISION_REVIEW_REQUIRED')
|
|
314
|
+
const incoming = new Set(data.plan.edges.filter(edge => edge.kind !== 'prerequisite').map(edge => edge.to))
|
|
315
|
+
state.leaves = Object.fromEntries(data.plan.nodes.filter(node => node.kind === 'leaf').map(node => [node.id,
|
|
316
|
+
state.leaves[node.id] ?? { status: incoming.has(node.id) ? 'INACTIVE' : 'PENDING', evidence: [], acceptance: 'UNVERIFIED_REFERENCE' }]))
|
|
317
|
+
state.plan = copy(data.plan)
|
|
318
|
+
// A newly added predeclared route may originate in an already finished leaf.
|
|
319
|
+
for (const [id, leaf] of Object.entries(state.leaves)) {
|
|
320
|
+
if (leaf.status === 'COMPLETE' || leaf.status === 'INCOMPLETE') activate(state, id,
|
|
321
|
+
leaf.status === 'COMPLETE' ? 'complete' : 'incomplete', leaf.selectedCase ?? null)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
269
325
|
// Deterministic replay is authoritative; snapshots are checked against it.
|
|
270
326
|
export function replay(events) {
|
|
271
327
|
if (!Array.isArray(events) || !events.length || events.length > LIMITS.events) fail('EVENT_LIMIT')
|
|
@@ -285,6 +341,8 @@ export function replay(events) {
|
|
|
285
341
|
const leaves = Object.fromEntries(plan.nodes.filter(node => node.kind === 'leaf').map(node => [node.id,
|
|
286
342
|
{ status: incoming.has(node.id) ? 'INACTIVE' : 'PENDING', evidence: [], acceptance: 'UNVERIFIED_REFERENCE' }]))
|
|
287
343
|
state = { schema: 1, revision: 0, plan, leaves, active: null, operations: [] }
|
|
344
|
+
} else if (event.type === 'REVISED') {
|
|
345
|
+
reviseState(state, event.data)
|
|
288
346
|
} else if (event.type === 'BEGUN') {
|
|
289
347
|
object(event.data, ['nodeId', 'attemptId', 'owner'], 'begin event')
|
|
290
348
|
if (state.active || !legalNext(state).some(node => node.id === event.data.nodeId)
|
|
@@ -381,6 +439,13 @@ export class TransactionGraph {
|
|
|
381
439
|
return { plan: normalizePlan(definition, { enableSwitch: this.enableSwitch, id: this.id }) }
|
|
382
440
|
}); return view(result, this.owner)
|
|
383
441
|
}
|
|
442
|
+
revise({ expectedRevision, operationId, plan, review }) {
|
|
443
|
+
const result = this.mutate(expectedRevision, operationId, 'REVISED', state => {
|
|
444
|
+
if (!state) fail('NO_PLAN')
|
|
445
|
+
validateRevisionTransition(state, plan)
|
|
446
|
+
return { plan: copy(plan), review: copy(review) }
|
|
447
|
+
}); return view(result, this.owner)
|
|
448
|
+
}
|
|
384
449
|
begin({ expectedRevision, operationId, choice }) {
|
|
385
450
|
const result = this.mutate(expectedRevision, operationId, 'BEGUN', state => {
|
|
386
451
|
if (!state) fail('NO_PLAN')
|
package/extensions.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import { GraphError } from './core.mjs'
|
|
3
|
+
|
|
4
|
+
export const EXTENSION_CONTRACT = 'dsh-taskgraph-session-extension-v1'
|
|
5
|
+
|
|
6
|
+
/** Optional host extensions may suppress the classic interface per session.
|
|
7
|
+
* Policy, interaction and extension data belong to the registering plugin.
|
|
8
|
+
* With no provider, the original classic interface is unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export class SessionPolicies {
|
|
11
|
+
constructor() { this.providers = new Map(); this.closed = false }
|
|
12
|
+
register({ id, allowClassic }) {
|
|
13
|
+
if (this.closed || typeof id !== 'string' || !/^[a-z][a-z0-9_-]{0,79}$/.test(id)
|
|
14
|
+
|| typeof allowClassic !== 'function' || this.providers.has(id)) throw new GraphError('SESSION_EXTENSION_CONTRACT_REQUIRED')
|
|
15
|
+
const entry = { allowClassic }; this.providers.set(id, entry)
|
|
16
|
+
return () => { if (this.providers.get(id) === entry) this.providers.delete(id) }
|
|
17
|
+
}
|
|
18
|
+
allowClassic(agent) {
|
|
19
|
+
if (this.closed) return false
|
|
20
|
+
for (const provider of this.providers.values()) if (provider.allowClassic(agent) !== true) return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
close() { this.closed = true; this.providers.clear() }
|
|
24
|
+
}
|
package/host.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { KEY, INTENT, RECEIPT, RECOVERY } from './projection.mjs'
|
|
|
9
9
|
import { administrative, incompleteDisposition, ownedExecutionChain, unresolvedWork, workKey } from './work-policy.mjs'
|
|
10
10
|
|
|
11
11
|
const fail = (code, detail = code) => { throw new GraphError(code, detail) }
|
|
12
|
+
// Preserve the standalone pre-extension default. Callers may opt into a policy.
|
|
13
|
+
export const ARTIFACT_MAX_BYTES = 16 * 1024 * 1024
|
|
12
14
|
const under = (root, target) => { const relative = path.relative(root, target); return !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep) }
|
|
13
15
|
export function realProjectPath(root, requested, { directory = true } = {}) {
|
|
14
16
|
if (typeof requested !== 'string' || !path.isAbsolute(requested)) fail('ABSOLUTE_PROJECT_PATH_REQUIRED')
|
|
@@ -38,15 +40,33 @@ function referenceSeq(reference, sessionId) {
|
|
|
38
40
|
if (!Number.isSafeInteger(value) || value < 0) fail('EVIDENCE_BAD_SEQ')
|
|
39
41
|
return value
|
|
40
42
|
}
|
|
41
|
-
export function artifactProof(projectRoot, ref, signal) {
|
|
43
|
+
export function artifactProof(projectRoot, ref, signal, { maxBytes = ARTIFACT_MAX_BYTES } = {}) {
|
|
42
44
|
if (!ref.sha256) fail('ARTIFACT_SHA_REQUIRED')
|
|
45
|
+
const identity = readArtifactIdentity(projectRoot, ref, signal, { maxBytes })
|
|
46
|
+
if (identity.sha256 !== ref.sha256) fail('ARTIFACT_CHANGED_OR_HASH_MISMATCH')
|
|
47
|
+
return identity
|
|
48
|
+
}
|
|
49
|
+
/** Observation of a historically verified deliverable, never current acceptance. */
|
|
50
|
+
export function artifactCurrentIdentity(projectRoot, ref, signal, options) {
|
|
51
|
+
if (!ref.sha256) fail('ARTIFACT_SHA_REQUIRED')
|
|
52
|
+
try {
|
|
53
|
+
const identity = readArtifactIdentity(projectRoot, ref, signal, options)
|
|
54
|
+
return { ref: identity.path, acceptedSha256: ref.sha256, currentSha256: identity.sha256,
|
|
55
|
+
status: identity.sha256 === ref.sha256 ? 'UNCHANGED' : 'CHANGED', stat: identity.stat }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== 'ENOENT') throw error
|
|
58
|
+
return { ref: ref.ref, acceptedSha256: ref.sha256, currentSha256: null, status: 'MISSING' }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function readArtifactIdentity(projectRoot, ref, signal, { maxBytes = ARTIFACT_MAX_BYTES } = {}) {
|
|
62
|
+
if (maxBytes !== null && (!Number.isSafeInteger(maxBytes) || maxBytes < 1)) fail('ARTIFACT_BYTE_POLICY_INVALID')
|
|
43
63
|
const requested = path.isAbsolute(ref.ref) ? ref.ref : path.resolve(projectRoot, ref.ref)
|
|
44
64
|
const file = realProjectPath(projectRoot, requested, { directory: false })
|
|
45
65
|
if (file.toLowerCase().endsWith('.gguf')) fail('MODEL_CONTENT_IS_NOT_TRANSACTION_ARTIFACT')
|
|
46
66
|
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
47
67
|
try {
|
|
48
68
|
const before = fs.fstatSync(fd, { bigint: true })
|
|
49
|
-
if (!before.isFile() || before.size >
|
|
69
|
+
if (!before.isFile() || maxBytes !== null && before.size > BigInt(maxBytes)) fail('ARTIFACT_BYTE_LIMIT')
|
|
50
70
|
const hash = createHash('sha256'), buffer = Buffer.alloc(65536)
|
|
51
71
|
let total = 0n
|
|
52
72
|
while (true) {
|
|
@@ -59,8 +79,8 @@ export function artifactProof(projectRoot, ref, signal) {
|
|
|
59
79
|
const after = fs.fstatSync(fd, { bigint: true }), outside = fs.lstatSync(file, { bigint: true })
|
|
60
80
|
const keys = ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs']
|
|
61
81
|
if (total !== before.size || keys.some(key => before[key] !== after[key] || before[key] !== outside[key])
|
|
62
|
-
|| fs.realpathSync(file) !== file
|
|
63
|
-
return { kind: 'artifact', refDigest: digest(ref), path: file, sha256:
|
|
82
|
+
|| fs.realpathSync(file) !== file) fail('ARTIFACT_CHANGED_OR_HASH_MISMATCH')
|
|
83
|
+
return { kind: 'artifact', refDigest: digest(ref), path: file, sha256: hash.digest('hex'),
|
|
64
84
|
stat: Object.fromEntries(keys.map(key => [key, String(before[key])])) }
|
|
65
85
|
} finally { fs.closeSync(fd) }
|
|
66
86
|
}
|
|
@@ -182,6 +202,25 @@ export class DshTransactionHost {
|
|
|
182
202
|
pending, legalNext: choices(display.legalNext), skipChoices: choices(display.skipChoices) } }
|
|
183
203
|
} catch { return { ...base, status: 'unavailable' } }
|
|
184
204
|
}
|
|
205
|
+
presence(session) {
|
|
206
|
+
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')
|
|
209
|
+
try {
|
|
210
|
+
const binding = this.binding(session)
|
|
211
|
+
const file = path.join(binding.projectRoot, this.storageRelative, digest(binding), 'transaction-host-journal.json')
|
|
212
|
+
if (!fs.existsSync(file)) {
|
|
213
|
+
if (projected.intents.length || projected.receipts.length) fail('HOST_JOURNAL_MISSING_FOR_RECORDED_SESSION')
|
|
214
|
+
return { state: 'NEVER_CREATED', previouslyCommitted: false, revision: 0 }
|
|
215
|
+
}
|
|
216
|
+
const { saved } = this.journal(session, { readOnly: true })
|
|
217
|
+
return { state: saved.document ? 'PRESENT' : previouslyCommitted ? 'LOST_OR_INVALID_AFTER_COMMIT' : saved.pending ? 'CREATION_PENDING' : 'NEVER_CREATED', previouslyCommitted,
|
|
218
|
+
revision: saved.document?.revision ?? 0 }
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return { state: previouslyCommitted ? 'LOST_OR_INVALID_AFTER_COMMIT' : 'UNKNOWN_INTERRUPTED_CREATION',
|
|
221
|
+
previouslyCommitted, code: error.code ?? error.name }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
185
224
|
assertOwned(authority, exec) {
|
|
186
225
|
const agent = exec.agent
|
|
187
226
|
exec.signal?.throwIfAborted()
|
|
@@ -194,6 +233,7 @@ export class DshTransactionHost {
|
|
|
194
233
|
if (!this.projection(agent.session).turn) fail('OPEN_MODEL_TURN_REQUIRED')
|
|
195
234
|
}
|
|
196
235
|
async authorize(exec) {
|
|
236
|
+
if (this.allowClassic && !this.allowClassic(exec.agent)) fail('TRANSACTION_GRAPH_SESSION_EXTENSION_DENIED')
|
|
197
237
|
if (!exec.agent || this.ctx.agents.get(exec.agent.id) !== exec.agent || this.ctx.agents.currentInitiator() !== exec.agent) fail('EXACT_CALLING_AGENT_REQUIRED')
|
|
198
238
|
await this.ctx.workingDirectory.ensure(exec.agent, exec.signal)
|
|
199
239
|
const { binding, key } = this.journal(exec.agent.session)
|
|
@@ -276,6 +316,7 @@ export class DshTransactionHost {
|
|
|
276
316
|
if (canonical(current) !== canonical(record)) fail('PENDING_RESULT_CHANGED_DURING_FLUSH')
|
|
277
317
|
if (p.intentSeq === null) saved = journal.bindIntent(p.operationId, record.intentSeq)
|
|
278
318
|
let rejectReason = null
|
|
319
|
+
if (p.operation === 'revise') rejectReason = 'This retired host revision entry requires its original extension; it cannot commit in standalone mode.'
|
|
279
320
|
if (p.operation === 'finish' && record.status === 'SUCCESS') {
|
|
280
321
|
try {
|
|
281
322
|
const work = this.workState(session, saved, projected)
|
package/index.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { createToolDefinitions } from './tools.mjs'
|
|
|
7
7
|
import { DshTransactionHost } from './host.mjs'
|
|
8
8
|
import { projection, KEY } from './projection.mjs'
|
|
9
9
|
import { createRemoteContribution } from './remote.mjs'
|
|
10
|
+
import { WorkflowRegistry, WORKFLOW_CONTRACT } from './interop.mjs'
|
|
11
|
+
import { EXTENSION_CONTRACT, SessionPolicies } from './extensions.mjs'
|
|
12
|
+
import { FileGraphStore } from './store.mjs'
|
|
10
13
|
|
|
11
14
|
export const name = 'dsh-bettertaskgraphs'
|
|
12
15
|
let bindSessionResource
|
|
@@ -24,15 +27,20 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
|
|
|
24
27
|
|| Object.keys(config).some(key => !['projectRoot', 'storageRelative', 'enableSwitch'].includes(key)))
|
|
25
28
|
throw new Error('Invalid transaction graph configuration')
|
|
26
29
|
this.host = new DshTransactionHost(ctx, { ...config, appendPluginRecord })
|
|
30
|
+
this.workflows = new WorkflowRegistry(ctx)
|
|
31
|
+
this.sessionPolicies = new SessionPolicies()
|
|
32
|
+
this.host.allowClassic = agent => this.sessionPolicies.allowClassic(agent)
|
|
33
|
+
Object.defineProperty(this, 'workflowContract', { value: WORKFLOW_CONTRACT })
|
|
34
|
+
Object.defineProperty(this, 'extensionContract', { value: EXTENSION_CONTRACT })
|
|
27
35
|
ctx.sessionProjections.register(projection)
|
|
28
36
|
for (const definition of createToolDefinitions({ defineTool, hostPort: this.host })) {
|
|
29
37
|
this.host.definitions.set(definition.name, definition)
|
|
30
38
|
ctx.tools.register(definition)
|
|
31
39
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
ctx.systemPrompt.section({ name: 'project:transaction-graph', order: 46,
|
|
41
|
+
text: ({ agent }) => this.host.inProject(agent?.session) && this.sessionPolicies.allowClassic(agent) ? guidance : '' })
|
|
42
|
+
ctx.systemPrompt.context({ name: 'project:transaction-graph-state', order: 46, required: true, interpolate: false,
|
|
43
|
+
text: ({ agent }) => this.host.inProject(agent?.session) && this.sessionPolicies.allowClassic(agent) ? this.host.context(agent) : '' })
|
|
36
44
|
ctx.inject(['typert', 'sessionController'], remoteCtx => {
|
|
37
45
|
const typert = remoteCtx.typert
|
|
38
46
|
if (typert.resources.get('session')?.field !== 'sessionId') throw new Error('Transaction graph requires the native Session RPC resource')
|
|
@@ -54,7 +62,7 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
|
|
|
54
62
|
queueMicrotask(() => { if (!this.host.closed) this.host.schedule(session) })
|
|
55
63
|
}
|
|
56
64
|
})
|
|
57
|
-
ctx.effect(() => () => this.host.close())
|
|
65
|
+
ctx.effect(() => () => { this.workflows.close(); this.sessionPolicies.close(); return this.host.close() })
|
|
58
66
|
}
|
|
59
67
|
async status({ sessionId }, signal) {
|
|
60
68
|
signal?.throwIfAborted()
|
|
@@ -67,4 +75,15 @@ export default class TransactionGraphPlugin extends TypertRemoteService {
|
|
|
67
75
|
}
|
|
68
76
|
registerRecoveryVerifier(adapter) { return this.host.registerRecoveryVerifier(adapter) }
|
|
69
77
|
recoverInterrupted(agent, request) { return this.host.recoverInterrupted(agent, request) }
|
|
78
|
+
registerWorkflowProvider(adapter) { return this.workflows.register(adapter) }
|
|
79
|
+
listWorkflows(agent) { return this.workflows.list(agent) }
|
|
80
|
+
readWorkflow(agent, reference) { return this.workflows.read(agent, reference) }
|
|
81
|
+
dispatchWorkflow(agent, reference, operation, request, signal) { return this.workflows.dispatch(agent, reference, operation, request, signal) }
|
|
82
|
+
subscribeWorkflows(callback) { return this.workflows.subscribe(callback) }
|
|
83
|
+
registerSessionPolicy(provider) { return this.sessionPolicies.register(provider) }
|
|
84
|
+
createExtensionStore(options) {
|
|
85
|
+
if (this.host.closed) throw new Error('TASK_GRAPH_SERVICE_CLOSED')
|
|
86
|
+
return new FileGraphStore(options)
|
|
87
|
+
}
|
|
88
|
+
inspectGraphPresence(agent) { return this.host.presence(agent.session) }
|
|
70
89
|
}
|
package/interop.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { copy, GraphError, validateDocument } from './core.mjs'
|
|
5
|
+
|
|
6
|
+
export const WORKFLOW_CONTRACT = 'dsh-taskgraph-workflow-v1'
|
|
7
|
+
const name = value => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,79}$/.test(value)
|
|
8
|
+
const fail = code => { throw new GraphError(code) }
|
|
9
|
+
const within = (root, target) => { const relative = path.relative(root, target); return relative === '' || relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative) }
|
|
10
|
+
|
|
11
|
+
/** In-process plugin port. A provider's joint ledger remains the sole authority.
|
|
12
|
+
* No model-supplied callback, synthetic session proof or mirrored commit is used.
|
|
13
|
+
*/
|
|
14
|
+
export class WorkflowRegistry {
|
|
15
|
+
constructor(ctx) { this.ctx = ctx; this.entries = new Map(); this.listeners = new Set(); this.closed = false }
|
|
16
|
+
register(adapter) {
|
|
17
|
+
if (this.closed || adapter?.contract !== WORKFLOW_CONTRACT || !name(adapter.kind) || !name(adapter.workflowId)
|
|
18
|
+
|| !Number.isSafeInteger(adapter.version) || adapter.version !== 1 || typeof adapter.read !== 'function'
|
|
19
|
+
|| typeof adapter.execute !== 'function' || typeof adapter.authorize !== 'function'
|
|
20
|
+
|| !Array.isArray(adapter.operations) || adapter.operations.some(operation => !name(operation))
|
|
21
|
+
|| new Set(adapter.operations).size !== adapter.operations.length) fail('WORKFLOW_ADAPTER_CONTRACT_REQUIRED')
|
|
22
|
+
if (typeof adapter.projectRoot !== 'string' || !path.isAbsolute(adapter.projectRoot)
|
|
23
|
+
|| fs.realpathSync(adapter.projectRoot) !== adapter.projectRoot || !fs.statSync(adapter.projectRoot).isDirectory()) fail('WORKFLOW_REAL_PROJECT_REQUIRED')
|
|
24
|
+
const key = `${adapter.kind}:${adapter.workflowId}`
|
|
25
|
+
if (this.entries.has(key)) fail('WORKFLOW_PROVIDER_ALREADY_REGISTERED')
|
|
26
|
+
const entry = { ...adapter, operations: Object.freeze([...adapter.operations]), lifecycle: new AbortController() }
|
|
27
|
+
this.entries.set(key, entry)
|
|
28
|
+
const publish = event => {
|
|
29
|
+
if (this.entries.get(key) !== entry || this.closed) return
|
|
30
|
+
// Only post-commit revision metadata crosses the notification port.
|
|
31
|
+
const notice = Object.freeze({ contract: WORKFLOW_CONTRACT, kind: entry.kind, workflowId: entry.workflowId,
|
|
32
|
+
type: typeof event?.type === 'string' ? event.type : 'changed', revision: this.snapshot(entry).revision })
|
|
33
|
+
for (const callback of this.listeners) { try { callback(notice) } catch (error) { this.ctx.logger?.warn?.(`workflow listener failed: ${error.code ?? error.name}`) } }
|
|
34
|
+
}
|
|
35
|
+
let unsubscribe
|
|
36
|
+
try { unsubscribe = adapter.subscribe?.(publish); entry.unsubscribe = unsubscribe } catch (error) { this.entries.delete(key); throw error }
|
|
37
|
+
return () => {
|
|
38
|
+
if (this.entries.get(key) !== entry) return
|
|
39
|
+
this.entries.delete(key); entry.lifecycle.abort(new GraphError('WORKFLOW_PROVIDER_DISPOSED')); unsubscribe?.()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
authorize(entry, agent) {
|
|
43
|
+
if (this.closed || !agent || this.ctx.agents.get(agent.id) !== agent || this.ctx.sessions.get(agent.session.id) !== agent.session)
|
|
44
|
+
fail('WORKFLOW_EXACT_CONTROLLER_REQUIRED')
|
|
45
|
+
const cwd = this.ctx.workingDirectory.get(agent.session)
|
|
46
|
+
if (typeof cwd !== 'string' || !within(entry.projectRoot, fs.realpathSync(cwd))
|
|
47
|
+
|| !within(entry.projectRoot, fs.realpathSync(agent.session.header.cwd))) fail('WORKFLOW_CONTROLLER_OUTSIDE_PROJECT')
|
|
48
|
+
if (entry.authorize(agent) !== true) fail('WORKFLOW_CONTROLLER_NOT_AUTHORIZED')
|
|
49
|
+
}
|
|
50
|
+
entry({ kind, workflowId }, agent) {
|
|
51
|
+
const entry = this.entries.get(`${kind}:${workflowId}`)
|
|
52
|
+
if (!entry || this.closed) fail('WORKFLOW_PROVIDER_UNAVAILABLE')
|
|
53
|
+
this.authorize(entry, agent); return entry
|
|
54
|
+
}
|
|
55
|
+
snapshot(entry) {
|
|
56
|
+
const value = copy(entry.read())
|
|
57
|
+
if (!value || !Number.isSafeInteger(value.revision) || value.revision < 0 || (value.graph === null) !== (value.revision === 0)) fail('WORKFLOW_INVALID_SNAPSHOT')
|
|
58
|
+
if (value.graph !== null) { validateDocument(value.graph); if (value.graph.revision !== value.revision) fail('WORKFLOW_REVISION_MISMATCH') }
|
|
59
|
+
return { ...value, contract: WORKFLOW_CONTRACT, kind: entry.kind, workflowId: entry.workflowId,
|
|
60
|
+
operations: [...entry.operations], goalReached: false }
|
|
61
|
+
}
|
|
62
|
+
list(agent) {
|
|
63
|
+
const result = []
|
|
64
|
+
for (const entry of this.entries.values()) {
|
|
65
|
+
try { this.authorize(entry, agent) } catch { continue }
|
|
66
|
+
result.push({ contract: WORKFLOW_CONTRACT, kind: entry.kind, workflowId: entry.workflowId, revision: this.snapshot(entry).revision, operations: [...entry.operations] })
|
|
67
|
+
}
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
read(agent, reference) { return this.snapshot(this.entry(reference, agent)) }
|
|
71
|
+
async dispatch(agent, reference, operation, request, signal) {
|
|
72
|
+
const entry = this.entry(reference, agent)
|
|
73
|
+
if (!entry.operations.includes(operation) || !request || typeof request !== 'object' || Array.isArray(request)) fail('WORKFLOW_OPERATION_NOT_SUPPORTED')
|
|
74
|
+
const input = copy(request), scope = AbortSignal.any([entry.lifecycle.signal, ...(signal ? [signal] : [])])
|
|
75
|
+
scope.throwIfAborted()
|
|
76
|
+
const result = await entry.execute(operation, input, { agent, signal: scope })
|
|
77
|
+
scope.throwIfAborted(); this.authorize(entry, agent)
|
|
78
|
+
if (this.entries.get(`${entry.kind}:${entry.workflowId}`) !== entry) fail('WORKFLOW_PROVIDER_DISPOSED')
|
|
79
|
+
return copy(result)
|
|
80
|
+
}
|
|
81
|
+
subscribe(callback) {
|
|
82
|
+
if (this.closed || typeof callback !== 'function') fail('WORKFLOW_LISTENER_REQUIRED')
|
|
83
|
+
this.listeners.add(callback); return () => this.listeners.delete(callback)
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
if (this.closed) return
|
|
87
|
+
this.closed = true
|
|
88
|
+
for (const entry of this.entries.values()) { entry.lifecycle.abort(new GraphError('WORKFLOW_PROVIDER_DISPOSED')); entry.unsubscribe?.() }
|
|
89
|
+
this.entries.clear(); this.listeners.clear()
|
|
90
|
+
}
|
|
91
|
+
}
|
package/journal.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function validateJournal(value, binding) {
|
|
|
27
27
|
|| (row.graphChecksum !== null && !hex(row.graphChecksum)) || !integer(row.resultSeq)
|
|
28
28
|
|| (row.reason !== null && (typeof row.reason !== 'string' || row.reason.length > 1200))
|
|
29
29
|
|| !hex(row.claimDigest) || !hex(row.candidateChecksum) || !integer(row.intentSeq) || !integer(row.expectedGraphRevision)
|
|
30
|
-
|| !['create', 'begin', 'finish', 'skip', 'reconcile'].includes(row.operation)
|
|
30
|
+
|| !['create', 'begin', 'finish', 'skip', 'reconcile', 'revise'].includes(row.operation)
|
|
31
31
|
|| !Array.isArray(row.evidenceDigests) || row.evidenceDigests.length > 32 || row.evidenceDigests.some(x => !hex(x)))
|
|
32
32
|
fail('JOURNAL_CLAIM_INVALID')
|
|
33
33
|
operations.add(row.operationId)
|
|
@@ -45,7 +45,7 @@ export function validateJournal(value, binding) {
|
|
|
45
45
|
'owner', 'bindingDigest', 'expectedGraphRevision', 'candidate', 'resolvedEvidence', 'intentSeq', 'claimDigest', 'clearedWork'])
|
|
46
46
|
|| operations.has(p.operationId) || !hex(p.payloadHash) || !hex(p.bindingDigest) || !hex(p.claimDigest)
|
|
47
47
|
|| claimDigest(p) !== p.claimDigest || p.bindingDigest !== digest(binding) || p.sessionId !== binding.sessionId
|
|
48
|
-
|| !['create', 'begin', 'finish', 'skip', 'reconcile'].includes(p.operation) || !integer(p.turn)
|
|
48
|
+
|| !['create', 'begin', 'finish', 'skip', 'reconcile', 'revise'].includes(p.operation) || !integer(p.turn)
|
|
49
49
|
|| p.expectedGraphRevision !== (value.document?.revision ?? 0) || (p.intentSeq !== null && !integer(p.intentSeq))
|
|
50
50
|
|| !Array.isArray(p.resolvedEvidence) || p.resolvedEvidence.length > 32 || !Array.isArray(p.clearedWork) || p.clearedWork.length > 1024
|
|
51
51
|
|| p.clearedWork.some(row => !fields(row, ['rootCallId', 'callSeq', 'rootResultSeq', 'eventDigest'])
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bettertaskgraphs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-rc.11",
|
|
4
4
|
"description": "Transactional task graphs with a conversation panel for DeepSeek Harness",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
|
-
"keywords": [
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"task-graph"
|
|
11
|
+
],
|
|
7
12
|
"homepage": "https://github.com/oldcitynight/dsh-bettertaskgraphs#readme",
|
|
8
13
|
"repository": {
|
|
9
14
|
"type": "git",
|
|
@@ -16,7 +21,12 @@
|
|
|
16
21
|
"main": "./index.mjs",
|
|
17
22
|
"exports": {
|
|
18
23
|
".": "./index.mjs",
|
|
19
|
-
"./
|
|
24
|
+
"./core": "./core.mjs",
|
|
25
|
+
"./store": "./store.mjs",
|
|
26
|
+
"./host": "./host.mjs",
|
|
27
|
+
"./interop": "./interop.mjs",
|
|
28
|
+
"./client": "./client.js",
|
|
29
|
+
"./extensions": "./extensions.mjs"
|
|
20
30
|
},
|
|
21
31
|
"files": [
|
|
22
32
|
"client.js",
|
|
@@ -25,6 +35,7 @@
|
|
|
25
35
|
"core.mjs",
|
|
26
36
|
"host.mjs",
|
|
27
37
|
"index.mjs",
|
|
38
|
+
"interop.mjs",
|
|
28
39
|
"journal.mjs",
|
|
29
40
|
"projection.mjs",
|
|
30
41
|
"remote.mjs",
|
|
@@ -32,7 +43,9 @@
|
|
|
32
43
|
"store.mjs",
|
|
33
44
|
"tools.mjs",
|
|
34
45
|
"view-model.mjs",
|
|
35
|
-
"work-policy.mjs"
|
|
46
|
+
"work-policy.mjs",
|
|
47
|
+
"extensions.mjs",
|
|
48
|
+
"VALIDATION.md"
|
|
36
49
|
],
|
|
37
50
|
"engines": {
|
|
38
51
|
"node": "^22.19.0 || >=24.0.0",
|
|
@@ -48,6 +61,7 @@
|
|
|
48
61
|
},
|
|
49
62
|
"publishConfig": {
|
|
50
63
|
"access": "public",
|
|
64
|
+
"tag": "next",
|
|
51
65
|
"registry": "https://registry.npmjs.org/"
|
|
52
66
|
},
|
|
53
67
|
"dependencies": {
|
package/projection.mjs
CHANGED
|
@@ -12,11 +12,12 @@ const bounded = n => z.string().min(1).max(n)
|
|
|
12
12
|
const hex = z.string().regex(/^[a-f0-9]{64}$/)
|
|
13
13
|
const seq = z.number().int().nonnegative()
|
|
14
14
|
const workSchema = z.object({ rootCallId: bounded(512), callSeq: seq, rootResultSeq: seq.nullable(), eventDigest: hex }).strict()
|
|
15
|
+
const bashFailureSchema = z.object({ rootCallId: bounded(512), callId: bounded(512) }).strict()
|
|
15
16
|
const factSchema = z.object({ seq, callId: bounded(512), tool: bounded(300), isError: z.boolean(),
|
|
16
17
|
outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']) }).strict()
|
|
17
18
|
const intentSchema = z.object({ version: z.literal(4), operationId: bounded(80), callId: bounded(512),
|
|
18
19
|
rootCallId: bounded(512), turn: seq, sessionId: bounded(512), bindingDigest: hex, claimDigest: hex,
|
|
19
|
-
operation: z.enum(['create', 'begin', 'finish', 'skip', 'reconcile']), expectedGraphRevision: seq, candidateChecksum: hex,
|
|
20
|
+
operation: z.enum(['create', 'begin', 'finish', 'skip', 'reconcile', 'revise']), expectedGraphRevision: seq, candidateChecksum: hex,
|
|
20
21
|
clearedWork: z.array(workSchema).max(1024) }).strict()
|
|
21
22
|
const dispatchSchema = z.object({ seq, subCallId: bounded(512), parentCallId: bounded(512), rootCallId: bounded(512),
|
|
22
23
|
tool: bounded(300), isError: z.boolean(), outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']), eventDigest: hex, turn: seq }).strict()
|
|
@@ -30,13 +31,14 @@ const pendingSchema = z.object({ intentSeq: seq, data: intentSchema,
|
|
|
30
31
|
export const stateSchema = z.object({ sessionId: bounded(512), ownFrom: seq, turn: seq, lastSeq: z.number().int().min(-1),
|
|
31
32
|
workStarted: seq, workExecuted: seq, uncertainties: z.array(workSchema).max(1024), acknowledgedWork: z.array(workSchema).max(1024),
|
|
32
33
|
calls: z.record(z.string(), z.object({ tool: bounded(300), turn: seq, seq, eventDigest: hex }).strict()),
|
|
34
|
+
bashFailures: z.array(bashFailureSchema).max(256),
|
|
33
35
|
dispatches: z.array(dispatchSchema).max(256), intents: z.array(pendingSchema).max(1024),
|
|
34
36
|
proofs: z.array(proofSchema).max(MAX_PROOFS), receipts: z.array(z.object({ operationId: bounded(80), seq,
|
|
35
37
|
status: z.enum(['COMMITTED', 'ABORTED']), graphRevision: seq, graphChecksum: hex.nullable(), eventDigest: hex }).strict()).max(1024), failure: z.string().nullable() }).strict()
|
|
36
38
|
export function initialState(header, inheritedEventCount = 0) {
|
|
37
39
|
return { sessionId: header.id, ownFrom: inheritedEventCount, turn: 0, lastSeq: -1, workStarted: 0, workExecuted: 0,
|
|
38
40
|
uncertainties: [], acknowledgedWork: [],
|
|
39
|
-
calls: {}, dispatches: [], intents: [], proofs: [], receipts: [], failure: null }
|
|
41
|
+
calls: {}, bashFailures: [], dispatches: [], intents: [], proofs: [], receipts: [], failure: null }
|
|
40
42
|
}
|
|
41
43
|
export function outcomeOfContent(content = []) {
|
|
42
44
|
const outcomes = new Set()
|
|
@@ -55,6 +57,11 @@ export function outcomeOfContent(content = []) {
|
|
|
55
57
|
// A first FAIL/NOT_RUN block may not hide a later UNKNOWN/contradiction.
|
|
56
58
|
return outcomes.has('UNKNOWN') || outcomes.size > 1 ? 'UNKNOWN' : [...outcomes][0] ?? 'UNVERIFIED'
|
|
57
59
|
}
|
|
60
|
+
function toolOutcome(state, tool, rootCallId, callId, content) {
|
|
61
|
+
const outcome = outcomeOfContent(content)
|
|
62
|
+
if (tool !== 'bash' || !state.bashFailures.some(row => row.rootCallId === rootCallId && row.callId === callId)) return outcome
|
|
63
|
+
return outcome === 'UNKNOWN' || outcome === 'NOT_RUN' ? 'UNKNOWN' : 'FAIL'
|
|
64
|
+
}
|
|
58
65
|
export function chainQualification(start, root, dispatches) {
|
|
59
66
|
let child = start
|
|
60
67
|
const visited = new Set()
|
|
@@ -94,7 +101,7 @@ export function applyEvent(state, event) {
|
|
|
94
101
|
if (event.seq < state.ownFrom) return state // Fork parents cannot authorize a child's graph.
|
|
95
102
|
if (event.type.startsWith('plugin:project-transaction-graph/') && ![INTENT, RECEIPT, RECOVERY].includes(event.type))
|
|
96
103
|
throw new GraphError('LEGACY_GRAPH_REQUIRES_EXPLICIT_MIGRATION')
|
|
97
|
-
if (!['turn/start', 'turn/end', 'tool/call', 'tool/ptc-dispatch', 'tool/result', INTENT, RECEIPT, RECOVERY].includes(event.type)) return state
|
|
104
|
+
if (!['turn/start', 'turn/end', 'tool/call', 'tool/ptc-dispatch', 'tool/result', 'job/settled', INTENT, RECEIPT, RECOVERY].includes(event.type)) return state
|
|
98
105
|
const d = event.data
|
|
99
106
|
let next = { ...state, lastSeq: event.seq }
|
|
100
107
|
if (event.type === 'turn/start') return { ...next, turn: d.turn }
|
|
@@ -126,6 +133,18 @@ export function applyEvent(state, event) {
|
|
|
126
133
|
rootResultSeq: event.type === RECOVERY ? event.seq : null, rootResultDigest: event.type === RECOVERY ? digest(event) : null,
|
|
127
134
|
workStartedAtRoot: event.type === RECOVERY ? state.workStarted : null, workExecutedAtRoot: event.type === RECOVERY ? state.workExecuted : null }] }
|
|
128
135
|
}
|
|
136
|
+
if (event.type === 'job/settled') {
|
|
137
|
+
const origin = d.toolCall
|
|
138
|
+
if (d.kind !== 'bash' || d.owner !== state.sessionId || !origin || !Object.hasOwn(state.calls, origin.rootCallId)
|
|
139
|
+
|| d.status !== 'completed' || d.producerReported !== true) return next
|
|
140
|
+
// Native Bash reports a nonzero exit in the owned job log even when its tool message is not an error.
|
|
141
|
+
const exit = /^exit code: (-?\d+)(?:;|$)/.exec(d.detail ?? '')
|
|
142
|
+
if (!exit || Number(exit[1]) === 0) return next
|
|
143
|
+
const failure = bashFailureSchema.parse({ rootCallId: origin.rootCallId, callId: origin.callId })
|
|
144
|
+
if (state.bashFailures.some(row => row.rootCallId === failure.rootCallId && row.callId === failure.callId)) return next
|
|
145
|
+
if (state.bashFailures.length >= 256) throw new GraphError('BASH_FAILURE_RECORD_LIMIT')
|
|
146
|
+
return { ...next, bashFailures: [...state.bashFailures, failure] }
|
|
147
|
+
}
|
|
129
148
|
if (event.type === 'tool/ptc-dispatch') {
|
|
130
149
|
const rootCallId = d.rootCallId ?? d.parentCallId
|
|
131
150
|
if (!Object.hasOwn(state.calls, rootCallId)) {
|
|
@@ -136,7 +155,7 @@ export function applyEvent(state, event) {
|
|
|
136
155
|
}
|
|
137
156
|
const dispatch = dispatchSchema.parse({ seq: event.seq, subCallId: d.subCallId, parentCallId: d.parentCallId,
|
|
138
157
|
rootCallId, tool: d.name, isError: d.isError,
|
|
139
|
-
outcome:
|
|
158
|
+
outcome: toolOutcome(state, d.name, rootCallId, d.subCallId, d.content), eventDigest: digest(event), turn: state.turn })
|
|
140
159
|
if (state.dispatches.some(row => row.subCallId === dispatch.subCallId && row.rootCallId === dispatch.rootCallId)
|
|
141
160
|
|| state.dispatches.length >= 256) throw new GraphError('PTC_DISPATCH_REPLAY_OR_LIMIT')
|
|
142
161
|
return { ...next, workStarted: state.workStarted + (!internal(d.name) && d.name !== 'run_code' ? 1 : 0),
|
|
@@ -145,9 +164,6 @@ export function applyEvent(state, event) {
|
|
|
145
164
|
if (event.type === 'tool/result') {
|
|
146
165
|
const root = d.message.source.callId, success = d.message.isError !== true // Optional false in the published ToolResultMessage API.
|
|
147
166
|
const syntheticUnknown = [TOOL_OUTCOME_UNKNOWN, TOOL_NOT_STARTED].includes(d.error?.code)
|
|
148
|
-
const rootOutcome = outcomeOfContent(d.message.content)
|
|
149
|
-
const rootQualification = syntheticUnknown || rootOutcome === 'UNKNOWN' ? 'UNKNOWN'
|
|
150
|
-
: !success || ['FAIL', 'NOT_RUN'].includes(rootOutcome) ? 'FAILED' : 'SUCCESS'
|
|
151
167
|
const sources = state.dispatches.filter(row => row.rootCallId === root)
|
|
152
168
|
const call = state.calls[root]
|
|
153
169
|
if (!call) {
|
|
@@ -158,6 +174,9 @@ export function applyEvent(state, event) {
|
|
|
158
174
|
return next
|
|
159
175
|
}
|
|
160
176
|
const standaloneCode = call.tool === 'run_code' && !sources.length
|
|
177
|
+
const rootOutcome = toolOutcome(state, call.tool, root, root, d.message.content)
|
|
178
|
+
const rootQualification = syntheticUnknown || rootOutcome === 'UNKNOWN' ? 'UNKNOWN'
|
|
179
|
+
: !success || ['FAIL', 'NOT_RUN'].includes(rootOutcome) ? 'FAILED' : 'SUCCESS'
|
|
161
180
|
const leafCode = sources.filter(row => row.tool === 'run_code' && !sources.some(child => child.parentCallId === row.subCallId))
|
|
162
181
|
const workStarted = state.workStarted + (standaloneCode ? 1 : 0) + leafCode.length
|
|
163
182
|
const executed = sources.filter(row => !internal(row.tool) && (row.tool !== 'run_code' || leafCode.includes(row)) && !['NOT_RUN', 'UNKNOWN'].includes(row.outcome)).length
|
|
@@ -189,6 +208,7 @@ export function applyEvent(state, event) {
|
|
|
189
208
|
if (uncertainties.length > 1024) throw new GraphError('UNRESOLVED_WORK_LIMIT')
|
|
190
209
|
}
|
|
191
210
|
return { ...next, calls, intents, workStarted, workExecuted, uncertainties, dispatches: state.dispatches.filter(row => row.rootCallId !== root),
|
|
211
|
+
bashFailures: state.bashFailures.filter(row => row.rootCallId !== root),
|
|
192
212
|
proofs: [...state.proofs, ...proofs].slice(-MAX_PROOFS) }
|
|
193
213
|
}
|
|
194
214
|
if (event.type === RECEIPT) {
|
|
@@ -202,4 +222,4 @@ export function applyEvent(state, event) {
|
|
|
202
222
|
return next
|
|
203
223
|
} catch (error) { return { ...state, failure: `Transaction projection failed at ${event.seq}: ${error.message}` } }
|
|
204
224
|
}
|
|
205
|
-
export const projection = { key: KEY, stateVersion:
|
|
225
|
+
export const projection = { key: KEY, stateVersion: 6, stateSchema, init: initialState, apply: applyEvent }
|
package/store.mjs
CHANGED
|
@@ -26,8 +26,8 @@ function regularBoundedJson(file, maximum = LIMITS.bytes) {
|
|
|
26
26
|
catch (error) { if (error.code === 'ENOENT') return null; throw error }
|
|
27
27
|
try {
|
|
28
28
|
const before = fs.fstatSync(fd)
|
|
29
|
-
if (!before.isFile() || before.size > maximum || before.size < 1) reject('INVALID_STORE_FILE')
|
|
30
|
-
const buffer = Buffer.alloc(Math.min(before.size + 1, maximum + 1))
|
|
29
|
+
if (!before.isFile() || maximum !== null && before.size > maximum || before.size < 1) reject('INVALID_STORE_FILE')
|
|
30
|
+
const buffer = Buffer.alloc(maximum === null ? before.size + 1 : Math.min(before.size + 1, maximum + 1))
|
|
31
31
|
let count = 0
|
|
32
32
|
while (count < buffer.length) {
|
|
33
33
|
const read = fs.readSync(fd, buffer, count, buffer.length - count, null)
|
|
@@ -60,8 +60,8 @@ export class MemoryGraphStore {
|
|
|
60
60
|
export class FileGraphStore {
|
|
61
61
|
constructor({ projectRoot, directory, fileName = 'transaction-graph.json', maximumBytes = LIMITS.bytes,
|
|
62
62
|
validator = validateDocument, updateValidator = checkNext, readOnly = false }) {
|
|
63
|
-
if (!/^[a-z][a-z0-9.-]{0,63}$/.test(fileName) || !Number.isSafeInteger(maximumBytes)
|
|
64
|
-
|| maximumBytes < 1 || maximumBytes > 12 * 1024 * 1024 || typeof validator !== 'function' || typeof updateValidator !== 'function') reject('INVALID_STORE_POLICY')
|
|
63
|
+
if (!/^[a-z][a-z0-9.-]{0,63}$/.test(fileName) || maximumBytes !== null && (!Number.isSafeInteger(maximumBytes)
|
|
64
|
+
|| maximumBytes < 1 || maximumBytes > 12 * 1024 * 1024) || typeof validator !== 'function' || typeof updateValidator !== 'function') reject('INVALID_STORE_POLICY')
|
|
65
65
|
this.maximumBytes = maximumBytes; this.validator = validator; this.updateValidator = updateValidator
|
|
66
66
|
this.readOnly = readOnly
|
|
67
67
|
if (typeof projectRoot !== 'string' || typeof directory !== 'string'
|
|
@@ -126,7 +126,7 @@ export class FileGraphStore {
|
|
|
126
126
|
this.validator(next)
|
|
127
127
|
this.updateValidator(previous, next)
|
|
128
128
|
const content = JSON.stringify(next) + '\n'
|
|
129
|
-
if (Buffer.byteLength(content) > this.maximumBytes) reject('STORE_BYTE_LIMIT')
|
|
129
|
+
if (this.maximumBytes !== null && Buffer.byteLength(content) > this.maximumBytes) reject('STORE_BYTE_LIMIT')
|
|
130
130
|
temporary = path.join(this.directory, '.transaction-graph-' + randomUUID() + '.tmp')
|
|
131
131
|
const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
|
|
132
132
|
try { temporaryIdentity = fs.fstatSync(fd); fs.writeFileSync(fd, content); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|