dsh-bettertaskgraphs 0.0.0-stage → 0.2.2
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 +539 -0
- package/client.source.mjs +322 -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 +205 -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/core.mjs
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
export const LIMITS = Object.freeze({ nodes: 128, edges: 512, depth: 16, cases: 8, events: 1024, bytes: 4 * 1024 * 1024, evidence: 32 })
|
|
5
|
+
export class GraphError extends Error {
|
|
6
|
+
constructor(code, message = code) { super(message); this.name = 'GraphError'; this.code = code }
|
|
7
|
+
}
|
|
8
|
+
const fail = (code, message) => { throw new GraphError(code, message) }
|
|
9
|
+
const own = (object, key) => Object.hasOwn(object, key)
|
|
10
|
+
export const copy = value => structuredClone(value)
|
|
11
|
+
export function canonical(value) {
|
|
12
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
13
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
14
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
|
|
15
|
+
}
|
|
16
|
+
export const digest = value => createHash('sha256').update(canonical(value)).digest('hex')
|
|
17
|
+
const identifier = value => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,79}$/.test(value)
|
|
18
|
+
function text(value, maximum, name) {
|
|
19
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maximum) fail('INVALID_TEXT', name)
|
|
20
|
+
return value.trim()
|
|
21
|
+
}
|
|
22
|
+
function object(value, allowed, name) {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
24
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail('INVALID_OBJECT', name)
|
|
25
|
+
if (Object.keys(value).some(key => !allowed.includes(key))) fail('UNKNOWN_FIELD', name)
|
|
26
|
+
}
|
|
27
|
+
function integer(value, minimum, name) {
|
|
28
|
+
if (!Number.isSafeInteger(value) || value < minimum) fail('INVALID_INTEGER', name)
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
function noCycles(ids, pairs, code) {
|
|
32
|
+
const outgoing = new Map(ids.map(id => [id, []])), degree = new Map(ids.map(id => [id, 0]))
|
|
33
|
+
for (const [a, b] of pairs) { outgoing.get(a).push(b); degree.set(b, degree.get(b) + 1) }
|
|
34
|
+
const ready = ids.filter(id => degree.get(id) === 0)
|
|
35
|
+
let count = 0
|
|
36
|
+
for (let i = 0; i < ready.length; i++) {
|
|
37
|
+
const id = ready[i]; count++
|
|
38
|
+
for (const target of outgoing.get(id)) { degree.set(target, degree.get(target) - 1); if (degree.get(target) === 0) ready.push(target) }
|
|
39
|
+
}
|
|
40
|
+
if (count !== ids.length) fail(code)
|
|
41
|
+
}
|
|
42
|
+
export function evidenceRefs(value = []) {
|
|
43
|
+
if (!Array.isArray(value) || value.length > LIMITS.evidence) fail('EVIDENCE_LIMIT')
|
|
44
|
+
const used = new Set()
|
|
45
|
+
return value.map(ref => {
|
|
46
|
+
object(ref, ['kind', 'ref', 'sha256'], 'evidence reference')
|
|
47
|
+
if (!['session_event', 'tool_result', 'artifact'].includes(ref.kind)) fail('INVALID_EVIDENCE_KIND')
|
|
48
|
+
const result = { kind: ref.kind, ref: text(ref.ref, 512, 'ref') }
|
|
49
|
+
if (ref.sha256 !== undefined) {
|
|
50
|
+
if (typeof ref.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ref.sha256)) fail('INVALID_EVIDENCE_SHA')
|
|
51
|
+
result.sha256 = ref.sha256
|
|
52
|
+
}
|
|
53
|
+
const key = canonical(result)
|
|
54
|
+
if (used.has(key)) fail('DUPLICATE_EVIDENCE')
|
|
55
|
+
used.add(key)
|
|
56
|
+
return result
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Durable IDs are generated by this program. Input keys are local plan labels.
|
|
61
|
+
export function normalizePlan(definition, { enableSwitch = false, id = prefix => `${prefix}_${randomUUID()}` } = {}) {
|
|
62
|
+
object(definition, ['title', 'nodes', 'edges'], 'plan')
|
|
63
|
+
if (!Array.isArray(definition.nodes) || definition.nodes.length < 2 || definition.nodes.length > LIMITS.nodes) fail('NODE_LIMIT')
|
|
64
|
+
if (!Array.isArray(definition.edges) || definition.edges.length > LIMITS.edges) fail('EDGE_LIMIT')
|
|
65
|
+
const keys = new Map(), usedIds = new Set()
|
|
66
|
+
const generated = prefix => {
|
|
67
|
+
const value = id(prefix)
|
|
68
|
+
if (!identifier(value) || usedIds.has(value)) fail('INVALID_GENERATED_ID')
|
|
69
|
+
usedIds.add(value); return value
|
|
70
|
+
}
|
|
71
|
+
const nodes = definition.nodes.map(raw => {
|
|
72
|
+
object(raw, ['key', 'kind', 'label', 'parent', 'switchCases'], 'node')
|
|
73
|
+
if (!identifier(raw.key) || keys.has(raw.key)) fail('DUPLICATE_OR_INVALID_KEY')
|
|
74
|
+
if (!['container', 'leaf'].includes(raw.kind)) fail('INVALID_NODE_KIND')
|
|
75
|
+
const cases = raw.switchCases ?? []
|
|
76
|
+
if (!Array.isArray(cases) || cases.length > LIMITS.cases || (cases.length && (!enableSwitch || raw.kind !== 'leaf'))) fail('SWITCH_DISABLED_OR_LIMIT')
|
|
77
|
+
const switchCases = cases.map(value => text(value, 64, 'case'))
|
|
78
|
+
if (new Set(switchCases).size !== switchCases.length) fail('DUPLICATE_CASE')
|
|
79
|
+
const node = { id: generated('node'), key: raw.key, kind: raw.kind, label: text(raw.label, 512, 'label'),
|
|
80
|
+
parent: raw.parent ?? null, switchCases }
|
|
81
|
+
keys.set(raw.key, node); return node
|
|
82
|
+
})
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
if (node.parent !== null) {
|
|
85
|
+
if (!keys.has(node.parent)) fail('UNKNOWN_PARENT')
|
|
86
|
+
node.parent = keys.get(node.parent).id
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const edges = definition.edges.map(raw => {
|
|
90
|
+
object(raw, ['kind', 'from', 'to', 'outcome', 'case'], 'edge')
|
|
91
|
+
if (!keys.has(raw.from) || !keys.has(raw.to)) fail('UNKNOWN_EDGE_ENDPOINT')
|
|
92
|
+
const edge = { id: generated('edge'), kind: raw.kind, from: keys.get(raw.from).id, to: keys.get(raw.to).id }
|
|
93
|
+
if (raw.outcome !== undefined) edge.outcome = raw.outcome
|
|
94
|
+
if (raw.case !== undefined) edge.case = raw.case
|
|
95
|
+
return edge
|
|
96
|
+
})
|
|
97
|
+
const plan = { schema: 1, id: generated('graph'), title: text(definition.title, 512, 'title'), enableSwitch, nodes, edges }
|
|
98
|
+
validatePlan(plan)
|
|
99
|
+
return plan
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validatePlan(plan) {
|
|
103
|
+
object(plan, ['schema', 'id', 'title', 'enableSwitch', 'nodes', 'edges'], 'normalized plan')
|
|
104
|
+
if (plan.schema !== 1 || !identifier(plan.id) || typeof plan.enableSwitch !== 'boolean') fail('INVALID_PLAN')
|
|
105
|
+
text(plan.title, 512, 'title')
|
|
106
|
+
if (!Array.isArray(plan.nodes) || plan.nodes.length < 2 || plan.nodes.length > LIMITS.nodes
|
|
107
|
+
|| !Array.isArray(plan.edges) || plan.edges.length > LIMITS.edges) fail('GRAPH_LIMIT')
|
|
108
|
+
const nodes = new Map(), ids = new Set([plan.id]), keys = new Set()
|
|
109
|
+
for (const node of plan.nodes) {
|
|
110
|
+
object(node, ['id', 'key', 'kind', 'label', 'parent', 'switchCases'], 'normalized node')
|
|
111
|
+
if (!identifier(node.id) || ids.has(node.id) || !identifier(node.key) || keys.has(node.key)
|
|
112
|
+
|| !['container', 'leaf'].includes(node.kind)) fail('INVALID_NODE')
|
|
113
|
+
text(node.label, 512, 'label'); ids.add(node.id); keys.add(node.key); nodes.set(node.id, node)
|
|
114
|
+
if (!Array.isArray(node.switchCases) || node.switchCases.length > LIMITS.cases
|
|
115
|
+
|| (node.switchCases.length && (!plan.enableSwitch || node.kind !== 'leaf'))
|
|
116
|
+
|| new Set(node.switchCases).size !== node.switchCases.length) fail('INVALID_SWITCH')
|
|
117
|
+
for (const label of node.switchCases) text(label, 64, 'case')
|
|
118
|
+
}
|
|
119
|
+
const roots = plan.nodes.filter(node => node.parent === null)
|
|
120
|
+
if (roots.length !== 1 || roots[0].kind !== 'container') fail('ONE_ROOT_CONTAINER_REQUIRED')
|
|
121
|
+
const containment = [], semantic = []
|
|
122
|
+
for (const node of plan.nodes) {
|
|
123
|
+
if (node.parent === null) continue
|
|
124
|
+
if (!nodes.has(node.parent) || nodes.get(node.parent).kind !== 'container' || node.parent === node.id) fail('INVALID_CONTAINMENT')
|
|
125
|
+
containment.push([node.parent, node.id]); semantic.push([node.id, node.parent])
|
|
126
|
+
}
|
|
127
|
+
noCycles([...nodes.keys()], containment, 'CONTAINMENT_CYCLE')
|
|
128
|
+
for (const node of plan.nodes) {
|
|
129
|
+
let cursor = node, depth = 1
|
|
130
|
+
while (cursor.parent !== null) { cursor = nodes.get(cursor.parent); if (++depth > LIMITS.depth) fail('DEPTH_LIMIT') }
|
|
131
|
+
if (node.kind === 'container' && !plan.nodes.some(child => child.parent === node.id)) fail('EMPTY_CONTAINER')
|
|
132
|
+
}
|
|
133
|
+
const duplicate = new Set(), routes = new Set()
|
|
134
|
+
for (const edge of plan.edges) {
|
|
135
|
+
object(edge, ['id', 'kind', 'from', 'to', 'outcome', 'case'], 'normalized edge')
|
|
136
|
+
if (!identifier(edge.id) || ids.has(edge.id) || !nodes.has(edge.from) || !nodes.has(edge.to) || edge.from === edge.to) fail('INVALID_EDGE')
|
|
137
|
+
ids.add(edge.id)
|
|
138
|
+
const from = nodes.get(edge.from), to = nodes.get(edge.to)
|
|
139
|
+
if (!['prerequisite', 'route', 'case'].includes(edge.kind) || to.kind !== 'leaf') fail('ILLEGAL_EDGE_KIND_OR_TARGET')
|
|
140
|
+
if (edge.kind === 'prerequisite') {
|
|
141
|
+
if (edge.outcome !== undefined || edge.case !== undefined) fail('PREREQUISITE_HAS_CONDITION')
|
|
142
|
+
} else {
|
|
143
|
+
if (from.kind !== 'leaf') fail('ROUTE_SOURCE_MUST_BE_LEAF')
|
|
144
|
+
if (edge.kind === 'route') {
|
|
145
|
+
if (!['complete', 'incomplete'].includes(edge.outcome) || edge.case !== undefined
|
|
146
|
+
|| (edge.outcome === 'complete' && from.switchCases.length)) fail('INVALID_ROUTE')
|
|
147
|
+
} else if (!plan.enableSwitch || !from.switchCases.includes(edge.case) || edge.outcome !== undefined) fail('INVALID_CASE_ROUTE')
|
|
148
|
+
const routeKey = `${edge.from}:${edge.kind}:${edge.outcome ?? edge.case}`
|
|
149
|
+
if (routes.has(routeKey)) fail('MULTIPLE_TARGETS_FOR_ONE_ROUTE')
|
|
150
|
+
routes.add(routeKey)
|
|
151
|
+
}
|
|
152
|
+
const signature = canonical({ kind: edge.kind, from: edge.from, to: edge.to, outcome: edge.outcome ?? null, case: edge.case ?? null })
|
|
153
|
+
if (duplicate.has(signature)) fail('DUPLICATE_EDGE')
|
|
154
|
+
duplicate.add(signature); semantic.push([edge.from, edge.to])
|
|
155
|
+
}
|
|
156
|
+
if (containment.length + plan.edges.length > LIMITS.edges) fail('TOTAL_EDGE_LIMIT')
|
|
157
|
+
noCycles([...nodes.keys()], semantic, 'PREREQUISITE_ROUTE_OR_COMPLETION_CYCLE')
|
|
158
|
+
for (const route of plan.edges.filter(edge => edge.kind === 'route' && edge.outcome === 'incomplete')) {
|
|
159
|
+
if (plan.edges.some(edge => edge.kind === 'prerequisite' && edge.from === route.from && edge.to === route.to)) fail('INCOMPLETE_ROUTE_REQUIRES_SAME_COMPLETE_SOURCE')
|
|
160
|
+
}
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function graphProgress({ plan, leaves }) {
|
|
165
|
+
const nodes = new Map(plan.nodes.map(node => [node.id, node]))
|
|
166
|
+
const children = new Map(plan.nodes.map(node => [node.id, []]))
|
|
167
|
+
const prerequisites = new Map(plan.nodes.map(node => [node.id, []]))
|
|
168
|
+
const routeSources = new Map(plan.nodes.map(node => [node.id, []]))
|
|
169
|
+
const incompleteTargets = new Map()
|
|
170
|
+
for (const node of plan.nodes) {
|
|
171
|
+
if (node.parent !== null) children.get(node.parent).push(node)
|
|
172
|
+
}
|
|
173
|
+
for (const edge of plan.edges) {
|
|
174
|
+
if (edge.kind === 'prerequisite') {
|
|
175
|
+
prerequisites.get(edge.to).push(edge.from)
|
|
176
|
+
} else {
|
|
177
|
+
routeSources.get(edge.to).push(edge.from)
|
|
178
|
+
if (edge.outcome === 'incomplete') incompleteTargets.set(edge.from, edge.to)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const branches = new Map()
|
|
183
|
+
const terminal = new Set(['COMPLETE', 'INCOMPLETE', 'SKIPPED'])
|
|
184
|
+
const priority = ['SELECTED', 'WAITING', 'NOT_SELECTED']
|
|
185
|
+
const mergeBranches = markers => priority.find(marker => markers.includes(marker)) ?? null
|
|
186
|
+
|
|
187
|
+
// Containment, prerequisites and routes were validated together as a DAG.
|
|
188
|
+
// Keep these markers derived so recorded outcomes and checksums stay intact.
|
|
189
|
+
function branchState(id) {
|
|
190
|
+
if (branches.has(id)) return branches.get(id)
|
|
191
|
+
const node = nodes.get(id)
|
|
192
|
+
let marker
|
|
193
|
+
if (node.kind === 'container') {
|
|
194
|
+
const markers = children.get(id).map(child => branchState(child.id))
|
|
195
|
+
marker = markers.every(Boolean) ? mergeBranches(markers) : null
|
|
196
|
+
} else if (routeSources.get(id).length) {
|
|
197
|
+
if (leaves[id].status !== 'INACTIVE') {
|
|
198
|
+
marker = 'SELECTED'
|
|
199
|
+
} else {
|
|
200
|
+
const stillOpen = routeSources.get(id).some(source =>
|
|
201
|
+
!terminal.has(leaves[source].status) && branchState(source) !== 'NOT_SELECTED')
|
|
202
|
+
marker = stillOpen ? 'WAITING' : 'NOT_SELECTED'
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
// A continuation with both selected and excluded prerequisites remains
|
|
206
|
+
// required. Its original prerequisite checks still decide eligibility.
|
|
207
|
+
marker = mergeBranches(prerequisites.get(id).map(branchState))
|
|
208
|
+
}
|
|
209
|
+
branches.set(id, marker)
|
|
210
|
+
return marker
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function descendantLeaves(node) {
|
|
214
|
+
return node.kind === 'leaf' ? [node] : children.get(node.id).flatMap(descendantLeaves)
|
|
215
|
+
}
|
|
216
|
+
function complete(id) {
|
|
217
|
+
const node = nodes.get(id)
|
|
218
|
+
if (node.kind === 'leaf') return leaves[id].status === 'COMPLETE'
|
|
219
|
+
const required = new Set(descendantLeaves(node).filter(leaf => branchState(leaf.id) !== 'NOT_SELECTED').map(leaf => leaf.id))
|
|
220
|
+
// A failed decision can be handled by its chosen continuation in this
|
|
221
|
+
// container. That continuation must itself complete or handle its outcome.
|
|
222
|
+
return required.size > 0 && [...required].every(leaf =>
|
|
223
|
+
leaves[leaf].status === 'COMPLETE' ||
|
|
224
|
+
(leaves[leaf].status === 'INCOMPLETE' && required.has(incompleteTargets.get(leaf))))
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
branchState, complete, children,
|
|
228
|
+
unmetPrerequisites: id => prerequisites.get(id).filter(source => !complete(source)),
|
|
229
|
+
pending: plan.nodes.filter(node =>
|
|
230
|
+
leaves[node.id]?.status === 'PENDING' && branchState(node.id) !== 'NOT_SELECTED'),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function legalNext(state, progress) {
|
|
235
|
+
if (!state || state.active) return []
|
|
236
|
+
const { pending, unmetPrerequisites } = progress ?? graphProgress(state)
|
|
237
|
+
return pending.filter(node => unmetPrerequisites(node.id).length === 0)
|
|
238
|
+
}
|
|
239
|
+
function matchesRoute(edge, outcome, selectedCase) {
|
|
240
|
+
return (edge.kind === 'route' && edge.outcome === outcome)
|
|
241
|
+
|| (edge.kind === 'case' && outcome === 'complete' && edge.case === selectedCase)
|
|
242
|
+
}
|
|
243
|
+
function activate(state, source, outcome, selectedCase) {
|
|
244
|
+
for (const edge of state.plan.edges) {
|
|
245
|
+
if (edge.from !== source) continue
|
|
246
|
+
if (matchesRoute(edge, outcome, selectedCase) && state.leaves[edge.to].status === 'INACTIVE')
|
|
247
|
+
state.leaves[edge.to].status = 'PENDING'
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function finishLeaf(state, data) {
|
|
251
|
+
const active = state.active
|
|
252
|
+
if (!active || data.nodeId !== active.nodeId || data.attemptId !== active.attemptId || data.owner !== active.owner) fail('ACTIVE_ATTEMPT_MISMATCH')
|
|
253
|
+
if (!['complete', 'incomplete'].includes(data.outcome)) fail('INVALID_OUTCOME')
|
|
254
|
+
const node = state.plan.nodes.find(node => node.id === active.nodeId)
|
|
255
|
+
if (data.outcome === 'complete' && node.switchCases.length) {
|
|
256
|
+
if (!node.switchCases.includes(data.selectedCase)) fail('EXPLICIT_SWITCH_CASE_REQUIRED')
|
|
257
|
+
} else if (data.selectedCase !== null) fail('UNEXPECTED_SWITCH_CASE')
|
|
258
|
+
const refs = evidenceRefs(data.evidence)
|
|
259
|
+
if (data.outcome === 'complete' && !refs.length) fail('COMPLETE_REQUIRES_TRACEABLE_REFERENCE')
|
|
260
|
+
const disposition = data.disposition ?? (data.outcome === 'complete' ? 'COMPLETED' : 'UNSATISFIED')
|
|
261
|
+
if (data.outcome === 'complete' ? disposition !== 'COMPLETED' : !['SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED'].includes(disposition)) fail('INVALID_FINISH_DISPOSITION')
|
|
262
|
+
text(data.note, 1200, 'finish note')
|
|
263
|
+
state.leaves[active.nodeId] = { status: data.outcome === 'complete' ? 'COMPLETE' : 'INCOMPLETE',
|
|
264
|
+
attemptId: active.attemptId, evidence: refs, note: data.note, disposition, acceptance: 'UNVERIFIED_REFERENCE' }
|
|
265
|
+
state.active = null
|
|
266
|
+
activate(state, active.nodeId, data.outcome, data.selectedCase)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Deterministic replay is authoritative; snapshots are checked against it.
|
|
270
|
+
export function replay(events) {
|
|
271
|
+
if (!Array.isArray(events) || !events.length || events.length > LIMITS.events) fail('EVENT_LIMIT')
|
|
272
|
+
let state = null, previous = null
|
|
273
|
+
const operations = new Set(), eventIds = new Set()
|
|
274
|
+
for (const event of events) {
|
|
275
|
+
object(event, ['id', 'seq', 'operationId', 'type', 'data', 'previousHash', 'hash'], 'event')
|
|
276
|
+
const body = { ...event }; delete body.hash
|
|
277
|
+
if (!identifier(event.id) || eventIds.has(event.id) || !identifier(event.operationId) || operations.has(event.operationId)
|
|
278
|
+
|| event.seq !== operations.size + 1 || event.previousHash !== previous || digest(body) !== event.hash) fail('EVENT_CHAIN_OR_REPLAY')
|
|
279
|
+
operations.add(event.operationId); eventIds.add(event.id)
|
|
280
|
+
if (!state) {
|
|
281
|
+
if (event.type !== 'CREATED') fail('FIRST_EVENT_MUST_CREATE')
|
|
282
|
+
object(event.data, ['plan'], 'created event')
|
|
283
|
+
validatePlan(event.data.plan)
|
|
284
|
+
const plan = copy(event.data.plan), incoming = new Set(plan.edges.filter(edge => edge.kind !== 'prerequisite').map(edge => edge.to))
|
|
285
|
+
const leaves = Object.fromEntries(plan.nodes.filter(node => node.kind === 'leaf').map(node => [node.id,
|
|
286
|
+
{ status: incoming.has(node.id) ? 'INACTIVE' : 'PENDING', evidence: [], acceptance: 'UNVERIFIED_REFERENCE' }]))
|
|
287
|
+
state = { schema: 1, revision: 0, plan, leaves, active: null, operations: [] }
|
|
288
|
+
} else if (event.type === 'BEGUN') {
|
|
289
|
+
object(event.data, ['nodeId', 'attemptId', 'owner'], 'begin event')
|
|
290
|
+
if (state.active || !legalNext(state).some(node => node.id === event.data.nodeId)
|
|
291
|
+
|| !identifier(event.data.attemptId) || !identifier(event.data.owner)) fail('BEGIN_NOT_LEGAL')
|
|
292
|
+
state.active = copy(event.data)
|
|
293
|
+
state.leaves[event.data.nodeId].status = 'ACTIVE'
|
|
294
|
+
} else if (event.type === 'FINISHED' || event.type === 'RECONCILED') {
|
|
295
|
+
object(event.data, ['nodeId', 'attemptId', 'owner', 'outcome', 'selectedCase', 'evidence', 'note', 'disposition'], 'finish event')
|
|
296
|
+
finishLeaf(state, event.data)
|
|
297
|
+
} else if (event.type === 'SKIPPED') {
|
|
298
|
+
object(event.data, ['nodeId', 'evidence', 'note'], 'skip event')
|
|
299
|
+
if (state.active || state.leaves[event.data.nodeId]?.status !== 'PENDING') fail('SKIP_NOT_LEGAL')
|
|
300
|
+
state.leaves[event.data.nodeId] = { status: 'SKIPPED', disposition: 'SKIPPED_NOT_RUN', evidence: evidenceRefs(event.data.evidence),
|
|
301
|
+
note: text(event.data.note, 1200, 'skip note'), acceptance: 'UNVERIFIED_REFERENCE' }
|
|
302
|
+
// A skip never fires a complete/incomplete route and never satisfies a prerequisite.
|
|
303
|
+
} else fail('UNKNOWN_EVENT_TYPE')
|
|
304
|
+
state.revision = event.seq; state.operations.push(event.operationId); previous = event.hash
|
|
305
|
+
}
|
|
306
|
+
return state
|
|
307
|
+
}
|
|
308
|
+
export function documentOf(events) {
|
|
309
|
+
const state = replay(events), document = { schema: 1, revision: state.revision, state, events: copy(events) }
|
|
310
|
+
if (Buffer.byteLength(canonical(document)) > LIMITS.bytes) fail('DOCUMENT_BYTE_LIMIT')
|
|
311
|
+
return { ...document, checksum: digest(document) }
|
|
312
|
+
}
|
|
313
|
+
export function validateDocument(document) {
|
|
314
|
+
object(document, ['schema', 'revision', 'state', 'events', 'checksum'], 'document')
|
|
315
|
+
if (document.schema !== 1 || Buffer.byteLength(canonical(document)) > LIMITS.bytes) fail('DOCUMENT_LIMIT')
|
|
316
|
+
const body = { ...document }; delete body.checksum
|
|
317
|
+
if (digest(body) !== document.checksum) fail('DOCUMENT_CHECKSUM')
|
|
318
|
+
const replayed = replay(document.events)
|
|
319
|
+
if (document.revision !== replayed.revision || canonical(document.state) !== canonical(replayed)) fail('SNAPSHOT_REPLAY_MISMATCH')
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
export function view(document, owner) {
|
|
323
|
+
if (!document) return { revision: 0, graph: null, legalNext: [], active: null, goalReached: false }
|
|
324
|
+
validateDocument(document)
|
|
325
|
+
const state = document.state
|
|
326
|
+
const progress = graphProgress(state), ready = legalNext(state, progress)
|
|
327
|
+
const readyIds = new Set(ready.map(node => node.id))
|
|
328
|
+
const decisions = new Set(state.plan.edges.filter(edge => edge.kind !== 'prerequisite').map(edge => edge.from))
|
|
329
|
+
const selectedBranches = new Map()
|
|
330
|
+
for (const event of document.events) {
|
|
331
|
+
if (event.type !== 'FINISHED' && event.type !== 'RECONCILED') continue
|
|
332
|
+
const { nodeId, outcome, selectedCase } = event.data
|
|
333
|
+
if (!decisions.has(nodeId) && selectedCase === null) continue
|
|
334
|
+
const target = state.plan.edges.find(edge => edge.from === nodeId && matchesRoute(edge, outcome, selectedCase))
|
|
335
|
+
selectedBranches.set(nodeId, { outcome, ...(selectedCase === null ? {} : { case: selectedCase }),
|
|
336
|
+
...(target ? { targetId: target.to } : {}) })
|
|
337
|
+
}
|
|
338
|
+
const rows = state.plan.nodes.map(node => {
|
|
339
|
+
const branchState = progress.branchState(node.id), selectedBranch = selectedBranches.get(node.id)
|
|
340
|
+
const row = { id: node.id, key: node.key, label: node.label, kind: node.kind, ...(branchState ? { branchState } : {}) }
|
|
341
|
+
if (node.kind === 'leaf') return { ...row, ...copy(state.leaves[node.id]), ...(selectedBranch ? { selectedBranch } : {}) }
|
|
342
|
+
const allChildrenComplete = progress.complete(node.id)
|
|
343
|
+
return { ...row, childCount: progress.children.get(node.id).length, allChildrenComplete,
|
|
344
|
+
status: allChildrenComplete ? 'COMPLETE' : 'CONTAINER_INCOMPLETE' }
|
|
345
|
+
})
|
|
346
|
+
return { revision: state.revision, graph: { id: state.plan.id, title: state.plan.title }, nodes: rows,
|
|
347
|
+
active: state.active ? { ...copy(state.active), recoveryRequired: state.active.owner !== owner,
|
|
348
|
+
caseChoices: state.plan.nodes.find(node => node.id === state.active.nodeId).switchCases.map((label, choice) => ({ choice, label })) } : null,
|
|
349
|
+
legalNext: ready.map((node, choice) => ({ choice, id: node.id, label: node.label, switchCases: node.switchCases })),
|
|
350
|
+
pendingButBlocked: progress.pending.filter(node => !readyIds.has(node.id)).map(node => ({ id: node.id,
|
|
351
|
+
reason: state.active ? 'ACTIVE_LEAF' : 'UNMET_PREREQUISITE',
|
|
352
|
+
unmetPrerequisites: progress.unmetPrerequisites(node.id) })),
|
|
353
|
+
skipChoices: state.active ? [] : progress.pending
|
|
354
|
+
.map((node, choice) => ({ choice, id: node.id, label: node.label })),
|
|
355
|
+
goalReached: false, evidenceMeaning: 'References are unverified. Transaction completion does not establish Goal completion.' }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export class TransactionGraph {
|
|
359
|
+
constructor(store, { enableSwitch = false, id = prefix => `${prefix}_${randomUUID()}`, verifyRecovery = async () => false } = {}) {
|
|
360
|
+
this.store = store; this.enableSwitch = enableSwitch; this.id = id; this.owner = id('owner'); this.verifyRecovery = verifyRecovery
|
|
361
|
+
if (!identifier(this.owner)) fail('INVALID_OWNER')
|
|
362
|
+
}
|
|
363
|
+
read() { return view(this.store.read(), this.owner) }
|
|
364
|
+
mutate(expectedRevision, operationId, type, makeData) {
|
|
365
|
+
integer(expectedRevision, 0, 'revision')
|
|
366
|
+
if (!identifier(operationId)) fail('INVALID_OPERATION_ID')
|
|
367
|
+
return this.store.compareAndSwap(expectedRevision, previous => {
|
|
368
|
+
if (previous) validateDocument(previous)
|
|
369
|
+
if (previous?.state.operations.includes(operationId)) fail('OPERATION_REPLAY')
|
|
370
|
+
if (previous && previous.state.plan.enableSwitch !== this.enableSwitch) fail('GRAPH_POLICY_CHANGED')
|
|
371
|
+
const data = makeData(previous?.state ?? null)
|
|
372
|
+
const event = { id: this.id('event'), seq: expectedRevision + 1, operationId, type, data,
|
|
373
|
+
previousHash: previous?.events.at(-1).hash ?? null }
|
|
374
|
+
event.hash = digest(event)
|
|
375
|
+
return documentOf([...(previous?.events ?? []), event])
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
create({ expectedRevision = 0, operationId, definition }) {
|
|
379
|
+
const result = this.mutate(expectedRevision, operationId, 'CREATED', state => {
|
|
380
|
+
if (state) fail('PLAN_IMMUTABLE_CREATE_ONCE')
|
|
381
|
+
return { plan: normalizePlan(definition, { enableSwitch: this.enableSwitch, id: this.id }) }
|
|
382
|
+
}); return view(result, this.owner)
|
|
383
|
+
}
|
|
384
|
+
begin({ expectedRevision, operationId, choice }) {
|
|
385
|
+
const result = this.mutate(expectedRevision, operationId, 'BEGUN', state => {
|
|
386
|
+
if (!state) fail('NO_PLAN')
|
|
387
|
+
if (state.active) fail(state.active.owner === this.owner ? 'ONE_ACTIVE_LEAF_ONLY' : 'RECOVERY_REQUIRED')
|
|
388
|
+
integer(choice, 0, 'choice')
|
|
389
|
+
const selected = legalNext(state)[choice]
|
|
390
|
+
if (!selected) fail('NO_SUCH_LEGAL_CHOICE')
|
|
391
|
+
return { nodeId: selected.id, attemptId: this.id('attempt'), owner: this.owner }
|
|
392
|
+
}); return view(result, this.owner)
|
|
393
|
+
}
|
|
394
|
+
finish({ expectedRevision, operationId, outcome, caseIndex, evidence = [], note, disposition = outcome === 'complete' ? 'COMPLETED' : 'UNSATISFIED' }) {
|
|
395
|
+
const result = this.mutate(expectedRevision, operationId, 'FINISHED', state => {
|
|
396
|
+
if (!state?.active) fail('NO_ACTIVE_LEAF')
|
|
397
|
+
if (state.active.owner !== this.owner) fail('RECOVERY_REQUIRED')
|
|
398
|
+
const node = state.plan.nodes.find(node => node.id === state.active.nodeId)
|
|
399
|
+
let selectedCase = null
|
|
400
|
+
if (caseIndex !== undefined) { integer(caseIndex, 0, 'caseIndex'); selectedCase = node.switchCases[caseIndex]; if (selectedCase === undefined) fail('NO_SUCH_CASE') }
|
|
401
|
+
return { ...state.active, outcome, selectedCase, disposition, evidence: evidenceRefs(evidence), note: text(note, 1200, 'note') }
|
|
402
|
+
}); return view(result, this.owner)
|
|
403
|
+
}
|
|
404
|
+
skip({ expectedRevision, operationId, choice, evidence = [], note }) {
|
|
405
|
+
const result = this.mutate(expectedRevision, operationId, 'SKIPPED', state => {
|
|
406
|
+
if (!state) fail('NO_PLAN')
|
|
407
|
+
if (state.active) fail(state.active.owner === this.owner ? 'ONE_ACTIVE_LEAF_ONLY' : 'RECOVERY_REQUIRED')
|
|
408
|
+
integer(choice, 0, 'choice')
|
|
409
|
+
const selected = graphProgress(state).pending[choice]
|
|
410
|
+
if (!selected) fail('NO_SUCH_SKIP_CHOICE')
|
|
411
|
+
return { nodeId: selected.id, evidence: evidenceRefs(evidence), note: text(note, 1200, 'note') }
|
|
412
|
+
}); return view(result, this.owner)
|
|
413
|
+
}
|
|
414
|
+
async reconcile({ expectedRevision, operationId, outcome, caseIndex, evidence = [], note }) {
|
|
415
|
+
const before = this.store.read()
|
|
416
|
+
if (!before) fail('NO_PLAN')
|
|
417
|
+
validateDocument(before)
|
|
418
|
+
if (before.revision !== expectedRevision || !before.state.active) fail('RECOVERY_STALE_OR_NO_ACTIVE')
|
|
419
|
+
if (before.state.active.owner === this.owner) fail('USE_NORMAL_FINISH')
|
|
420
|
+
const node = before.state.plan.nodes.find(node => node.id === before.state.active.nodeId)
|
|
421
|
+
let selectedCase = null
|
|
422
|
+
if (caseIndex !== undefined) { integer(caseIndex, 0, 'caseIndex'); selectedCase = node.switchCases[caseIndex]; if (selectedCase === undefined) fail('NO_SUCH_CASE') }
|
|
423
|
+
const data = { ...copy(before.state.active), outcome, selectedCase, disposition: outcome === 'complete' ? 'COMPLETED' : 'UNSATISFIED', evidence: evidenceRefs(evidence), note: text(note, 1200, 'note') }
|
|
424
|
+
// Freeze the exact route/outcome/refs before awaiting the external attestation.
|
|
425
|
+
const accepted = await this.verifyRecovery({ expectedRevision, ...copy(data) })
|
|
426
|
+
if (accepted !== true) fail('HOST_RECOVERY_ATTESTATION_REQUIRED')
|
|
427
|
+
const result = this.mutate(expectedRevision, operationId, 'RECONCILED', state => {
|
|
428
|
+
if (canonical(state.active) !== canonical(before.state.active)) fail('RECOVERY_ACTIVE_CHANGED')
|
|
429
|
+
return copy(data)
|
|
430
|
+
}); return view(result, this.owner)
|
|
431
|
+
}
|
|
432
|
+
}
|