dsh-bettertaskgraphs 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +55 -2
- package/client.js +535 -0
- package/client.source.mjs +318 -0
- package/cordis.patch.yml +5 -0
- package/core.mjs +432 -0
- package/host.mjs +472 -0
- package/index.mjs +70 -0
- package/journal.mjs +131 -0
- package/package.json +78 -4
- package/projection.mjs +225 -0
- package/remote.mjs +51 -0
- package/scripts/build-client.mjs +26 -0
- package/store.mjs +152 -0
- package/tools.mjs +94 -0
- package/view-model.mjs +157 -0
- package/work-policy.mjs +74 -0
package/journal.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import { FileGraphStore } from './store.mjs'
|
|
3
|
+
import { canonical, copy, digest, GraphError, validateDocument } from './core.mjs'
|
|
4
|
+
export const MAX_CLAIMS = 1024
|
|
5
|
+
const fail = code => { throw new GraphError(code) }
|
|
6
|
+
const integer = value => Number.isSafeInteger(value) && value >= 0
|
|
7
|
+
const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
|
8
|
+
const fields = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
|
|
9
|
+
&& Object.keys(value).every(key => keys.includes(key)) && keys.every(key => Object.hasOwn(value, key))
|
|
10
|
+
export function seal(value) { const body = copy(value); delete body.checksum; return { ...body, checksum: digest(body) } }
|
|
11
|
+
export function claimDigest(value) { const body = copy(value); delete body.claimDigest; delete body.intentSeq; return digest(body) }
|
|
12
|
+
export function initialJournal(binding) {
|
|
13
|
+
return seal({ schema: 4, revision: 0, binding: copy(binding), document: null, activeProof: null, pending: null, claims: [] })
|
|
14
|
+
}
|
|
15
|
+
export function validateJournal(value, binding) {
|
|
16
|
+
if (!fields(value, ['schema', 'revision', 'binding', 'document', 'activeProof', 'pending', 'claims', 'checksum'])
|
|
17
|
+
|| value.schema !== 4 || !integer(value.revision) || canonical(value.binding) !== canonical(binding)
|
|
18
|
+
|| !hex(value.checksum) || seal(value).checksum !== value.checksum || !Array.isArray(value.claims) || value.claims.length > MAX_CLAIMS)
|
|
19
|
+
fail('JOURNAL_SCOPE_CHECKSUM_OR_LIMIT')
|
|
20
|
+
if (value.document) validateDocument(value.document)
|
|
21
|
+
const operations = new Set()
|
|
22
|
+
for (const row of value.claims) {
|
|
23
|
+
if (!fields(row, ['operationId', 'payloadHash', 'status', 'graphRevision', 'graphChecksum', 'resultSeq', 'reason', 'evidenceDigests',
|
|
24
|
+
'claimDigest', 'intentSeq', 'candidateChecksum', 'expectedGraphRevision', 'operation'])
|
|
25
|
+
|| typeof row.operationId !== 'string' || operations.has(row.operationId) || !hex(row.payloadHash)
|
|
26
|
+
|| !['COMMITTED', 'ABORTED'].includes(row.status) || !integer(row.graphRevision)
|
|
27
|
+
|| (row.graphChecksum !== null && !hex(row.graphChecksum)) || !integer(row.resultSeq)
|
|
28
|
+
|| (row.reason !== null && (typeof row.reason !== 'string' || row.reason.length > 1200))
|
|
29
|
+
|| !hex(row.claimDigest) || !hex(row.candidateChecksum) || !integer(row.intentSeq) || !integer(row.expectedGraphRevision)
|
|
30
|
+
|| !['create', 'begin', 'finish', 'skip', 'reconcile'].includes(row.operation)
|
|
31
|
+
|| !Array.isArray(row.evidenceDigests) || row.evidenceDigests.length > 32 || row.evidenceDigests.some(x => !hex(x)))
|
|
32
|
+
fail('JOURNAL_CLAIM_INVALID')
|
|
33
|
+
operations.add(row.operationId)
|
|
34
|
+
if ((value.document?.state.operations.includes(row.operationId) ?? false) !== (row.status === 'COMMITTED')) fail('JOURNAL_COMMIT_OPERATION_MISMATCH')
|
|
35
|
+
}
|
|
36
|
+
if (value.activeProof) {
|
|
37
|
+
if (!fields(value.activeProof, ['attemptId', 'beginResultSeq', 'beginTurn', 'workStartedAtBegin', 'workExecutedAtBegin']) || !integer(value.activeProof.beginResultSeq)
|
|
38
|
+
|| !integer(value.activeProof.beginTurn) || !integer(value.activeProof.workStartedAtBegin) || !integer(value.activeProof.workExecutedAtBegin)
|
|
39
|
+
|| value.document?.state.active?.attemptId !== value.activeProof.attemptId)
|
|
40
|
+
fail('JOURNAL_ACTIVE_PROOF_MISMATCH')
|
|
41
|
+
} else if (value.document?.state.active) fail('JOURNAL_ACTIVE_PROOF_REQUIRED')
|
|
42
|
+
const p = value.pending
|
|
43
|
+
if (p) {
|
|
44
|
+
if (!fields(p, ['operationId', 'payloadHash', 'operation', 'arguments', 'callId', 'rootCallId', 'turn', 'sessionId',
|
|
45
|
+
'owner', 'bindingDigest', 'expectedGraphRevision', 'candidate', 'resolvedEvidence', 'intentSeq', 'claimDigest', 'clearedWork'])
|
|
46
|
+
|| operations.has(p.operationId) || !hex(p.payloadHash) || !hex(p.bindingDigest) || !hex(p.claimDigest)
|
|
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)
|
|
49
|
+
|| p.expectedGraphRevision !== (value.document?.revision ?? 0) || (p.intentSeq !== null && !integer(p.intentSeq))
|
|
50
|
+
|| !Array.isArray(p.resolvedEvidence) || p.resolvedEvidence.length > 32 || !Array.isArray(p.clearedWork) || p.clearedWork.length > 1024
|
|
51
|
+
|| p.clearedWork.some(row => !fields(row, ['rootCallId', 'callSeq', 'rootResultSeq', 'eventDigest'])
|
|
52
|
+
|| typeof row.rootCallId !== 'string' || !row.rootCallId || row.rootCallId.length > 512 || !integer(row.callSeq)
|
|
53
|
+
|| row.rootResultSeq !== null && !integer(row.rootResultSeq) || !hex(row.eventDigest))) fail('JOURNAL_PENDING_INVALID')
|
|
54
|
+
validateDocument(p.candidate)
|
|
55
|
+
if (p.candidate.revision !== p.expectedGraphRevision + 1 || p.candidate.events.at(-1).operationId !== p.operationId
|
|
56
|
+
|| canonical(p.candidate.events.slice(0, -1)) !== canonical(value.document?.events ?? [])) fail('JOURNAL_CANDIDATE_NOT_SINGLE_APPEND')
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class HostJournal {
|
|
62
|
+
constructor({ projectRoot, directory, binding, store, readOnly = false }) {
|
|
63
|
+
this.binding = copy(binding)
|
|
64
|
+
const validate = value => validateJournal(value, this.binding)
|
|
65
|
+
this.store = store ?? new FileGraphStore({ projectRoot, directory, fileName: 'transaction-host-journal.json', maximumBytes: 12 * 1024 * 1024, readOnly,
|
|
66
|
+
validator: validate, updateValidator: (previous, next) => {
|
|
67
|
+
if (next.revision !== (previous?.revision ?? 0) + 1 || previous && canonical(previous.binding) !== canonical(next.binding)) fail('JOURNAL_CAS_REVISION_OR_SCOPE')
|
|
68
|
+
const oldClaims = previous?.claims ?? []
|
|
69
|
+
if (canonical(next.claims.slice(0, oldClaims.length)) !== canonical(oldClaims)) fail('JOURNAL_CLAIM_HISTORY_IMMUTABLE')
|
|
70
|
+
} })
|
|
71
|
+
}
|
|
72
|
+
read() {
|
|
73
|
+
const value = this.store.read() ?? initialJournal(this.binding)
|
|
74
|
+
validateJournal(value, this.binding)
|
|
75
|
+
return value
|
|
76
|
+
}
|
|
77
|
+
change(expected, update) {
|
|
78
|
+
return this.store.compareAndSwap(expected, previous => {
|
|
79
|
+
const before = previous ?? initialJournal(this.binding)
|
|
80
|
+
validateJournal(before, this.binding)
|
|
81
|
+
const after = seal({ ...update(copy(before)), schema: 4, binding: copy(this.binding), revision: before.revision + 1 })
|
|
82
|
+
validateJournal(after, this.binding)
|
|
83
|
+
return after
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
reserve(claim) {
|
|
87
|
+
const before = this.read()
|
|
88
|
+
return this.change(before.revision, state => {
|
|
89
|
+
if (state.pending) fail('PENDING_INTENT_REQUIRES_RECONCILIATION')
|
|
90
|
+
if (state.claims.some(row => row.operationId === claim.operationId)) fail('HOST_TOOL_CALL_REPLAY')
|
|
91
|
+
if (state.claims.length >= MAX_CLAIMS) fail('HOST_CLAIM_LIMIT')
|
|
92
|
+
return { ...state, pending: copy(claim) }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
bindIntent(operationId, intentSeq) {
|
|
96
|
+
const before = this.read()
|
|
97
|
+
return this.change(before.revision, state => {
|
|
98
|
+
if (state.pending?.operationId !== operationId || state.pending.intentSeq !== null && state.pending.intentSeq !== intentSeq) fail('HOST_INTENT_CHANGED')
|
|
99
|
+
return { ...state, pending: { ...state.pending, intentSeq } }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
resolve(projected, { rejectReason = null } = {}) {
|
|
103
|
+
const before = this.read(), pending = before.pending
|
|
104
|
+
if (!pending) return before
|
|
105
|
+
if (projected.data.operationId !== pending.operationId || projected.data.claimDigest !== pending.claimDigest
|
|
106
|
+
|| projected.data.bindingDigest !== pending.bindingDigest || projected.intentSeq !== pending.intentSeq
|
|
107
|
+
|| projected.data.operation !== pending.operation || projected.data.expectedGraphRevision !== pending.expectedGraphRevision
|
|
108
|
+
|| projected.data.candidateChecksum !== pending.candidate.checksum || projected.data.callId !== pending.callId
|
|
109
|
+
|| projected.data.rootCallId !== pending.rootCallId || projected.data.turn !== pending.turn || projected.data.sessionId !== pending.sessionId
|
|
110
|
+
|| canonical(projected.data.clearedWork) !== canonical(pending.clearedWork))
|
|
111
|
+
fail('HOST_PERSISTED_INTENT_PROOF_MISMATCH')
|
|
112
|
+
if (!['SUCCESS', 'FAILED'].includes(projected.status) || !integer(projected.rootResultSeq)) fail('HOST_RESULT_UNKNOWN_NO_COMMIT')
|
|
113
|
+
return this.change(before.revision, state => {
|
|
114
|
+
if (canonical(state.pending) !== canonical(pending)) fail('HOST_PENDING_CHANGED')
|
|
115
|
+
if (rejectReason !== null && (typeof rejectReason !== 'string' || !rejectReason || rejectReason.length > 1200)) fail('INVALID_CANDIDATE_REJECTION')
|
|
116
|
+
const committed = projected.status === 'SUCCESS' && rejectReason === null, document = committed ? pending.candidate : state.document
|
|
117
|
+
let activeProof = state.activeProof
|
|
118
|
+
if (committed && pending.operation === 'begin') activeProof = { attemptId: document.state.active.attemptId,
|
|
119
|
+
beginResultSeq: projected.rootResultSeq, beginTurn: pending.turn, workStartedAtBegin: projected.workStartedAtRoot,
|
|
120
|
+
workExecutedAtBegin: projected.workExecutedAtRoot }
|
|
121
|
+
if (committed && ['finish', 'reconcile'].includes(pending.operation)) activeProof = null
|
|
122
|
+
const row = { operationId: pending.operationId, payloadHash: pending.payloadHash, status: committed ? 'COMMITTED' : 'ABORTED',
|
|
123
|
+
graphRevision: document?.revision ?? 0, graphChecksum: document?.checksum ?? null, resultSeq: projected.rootResultSeq,
|
|
124
|
+
reason: committed ? null : rejectReason ?? 'Root/ancestor execution or typed-result qualification failed; candidate not applied. Original tool/result unchanged.',
|
|
125
|
+
claimDigest: pending.claimDigest, intentSeq: pending.intentSeq, candidateChecksum: pending.candidate.checksum,
|
|
126
|
+
expectedGraphRevision: pending.expectedGraphRevision, operation: pending.operation,
|
|
127
|
+
evidenceDigests: pending.resolvedEvidence.map(digest) }
|
|
128
|
+
return { ...state, document, activeProof, pending: null, claims: [...state.claims, row] }
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bettertaskgraphs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Transactional task graphs with a conversation panel for DeepSeek Harness",
|
|
5
|
+
"license": "GPL-3.0-only",
|
|
6
|
+
"keywords": ["dsh", "deepseek-harness", "cordis", "task-graph"],
|
|
7
|
+
"homepage": "https://github.com/oldcitynight/dsh-bettertaskgraphs#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/oldcitynight/dsh-bettertaskgraphs.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/oldcitynight/dsh-bettertaskgraphs/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./index.mjs",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.mjs",
|
|
19
|
+
"./client": "./client.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"client.js",
|
|
23
|
+
"client.source.mjs",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"core.mjs",
|
|
26
|
+
"host.mjs",
|
|
27
|
+
"index.mjs",
|
|
28
|
+
"journal.mjs",
|
|
29
|
+
"projection.mjs",
|
|
30
|
+
"remote.mjs",
|
|
31
|
+
"scripts/build-client.mjs",
|
|
32
|
+
"store.mjs",
|
|
33
|
+
"tools.mjs",
|
|
34
|
+
"view-model.mjs",
|
|
35
|
+
"work-policy.mjs"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
39
|
+
"dsh": "0.2.1-rc.5"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "node scripts/build-client.mjs",
|
|
43
|
+
"check": "node scripts/build-client.mjs --check",
|
|
44
|
+
"test": "npm run check && node --test",
|
|
45
|
+
"prepack": "npm run build",
|
|
46
|
+
"prepublishOnly": "npm test",
|
|
47
|
+
"pack:bundle": "node -e \"require('node:fs').mkdirSync('dist', { recursive: true })\" && npm pack --pack-destination dist"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org/"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"zod": "4.6.7"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.5",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.5",
|
|
59
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.5"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
63
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.5",
|
|
64
|
+
"@deepseek-ai/dsh-session-projection": "0.2.1-rc.5",
|
|
65
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.5",
|
|
66
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.5"
|
|
67
|
+
},
|
|
68
|
+
"dsh": {
|
|
69
|
+
"bundle": {
|
|
70
|
+
"patch": "./cordis.patch.yml"
|
|
71
|
+
},
|
|
72
|
+
"client": {
|
|
73
|
+
"platform": "web",
|
|
74
|
+
"inject": [
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
76
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/projection.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
|
4
|
+
import { canonical, copy, digest, GraphError } from './core.mjs'
|
|
5
|
+
import { administrative, workKey } from './work-policy.mjs'
|
|
6
|
+
export const KEY = 'projectTransactionGraphV4'
|
|
7
|
+
export const INTENT = 'plugin:project-transaction-graph/intent-v4'
|
|
8
|
+
export const RECEIPT = 'plugin:project-transaction-graph/receipt-v4'
|
|
9
|
+
export const RECOVERY = 'plugin:project-transaction-graph/operator-recovery-v4'
|
|
10
|
+
export const MAX_PROOFS = 1024
|
|
11
|
+
const bounded = n => z.string().min(1).max(n)
|
|
12
|
+
const hex = z.string().regex(/^[a-f0-9]{64}$/)
|
|
13
|
+
const seq = z.number().int().nonnegative()
|
|
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()
|
|
16
|
+
const factSchema = z.object({ seq, callId: bounded(512), tool: bounded(300), isError: z.boolean(),
|
|
17
|
+
outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']) }).strict()
|
|
18
|
+
const intentSchema = z.object({ version: z.literal(4), operationId: bounded(80), callId: bounded(512),
|
|
19
|
+
rootCallId: bounded(512), turn: seq, sessionId: bounded(512), bindingDigest: hex, claimDigest: hex,
|
|
20
|
+
operation: z.enum(['create', 'begin', 'finish', 'skip', 'reconcile']), expectedGraphRevision: seq, candidateChecksum: hex,
|
|
21
|
+
clearedWork: z.array(workSchema).max(1024) }).strict()
|
|
22
|
+
const dispatchSchema = z.object({ seq, subCallId: bounded(512), parentCallId: bounded(512), rootCallId: bounded(512),
|
|
23
|
+
tool: bounded(300), isError: z.boolean(), outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']), eventDigest: hex, turn: seq }).strict()
|
|
24
|
+
const proofSchema = z.object({ seq, callId: bounded(512), rootCallId: bounded(512), rootCallSeq: seq, rootResultSeq: seq,
|
|
25
|
+
tool: bounded(300), successful: z.boolean(), qualification: z.enum(['SUCCESS', 'FAILED', 'UNKNOWN']), isError: z.boolean(),
|
|
26
|
+
outcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']), ownOutcome: z.enum(['UNVERIFIED', 'FAIL', 'NOT_RUN', 'UNKNOWN']),
|
|
27
|
+
ancestorOutcomes: z.array(factSchema).max(33), eventDigest: hex, turn: seq }).strict()
|
|
28
|
+
const pendingSchema = z.object({ intentSeq: seq, data: intentSchema,
|
|
29
|
+
intentDigest: hex, status: z.enum(['PENDING', 'SUCCESS', 'FAILED', 'UNKNOWN']),
|
|
30
|
+
rootResultSeq: seq.nullable(), rootResultDigest: hex.nullable(), workStartedAtRoot: seq.nullable(), workExecutedAtRoot: seq.nullable() }).strict()
|
|
31
|
+
export const stateSchema = z.object({ sessionId: bounded(512), ownFrom: seq, turn: seq, lastSeq: z.number().int().min(-1),
|
|
32
|
+
workStarted: seq, workExecuted: seq, uncertainties: z.array(workSchema).max(1024), acknowledgedWork: z.array(workSchema).max(1024),
|
|
33
|
+
calls: z.record(z.string(), z.object({ tool: bounded(300), turn: seq, seq, eventDigest: hex }).strict()),
|
|
34
|
+
bashFailures: z.array(bashFailureSchema).max(256),
|
|
35
|
+
dispatches: z.array(dispatchSchema).max(256), intents: z.array(pendingSchema).max(1024),
|
|
36
|
+
proofs: z.array(proofSchema).max(MAX_PROOFS), receipts: z.array(z.object({ operationId: bounded(80), seq,
|
|
37
|
+
status: z.enum(['COMMITTED', 'ABORTED']), graphRevision: seq, graphChecksum: hex.nullable(), eventDigest: hex }).strict()).max(1024), failure: z.string().nullable() }).strict()
|
|
38
|
+
export function initialState(header, inheritedEventCount = 0) {
|
|
39
|
+
return { sessionId: header.id, ownFrom: inheritedEventCount, turn: 0, lastSeq: -1, workStarted: 0, workExecuted: 0,
|
|
40
|
+
uncertainties: [], acknowledgedWork: [],
|
|
41
|
+
calls: {}, bashFailures: [], dispatches: [], intents: [], proofs: [], receipts: [], failure: null }
|
|
42
|
+
}
|
|
43
|
+
export function outcomeOfContent(content = []) {
|
|
44
|
+
const outcomes = new Set()
|
|
45
|
+
for (const block of content) {
|
|
46
|
+
if (block.type !== 'text' || typeof block.text !== 'string') continue
|
|
47
|
+
if (block.text.length > 32768) return 'UNKNOWN' // No bounded parser means no positive completion qualification.
|
|
48
|
+
let value
|
|
49
|
+
try { value = JSON.parse(block.text) } catch { continue }
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue
|
|
51
|
+
if (value.passed === false || value.ok === false || value.success === false) outcomes.add('FAIL')
|
|
52
|
+
for (const status of [value.goalVerification?.status, value.verification?.status, value.status, value.state]) if (typeof status === 'string') {
|
|
53
|
+
if (['FAIL', 'FAILED', 'ERROR'].includes(status.toUpperCase())) outcomes.add('FAIL')
|
|
54
|
+
if (['NOT_RUN', 'UNKNOWN'].includes(status.toUpperCase())) outcomes.add(status.toUpperCase())
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// A first FAIL/NOT_RUN block may not hide a later UNKNOWN/contradiction.
|
|
58
|
+
return outcomes.has('UNKNOWN') || outcomes.size > 1 ? 'UNKNOWN' : [...outcomes][0] ?? 'UNVERIFIED'
|
|
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
|
+
}
|
|
65
|
+
export function chainQualification(start, root, dispatches) {
|
|
66
|
+
let child = start
|
|
67
|
+
const visited = new Set()
|
|
68
|
+
let failed = false, unknown = false
|
|
69
|
+
while (child !== root) {
|
|
70
|
+
if (visited.has(child) || visited.size >= 32) return 'UNKNOWN'
|
|
71
|
+
visited.add(child)
|
|
72
|
+
const matches = dispatches.filter(row => row.subCallId === child && row.rootCallId === root)
|
|
73
|
+
if (matches.length !== 1) return 'UNKNOWN'
|
|
74
|
+
const row = matches[0]
|
|
75
|
+
if (row.isError || ['FAIL', 'NOT_RUN'].includes(row.outcome)) failed = true
|
|
76
|
+
if (row.outcome === 'UNKNOWN' || !['UNVERIFIED', 'FAIL', 'NOT_RUN'].includes(row.outcome)) unknown = true
|
|
77
|
+
child = row.parentCallId
|
|
78
|
+
}
|
|
79
|
+
return unknown ? 'UNKNOWN' : failed ? 'FAILED' : 'SUCCESS'
|
|
80
|
+
}
|
|
81
|
+
export const chainSucceeded = (start, root, dispatches) => chainQualification(start, root, dispatches) === 'SUCCESS'
|
|
82
|
+
function ancestryFacts(start, root, dispatches) {
|
|
83
|
+
const facts = [], visited = new Set()
|
|
84
|
+
let cursor = start
|
|
85
|
+
while (cursor !== root && !visited.has(cursor) && facts.length < 32) {
|
|
86
|
+
visited.add(cursor)
|
|
87
|
+
const matches = dispatches.filter(row => row.subCallId === cursor && row.rootCallId === root)
|
|
88
|
+
if (matches.length !== 1) break
|
|
89
|
+
const row = matches[0]
|
|
90
|
+
if (cursor !== start) facts.push({ seq: row.seq, callId: row.subCallId, tool: row.tool, isError: row.isError, outcome: row.outcome })
|
|
91
|
+
cursor = row.parentCallId
|
|
92
|
+
}
|
|
93
|
+
return facts
|
|
94
|
+
}
|
|
95
|
+
const qualifiedStatus = (root, chain) => root === 'UNKNOWN' || chain === 'UNKNOWN' ? 'UNKNOWN'
|
|
96
|
+
: root === 'FAILED' || chain === 'FAILED' ? 'FAILED' : 'SUCCESS'
|
|
97
|
+
const internal = administrative
|
|
98
|
+
export function applyEvent(state, event) {
|
|
99
|
+
if (state.failure) return state
|
|
100
|
+
try {
|
|
101
|
+
if (event.seq < state.ownFrom) return state // Fork parents cannot authorize a child's graph.
|
|
102
|
+
if (event.type.startsWith('plugin:project-transaction-graph/') && ![INTENT, RECEIPT, RECOVERY].includes(event.type))
|
|
103
|
+
throw new GraphError('LEGACY_GRAPH_REQUIRES_EXPLICIT_MIGRATION')
|
|
104
|
+
if (!['turn/start', 'turn/end', 'tool/call', 'tool/ptc-dispatch', 'tool/result', 'job/settled', INTENT, RECEIPT, RECOVERY].includes(event.type)) return state
|
|
105
|
+
const d = event.data
|
|
106
|
+
let next = { ...state, lastSeq: event.seq }
|
|
107
|
+
if (event.type === 'turn/start') return { ...next, turn: d.turn }
|
|
108
|
+
if (event.type === 'tool/call') {
|
|
109
|
+
if (Object.hasOwn(state.calls, d.callId) || Object.keys(state.calls).length >= 256) throw new GraphError('CALL_ID_REPLAY_OR_LIMIT')
|
|
110
|
+
return { ...next, workStarted: state.workStarted + (!internal(d.name) && d.name !== 'run_code' ? 1 : 0),
|
|
111
|
+
calls: { ...state.calls, [d.callId]: { tool: d.name, turn: state.turn, seq: event.seq, eventDigest: digest(event) } } }
|
|
112
|
+
}
|
|
113
|
+
if (event.type === INTENT || event.type === RECOVERY) {
|
|
114
|
+
const data = intentSchema.parse(d)
|
|
115
|
+
if (data.sessionId !== state.sessionId || data.turn !== state.turn || (event.type === INTENT && !state.turn)
|
|
116
|
+
|| state.intents.some(row => row.data.operationId === data.operationId) || state.intents.length >= 1024) throw new GraphError('INTENT_SCOPE_REPLAY_OR_LIMIT')
|
|
117
|
+
if (event.type === INTENT && data.clearedWork.length) throw new GraphError('MODEL_INTENT_CANNOT_ACKNOWLEDGE_WORK')
|
|
118
|
+
let uncertainties = state.uncertainties, acknowledgedWork = state.acknowledgedWork
|
|
119
|
+
if (event.type === RECOVERY) {
|
|
120
|
+
for (const work of data.clearedWork) {
|
|
121
|
+
const unknown = uncertainties.find(row => canonical(row) === canonical(work))
|
|
122
|
+
const open = state.calls[work.rootCallId]
|
|
123
|
+
if (!unknown && !(work.rootResultSeq === null && open?.seq === work.callSeq && open.eventDigest === work.eventDigest))
|
|
124
|
+
throw new GraphError('OPERATOR_WORK_ACK_NOT_OWN_RECORDED_FACT')
|
|
125
|
+
}
|
|
126
|
+
const keys = new Set(data.clearedWork.map(workKey))
|
|
127
|
+
uncertainties = uncertainties.filter(row => !keys.has(workKey(row)))
|
|
128
|
+
acknowledgedWork = [...acknowledgedWork, ...data.clearedWork.filter(row => !acknowledgedWork.some(old => workKey(old) === workKey(row)))]
|
|
129
|
+
if (acknowledgedWork.length > 1024) throw new GraphError('ACKNOWLEDGED_WORK_LIMIT')
|
|
130
|
+
}
|
|
131
|
+
return { ...next, uncertainties, acknowledgedWork, intents: [...state.intents, { intentSeq: event.seq, data,
|
|
132
|
+
intentDigest: digest(event), status: event.type === RECOVERY ? 'SUCCESS' : 'PENDING',
|
|
133
|
+
rootResultSeq: event.type === RECOVERY ? event.seq : null, rootResultDigest: event.type === RECOVERY ? digest(event) : null,
|
|
134
|
+
workStartedAtRoot: event.type === RECOVERY ? state.workStarted : null, workExecutedAtRoot: event.type === RECOVERY ? state.workExecuted : null }] }
|
|
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
|
+
}
|
|
148
|
+
if (event.type === 'tool/ptc-dispatch') {
|
|
149
|
+
const rootCallId = d.rootCallId ?? d.parentCallId
|
|
150
|
+
if (!Object.hasOwn(state.calls, rootCallId)) {
|
|
151
|
+
// Only a child of an open, owned root can become evidence. Once a
|
|
152
|
+
// graph has history, an unexplained dispatch could hide actual work.
|
|
153
|
+
if (state.intents.length || state.receipts.length) throw new GraphError('UNATTRIBUTED_PTC_DISPATCH_IN_GRAPH_SESSION')
|
|
154
|
+
return next
|
|
155
|
+
}
|
|
156
|
+
const dispatch = dispatchSchema.parse({ seq: event.seq, subCallId: d.subCallId, parentCallId: d.parentCallId,
|
|
157
|
+
rootCallId, tool: d.name, isError: d.isError,
|
|
158
|
+
outcome: toolOutcome(state, d.name, rootCallId, d.subCallId, d.content), eventDigest: digest(event), turn: state.turn })
|
|
159
|
+
if (state.dispatches.some(row => row.subCallId === dispatch.subCallId && row.rootCallId === dispatch.rootCallId)
|
|
160
|
+
|| state.dispatches.length >= 256) throw new GraphError('PTC_DISPATCH_REPLAY_OR_LIMIT')
|
|
161
|
+
return { ...next, workStarted: state.workStarted + (!internal(d.name) && d.name !== 'run_code' ? 1 : 0),
|
|
162
|
+
dispatches: [...state.dispatches, dispatch] }
|
|
163
|
+
}
|
|
164
|
+
if (event.type === 'tool/result') {
|
|
165
|
+
const root = d.message.source.callId, success = d.message.isError !== true // Optional false in the published ToolResultMessage API.
|
|
166
|
+
const syntheticUnknown = [TOOL_OUTCOME_UNKNOWN, TOOL_NOT_STARTED].includes(d.error?.code)
|
|
167
|
+
const sources = state.dispatches.filter(row => row.rootCallId === root)
|
|
168
|
+
const call = state.calls[root]
|
|
169
|
+
if (!call) {
|
|
170
|
+
if (state.intents.some(row => row.data.rootCallId === root && ['PENDING', 'UNKNOWN'].includes(row.status)))
|
|
171
|
+
throw new GraphError('OWN_INTENT_RESULT_WITHOUT_OWN_TOOL_CALL')
|
|
172
|
+
// Fork/restore seeds may synthesize child-owned closers for an inherited
|
|
173
|
+
// call. They are not child work, evidence, or authority for a candidate.
|
|
174
|
+
return next
|
|
175
|
+
}
|
|
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'
|
|
180
|
+
const leafCode = sources.filter(row => row.tool === 'run_code' && !sources.some(child => child.parentCallId === row.subCallId))
|
|
181
|
+
const workStarted = state.workStarted + (standaloneCode ? 1 : 0) + leafCode.length
|
|
182
|
+
const executed = sources.filter(row => !internal(row.tool) && (row.tool !== 'run_code' || leafCode.includes(row)) && !['NOT_RUN', 'UNKNOWN'].includes(row.outcome)).length
|
|
183
|
+
+ (!internal(call.tool) && (call.tool !== 'run_code' || standaloneCode) && !['NOT_RUN', 'UNKNOWN'].includes(rootOutcome) && !syntheticUnknown ? 1 : 0)
|
|
184
|
+
const workExecuted = state.workExecuted + executed
|
|
185
|
+
const intents = state.intents.map(row => row.data.rootCallId !== root ? row : {
|
|
186
|
+
...row, status: qualifiedStatus(rootQualification, chainQualification(row.data.callId, root, sources)),
|
|
187
|
+
rootResultSeq: event.seq, rootResultDigest: digest(event), workStartedAtRoot: workStarted, workExecutedAtRoot: workExecuted })
|
|
188
|
+
const rootFact = { seq: event.seq, callId: root, tool: call.tool, isError: !success, outcome: syntheticUnknown ? 'UNKNOWN' : rootOutcome }
|
|
189
|
+
const proofs = sources.filter(row => !internal(row.tool) && (row.tool !== 'run_code' || leafCode.includes(row))).map(row => {
|
|
190
|
+
const qualification = qualifiedStatus(rootQualification, chainQualification(row.subCallId, root, sources))
|
|
191
|
+
return { seq: row.seq, callId: row.subCallId, rootCallId: root, rootResultSeq: event.seq, rootCallSeq: call.seq,
|
|
192
|
+
tool: row.tool, successful: qualification === 'SUCCESS', qualification, isError: row.isError,
|
|
193
|
+
outcome: row.outcome, ownOutcome: row.outcome, ancestorOutcomes: [...ancestryFacts(row.subCallId, root, sources), rootFact],
|
|
194
|
+
eventDigest: row.eventDigest, turn: row.turn }
|
|
195
|
+
})
|
|
196
|
+
if (!internal(call.tool) && (call.tool !== 'run_code' || !sources.length)) proofs.push({ seq: event.seq,
|
|
197
|
+
callId: root, rootCallId: root, rootResultSeq: event.seq, tool: call.tool,
|
|
198
|
+
rootCallSeq: call.seq,
|
|
199
|
+
successful: rootQualification === 'SUCCESS', qualification: rootQualification, isError: !success,
|
|
200
|
+
outcome: syntheticUnknown ? 'UNKNOWN' : rootOutcome, ownOutcome: syntheticUnknown ? 'UNKNOWN' : rootOutcome, ancestorOutcomes: [],
|
|
201
|
+
eventDigest: digest(event), turn: call.turn })
|
|
202
|
+
const calls = { ...state.calls }; delete calls[root]
|
|
203
|
+
let uncertainties = state.uncertainties
|
|
204
|
+
if (rootQualification === 'UNKNOWN' || sources.some(row => row.outcome === 'UNKNOWN') || proofs.some(row => row.qualification === 'UNKNOWN')) {
|
|
205
|
+
const work = { rootCallId: root, callSeq: call.seq, rootResultSeq: event.seq, eventDigest: digest(event) }
|
|
206
|
+
if (!state.acknowledgedWork.some(row => workKey(row) === workKey(work)) && !uncertainties.some(row => workKey(row) === workKey(work)))
|
|
207
|
+
uncertainties = [...uncertainties, work]
|
|
208
|
+
if (uncertainties.length > 1024) throw new GraphError('UNRESOLVED_WORK_LIMIT')
|
|
209
|
+
}
|
|
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),
|
|
212
|
+
proofs: [...state.proofs, ...proofs].slice(-MAX_PROOFS) }
|
|
213
|
+
}
|
|
214
|
+
if (event.type === RECEIPT) {
|
|
215
|
+
const row = z.object({ operationId: bounded(80), status: z.enum(['COMMITTED', 'ABORTED']),
|
|
216
|
+
graphRevision: seq, graphChecksum: hex.nullable() }).strict().parse(d)
|
|
217
|
+
const previous = state.receipts.find(old => old.operationId === row.operationId)
|
|
218
|
+
if (previous && ['status', 'graphRevision', 'graphChecksum'].some(key => previous[key] !== row[key])) throw new GraphError('RECEIPT_CONFLICT')
|
|
219
|
+
return previous ? next : { ...next, receipts: [...state.receipts, { ...row, seq: event.seq, eventDigest: digest(event) }].slice(-1024) }
|
|
220
|
+
}
|
|
221
|
+
if (event.type === 'turn/end') return { ...next, turn: 0, intents: state.intents.map(row => row.status === 'PENDING' ? { ...row, status: 'UNKNOWN' } : row) }
|
|
222
|
+
return next
|
|
223
|
+
} catch (error) { return { ...state, failure: `Transaction projection failed at ${event.seq}: ${error.message}` } }
|
|
224
|
+
}
|
|
225
|
+
export const projection = { key: KEY, stateVersion: 6, stateSchema, init: initialState, apply: applyEvent }
|
package/remote.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Shared by the Host registration and the lazy browser module.
|
|
3
|
+
export function createRemoteContribution(z) {
|
|
4
|
+
const packageName = 'dsh-bettertaskgraphs'
|
|
5
|
+
const id = z.string().min(1).max(128)
|
|
6
|
+
const input = z.object({ sessionId: id }).strict()
|
|
7
|
+
const node = z.object({
|
|
8
|
+
id, label: z.string().min(1).max(512), kind: z.enum(['container', 'leaf']),
|
|
9
|
+
parent: id.nullable(),
|
|
10
|
+
status: z.enum(['COMPLETE', 'CONTAINER_INCOMPLETE', 'ACTIVE', 'INACTIVE', 'INCOMPLETE', 'PENDING', 'SKIPPED']),
|
|
11
|
+
blocked: z.literal(true).optional(),
|
|
12
|
+
disposition: z.enum(['COMPLETED', 'SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED']).optional(),
|
|
13
|
+
branchState: z.enum(['SELECTED', 'WAITING', 'NOT_SELECTED']).optional(),
|
|
14
|
+
selectedBranch: z.object({
|
|
15
|
+
outcome: z.enum(['complete', 'incomplete']), case: z.string().min(1).max(64).optional(), targetId: id.optional(),
|
|
16
|
+
}).strict().optional(),
|
|
17
|
+
}).strict()
|
|
18
|
+
const edge = z.discriminatedUnion('kind', [
|
|
19
|
+
z.object({ from: id, to: id, kind: z.literal('prerequisite') }).strict(),
|
|
20
|
+
z.object({ from: id, to: id, kind: z.literal('route'), outcome: z.enum(['complete', 'incomplete']) }).strict(),
|
|
21
|
+
z.object({ from: id, to: id, kind: z.literal('case'), case: z.string().min(1).max(64) }).strict(),
|
|
22
|
+
])
|
|
23
|
+
const choice = z.object({ id, choice: z.number().int().min(0).max(127) }).strict()
|
|
24
|
+
const graph = z.object({
|
|
25
|
+
revision: z.number().int().min(1).max(1024), title: z.string().min(1).max(512),
|
|
26
|
+
nodes: z.array(node).min(2).max(128), edges: z.array(edge).max(512),
|
|
27
|
+
active: z.object({ nodeId: id, recoveryRequired: z.boolean() }).strict().nullable(),
|
|
28
|
+
pending: z.object({ operation: z.string().min(1).max(64), qualification: z.string().min(1).max(128) }).strict().nullable(),
|
|
29
|
+
legalNext: z.array(choice).max(128), skipChoices: z.array(choice).max(128),
|
|
30
|
+
}).strict()
|
|
31
|
+
const output = z.discriminatedUnion('status', [
|
|
32
|
+
z.object({ sessionId: id, status: z.literal('ready'), graph }).strict(),
|
|
33
|
+
z.object({ sessionId: id, status: z.literal('empty'), graph: z.null() }).strict(),
|
|
34
|
+
z.object({ sessionId: id, status: z.literal('unavailable'), graph: z.null() }).strict(),
|
|
35
|
+
])
|
|
36
|
+
const codec = (key, schema) => ({ mode: 'strict', typeSymbol: `${packageName}#${key}`, create: () => schema })
|
|
37
|
+
return {
|
|
38
|
+
package: packageName,
|
|
39
|
+
descriptors: [{
|
|
40
|
+
id: `${packageName}#transactionGraphView/status`,
|
|
41
|
+
service: 'transactionGraph', namespace: 'transactionGraphView', method: 'status', invocation: { kind: 'direct' },
|
|
42
|
+
parameters: [{
|
|
43
|
+
name: 'request', wire: 'request', source: 'json',
|
|
44
|
+
codec: codec('TransactionGraphStatusRequest', input),
|
|
45
|
+
strictCodec: codec('TransactionGraphStatusStrictRequest', input),
|
|
46
|
+
}],
|
|
47
|
+
cancellation: { parameter: 'signal' },
|
|
48
|
+
result: codec('TransactionGraphStatusResult', output),
|
|
49
|
+
}],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
const root = new URL('../', import.meta.url)
|
|
5
|
+
const { name: packageName, version, license, repository } = JSON.parse(readFileSync(new URL('package.json', root), 'utf8'))
|
|
6
|
+
const sourceUrl = repository.url.replace(/^git\+/, '').replace(/\.git$/, '')
|
|
7
|
+
const sources = ['remote.mjs', 'view-model.mjs', 'client.source.mjs'].map(name => {
|
|
8
|
+
const source = readFileSync(new URL(name, root), 'utf8')
|
|
9
|
+
if (/^import\s/m.test(source)) throw new Error(`${name} must be self-contained for the DSH lazy client factory`)
|
|
10
|
+
return source.replace(/^export\s+(?=(?:function|const|class)\b)/gm, '')
|
|
11
|
+
})
|
|
12
|
+
const output = `// SPDX-License-Identifier: ${license}
|
|
13
|
+
// Corresponding source: ${sourceUrl}/tree/v${version}
|
|
14
|
+
// Generated by scripts/build-client.mjs.
|
|
15
|
+
window.__ModuleLoader__.load({id:${JSON.stringify(packageName)},factory(require){
|
|
16
|
+
${sources.join('\n')}
|
|
17
|
+
return createClientPlugin(require('react'),require('zod').z,createRemoteContribution,graphPresentation,branchLayout);
|
|
18
|
+
}});
|
|
19
|
+
`
|
|
20
|
+
if (process.argv.slice(2).join(' ') === '--check') {
|
|
21
|
+
if (readFileSync(new URL('client.js', root), 'utf8') !== output) throw new Error('Transaction graph client bundle is stale')
|
|
22
|
+
} else if (process.argv.length === 2) {
|
|
23
|
+
writeFileSync(new URL('client.js', root), output)
|
|
24
|
+
} else {
|
|
25
|
+
throw new Error('Use no arguments to build, or --check to verify')
|
|
26
|
+
}
|