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/store.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { GraphError, LIMITS, canonical, copy, validateDocument } from './core.mjs'
6
+
7
+ const reject = (code, message = code) => { throw new GraphError(code, message) }
8
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino
9
+ function checkRevision(expectedRevision, previous) {
10
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) reject('INVALID_REVISION')
11
+ if ((previous?.revision ?? 0) !== expectedRevision) reject('CAS_CONFLICT')
12
+ }
13
+ function checkNext(previous, next) {
14
+ validateDocument(next)
15
+ if (next.revision !== (previous?.revision ?? 0) + 1 || next.events.length !== (previous?.events.length ?? 0) + 1)
16
+ reject('ONE_EVENT_PER_CAS_REQUIRED')
17
+ if (previous && canonical(next.events.slice(0, -1)) !== canonical(previous.events)) reject('HISTORY_IMMUTABLE')
18
+ }
19
+ function syncDirectory(directory) {
20
+ const fd = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY)
21
+ try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
22
+ }
23
+ function regularBoundedJson(file, maximum = LIMITS.bytes) {
24
+ let fd
25
+ try { fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW) }
26
+ catch (error) { if (error.code === 'ENOENT') return null; throw error }
27
+ try {
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))
31
+ let count = 0
32
+ while (count < buffer.length) {
33
+ const read = fs.readSync(fd, buffer, count, buffer.length - count, null)
34
+ if (!read) break
35
+ count += read
36
+ }
37
+ const after = fs.fstatSync(fd)
38
+ if (!sameFile(before, after) || count !== before.size || after.size !== count || before.mtimeMs !== after.mtimeMs)
39
+ reject('STORE_CHANGED_DURING_READ')
40
+ return JSON.parse(buffer.subarray(0, count).toString('utf8'))
41
+ } finally { fs.closeSync(fd) }
42
+ }
43
+
44
+ export class MemoryGraphStore {
45
+ constructor(document = null) { if (document) validateDocument(document); this.document = document ? copy(document) : null }
46
+ read() { return this.document ? copy(this.document) : null }
47
+ compareAndSwap(expectedRevision, update) {
48
+ const previous = this.read()
49
+ checkRevision(expectedRevision, previous)
50
+ const next = update(previous ? copy(previous) : null)
51
+ checkNext(previous, next)
52
+ this.document = copy(next)
53
+ return this.read()
54
+ }
55
+ }
56
+
57
+ // Explicit projectRoot and a new/private directory are mandatory. No default DB.
58
+ // All components below projectRoot must be real directories, never symlinks.
59
+ // This is application path validation, not isolation from a hostile same-user OS.
60
+ export class FileGraphStore {
61
+ constructor({ projectRoot, directory, fileName = 'transaction-graph.json', maximumBytes = LIMITS.bytes,
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')
65
+ this.maximumBytes = maximumBytes; this.validator = validator; this.updateValidator = updateValidator
66
+ this.readOnly = readOnly
67
+ if (typeof projectRoot !== 'string' || typeof directory !== 'string'
68
+ || !path.isAbsolute(projectRoot) || !path.isAbsolute(directory)) reject('EXPLICIT_ABSOLUTE_STORE_ROOT_REQUIRED')
69
+ this.projectRoot = path.resolve(projectRoot)
70
+ this.directory = path.resolve(directory)
71
+ if (fs.realpathSync(this.projectRoot) !== this.projectRoot || !fs.lstatSync(this.projectRoot).isDirectory()) reject('INVALID_PROJECT_ROOT')
72
+ const relative = path.relative(this.projectRoot, this.directory)
73
+ if (!relative || relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative)) reject('STORE_OUTSIDE_PROJECT')
74
+ let cursor = this.projectRoot
75
+ for (const component of relative.split(path.sep)) {
76
+ cursor = path.join(cursor, component)
77
+ if (!readOnly) {
78
+ try { fs.mkdirSync(cursor, { mode: 0o700 }); syncDirectory(path.dirname(cursor)) }
79
+ catch (error) { if (error.code !== 'EEXIST') throw error }
80
+ }
81
+ const info = fs.lstatSync(cursor)
82
+ if (info.isSymbolicLink() || !info.isDirectory()) reject('STORE_SYMLINK_OR_NOT_DIRECTORY')
83
+ }
84
+ this.rootIdentity = fs.lstatSync(this.projectRoot)
85
+ this.directoryIdentity = fs.lstatSync(this.directory)
86
+ this.file = path.join(this.directory, fileName)
87
+ this.lock = path.join(this.directory, '.transaction-graph.lock')
88
+ this.checkDirectory()
89
+ }
90
+ checkDirectory() {
91
+ const root = fs.lstatSync(this.projectRoot), directory = fs.lstatSync(this.directory)
92
+ if (!sameFile(root, this.rootIdentity) || !sameFile(directory, this.directoryIdentity)
93
+ || !root.isDirectory() || !directory.isDirectory() || directory.isSymbolicLink()
94
+ || fs.realpathSync(this.projectRoot) !== this.projectRoot || fs.realpathSync(this.directory) !== this.directory)
95
+ reject('STORE_DIRECTORY_IDENTITY_CHANGED')
96
+ let cursor = this.projectRoot
97
+ for (const part of path.relative(this.projectRoot, this.directory).split(path.sep)) {
98
+ cursor = path.join(cursor, part)
99
+ const info = fs.lstatSync(cursor)
100
+ if (info.isSymbolicLink() || !info.isDirectory()) reject('STORE_SYMLINK_OR_NOT_DIRECTORY')
101
+ }
102
+ }
103
+ read() {
104
+ this.checkDirectory()
105
+ const document = regularBoundedJson(this.file, this.maximumBytes)
106
+ this.checkDirectory()
107
+ if (document) this.validator(document)
108
+ return document
109
+ }
110
+ compareAndSwap(expectedRevision, update) {
111
+ if (this.readOnly) reject('READ_ONLY_TRANSACTION_STORE')
112
+ this.checkDirectory()
113
+ try { fs.mkdirSync(this.lock, { mode: 0o700 }) }
114
+ catch (error) { if (error.code === 'EEXIST') reject('CAS_LOCK_BUSY_NO_AUTOMATIC_STEAL'); throw error }
115
+ const lockIdentity = fs.lstatSync(this.lock), token = randomUUID()
116
+ const ownerFile = path.join(this.lock, 'owner.json')
117
+ let temporary = null, temporaryIdentity = null, ownerWritten = false
118
+ try {
119
+ const ownerFd = fs.openSync(ownerFile, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
120
+ try { fs.writeFileSync(ownerFd, JSON.stringify({ token })); fs.fsyncSync(ownerFd); ownerWritten = true } finally { fs.closeSync(ownerFd) }
121
+ syncDirectory(this.lock)
122
+ const previous = this.read()
123
+ checkRevision(expectedRevision, previous)
124
+ const next = update(previous ? copy(previous) : null)
125
+ if (next && typeof next.then === 'function') reject('ASYNC_STORE_UPDATE_FORBIDDEN')
126
+ this.validator(next)
127
+ this.updateValidator(previous, next)
128
+ const content = JSON.stringify(next) + '\n'
129
+ if (Buffer.byteLength(content) > this.maximumBytes) reject('STORE_BYTE_LIMIT')
130
+ temporary = path.join(this.directory, '.transaction-graph-' + randomUUID() + '.tmp')
131
+ const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600)
132
+ try { temporaryIdentity = fs.fstatSync(fd); fs.writeFileSync(fd, content); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
133
+ this.checkDirectory()
134
+ if (!sameFile(lockIdentity, fs.lstatSync(this.lock)) || regularBoundedJson(ownerFile)?.token !== token) reject('LOCK_OWNERSHIP_CHANGED')
135
+ // One rename commits plan, state and events together; no separate stale snapshot.
136
+ fs.renameSync(temporary, this.file); temporary = null
137
+ syncDirectory(this.directory)
138
+ return copy(next)
139
+ } finally {
140
+ if (temporary && temporaryIdentity) {
141
+ try { if (sameFile(temporaryIdentity, fs.lstatSync(temporary))) fs.unlinkSync(temporary) }
142
+ catch (error) { if (error.code !== 'ENOENT') throw error }
143
+ }
144
+ // Never recursively remove a lock or infer that a previous owner is dead.
145
+ if (!sameFile(lockIdentity, fs.lstatSync(this.lock)) || (ownerWritten && regularBoundedJson(ownerFile)?.token !== token))
146
+ reject('LOCK_OWNERSHIP_CHANGED')
147
+ if (ownerWritten) fs.unlinkSync(ownerFile)
148
+ fs.rmdirSync(this.lock)
149
+ syncDirectory(this.directory)
150
+ }
151
+ }
152
+ }
package/tools.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { createHash } from 'node:crypto'
3
+ import { GraphError, copy } from './core.mjs'
4
+
5
+ export const TOOL_NAMES = Object.freeze(['transaction_graph_read', 'transaction_graph_create',
6
+ 'transaction_graph_begin', 'transaction_graph_finish', 'transaction_graph_skip'])
7
+ const evidence = { type: 'array', items: { type: 'object', properties: {
8
+ kind: { type: 'string', required: true, enum: ['session_event', 'tool_result', 'artifact'] },
9
+ ref: { type: 'string', required: true }, sha256: { type: 'string' },
10
+ }, additionalProperties: false } }
11
+ const revision = { type: 'integer', required: true, description: 'Exact revision from the last program-generated read.' }
12
+ const definition = { type: 'object', required: true, properties: {
13
+ title: { type: 'string', required: true },
14
+ nodes: { type: 'array', required: true, items: { type: 'object', properties: {
15
+ key: { type: 'string', required: true }, kind: { type: 'string', required: true, enum: ['container', 'leaf'] },
16
+ label: { type: 'string', required: true }, parent: { type: 'string' },
17
+ switchCases: { type: 'array', items: { type: 'string' } },
18
+ }, additionalProperties: false } },
19
+ edges: { type: 'array', required: true, items: { type: 'object', properties: {
20
+ kind: { type: 'string', required: true, enum: ['prerequisite', 'route', 'case'] },
21
+ from: { type: 'string', required: true }, to: { type: 'string', required: true },
22
+ outcome: { type: 'string', enum: ['complete', 'incomplete'] }, case: { type: 'string' },
23
+ }, additionalProperties: false } },
24
+ }, additionalProperties: false }
25
+
26
+ // Mandatory adapter port. No fallback infers successful tool execution from a ref.
27
+ // Implementations must durably reserve one intent, commit only after real successful
28
+ // tool/result AND every enclosing PTC parent, and CAS-check again at that boundary.
29
+ // A restarted adapter must reconcile persisted pending intents, never replay them.
30
+ export class UnconnectedHostPort {
31
+ authorize() { throw new GraphError('DSH_INTEGRATION_NOT_RUN') }
32
+ read() { throw new GraphError('DSH_INTEGRATION_NOT_RUN') }
33
+ stageMutation() { throw new GraphError('DSH_INTEGRATION_NOT_RUN') }
34
+ }
35
+
36
+ function operationId(scope, callId) {
37
+ if (typeof scope !== 'string' || !scope || scope.length > 512 || typeof callId !== 'string' || !callId || callId.length > 512)
38
+ throw new GraphError('HOST_SCOPE_AND_CALL_ID_REQUIRED')
39
+ return 'op_' + createHash('sha256').update(JSON.stringify([scope, callId])).digest('hex')
40
+ }
41
+ function toolArguments(operation, args) {
42
+ const allowed = { read: [], create: ['expectedRevision', 'definition'], begin: ['expectedRevision', 'choice'],
43
+ finish: ['expectedRevision', 'outcome', 'caseIndex', 'evidence', 'note', 'disposition'],
44
+ skip: ['expectedRevision', 'choice', 'evidence', 'note'] }[operation]
45
+ if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some(key => !allowed.includes(key)))
46
+ throw new GraphError('UNEXPECTED_TOOL_ARGUMENT')
47
+ if (operation !== 'read' && (!Number.isSafeInteger(args.expectedRevision) || args.expectedRevision < 0))
48
+ throw new GraphError('INVALID_REVISION')
49
+ if (['begin', 'skip'].includes(operation) && (!Number.isSafeInteger(args.choice) || args.choice < 0))
50
+ throw new GraphError('INVALID_CHOICE')
51
+ return copy(args)
52
+ }
53
+
54
+ export function createToolDefinitions({ defineTool, hostPort = new UnconnectedHostPort() }) {
55
+ if (typeof defineTool !== 'function') throw new GraphError('REAL_DSH_DEFINE_TOOL_REQUIRED')
56
+ const definitions = [
57
+ { name: TOOL_NAMES[0], operation: 'read', parameters: {},
58
+ description: 'Read program-generated transaction IDs, revision, legal begin/skip choices, active/recovery state and parent summaries. Completion is not Goal completion.' },
59
+ { name: TOOL_NAMES[1], operation: 'create', parameters: { expectedRevision: revision, definition },
60
+ description: 'Propose one immutable bounded transaction graph. Declare immediate prerequisites and keep independent conditions. Omit repeated prerequisites already implied by an unconditional chain of leaf tasks. Supply local node keys; the program generates durable IDs. Switch cases require explicit host policy. A staged proposal is not yet committed.' },
61
+ { name: TOOL_NAMES[2], operation: 'begin', parameters: { expectedRevision: revision, choice: { type: 'integer', required: true } },
62
+ description: 'Stage the displayed legal choice index as the sole active leaf. Do not copy IDs or invent a next transaction. Restarted ACTIVE requires host reconciliation.' },
63
+ { name: TOOL_NAMES[3], operation: 'finish', parameters: { expectedRevision: revision,
64
+ outcome: { type: 'string', required: true, enum: ['complete', 'incomplete'] }, caseIndex: { type: 'integer' },
65
+ disposition: { type: 'string', enum: ['SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED'], description: 'Required for incomplete: explicit non-execution, unsatisfied outcome, or real known terminal failure. UNKNOWN never qualifies.' },
66
+ evidence, note: { type: 'string', required: true } },
67
+ description: 'Stage complete or explicit incomplete disposition for the active attempt. Any unresolved owned work blocks every finish, including empty references. FAILED requires real terminal failure evidence; SKIPPED_NOT_RUN preserves non-execution. Complete requires successful references. Goal success is independent.' },
68
+ { name: TOOL_NAMES[4], operation: 'skip', parameters: { expectedRevision: revision, choice: { type: 'integer', required: true },
69
+ evidence, note: { type: 'string', required: true } },
70
+ description: 'Stage a displayed pending skip choice when no leaf is active. A skip neither satisfies prerequisites nor activates completion/incompletion routes.' },
71
+ ]
72
+ return definitions.map(({ operation, ...tool }) => defineTool({ ...tool,
73
+ ...(operation === 'read' ? { projectPlanCapability: 'read_or_protocol' } : {}),
74
+ output: { schema: { type: 'object', properties: {}, additionalProperties: true },
75
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
76
+ execute: async (args, exec) => {
77
+ exec.signal?.throwIfAborted()
78
+ if (!exec.agent || typeof exec.callId !== 'string') throw new GraphError('REAL_AGENT_TOOL_EXECUTION_REQUIRED')
79
+ const checkedArguments = toolArguments(operation, args)
80
+ // The real port must verify current initiator, running agent, active session
81
+ // and permission scope using actual DSH APIs, not model-supplied fields.
82
+ const authority = await hostPort.authorize(exec, { operation })
83
+ if (!authority || authority.currentInitiatorVerified !== true || authority.agentId !== exec.agent.id)
84
+ throw new GraphError('HOST_OWNERSHIP_NOT_VERIFIED')
85
+ exec.signal?.throwIfAborted()
86
+ if (operation === 'read') return hostPort.read(authority, exec)
87
+ const result = await hostPort.stageMutation({ operation, arguments: checkedArguments,
88
+ operationId: operationId(authority.sessionScope, exec.callId), authority, execution: exec })
89
+ if (result?.state !== 'PENDING_HOST_COMMIT') throw new GraphError('HOST_MUST_STAGE_NOT_PRETEND_COMMIT')
90
+ return { ...result, goalReached: false,
91
+ instruction: 'Await the real successful tool/PTC commit boundary, then read the committed revision. Pending is not completion.' }
92
+ },
93
+ }))
94
+ }
package/view-model.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Only reduce a prerequisite that is already implied by a chain of leaf
3
+ // prerequisites. Entering a conditional task can change branch participation.
4
+ function chainEdges(graph) {
5
+ const leaves = new Set(graph.nodes.filter(node => node.kind === 'leaf').map(node => node.id))
6
+ const prerequisites = new Map()
7
+ const conditional = new Set()
8
+ for (const edge of graph.edges) {
9
+ if (edge.kind !== 'prerequisite') {
10
+ conditional.add(edge.to)
11
+ continue
12
+ }
13
+ const sources = prerequisites.get(edge.to) ?? []
14
+ sources.push(edge.from)
15
+ prerequisites.set(edge.to, sources)
16
+ }
17
+ return graph.edges.filter(edge => {
18
+ if (edge.kind !== 'prerequisite' || !leaves.has(edge.from)) return true
19
+ const sources = prerequisites.get(edge.to)
20
+ if (sources.length < 2) return true
21
+ const pending = sources.filter(id => id !== edge.from)
22
+ const seen = new Set()
23
+ while (pending.length) {
24
+ const id = pending.pop()
25
+ if (id === edge.from) return false
26
+ if (!leaves.has(id) || conditional.has(id) || seen.has(id)) continue
27
+ seen.add(id)
28
+ pending.push(...(prerequisites.get(id) ?? []))
29
+ }
30
+ return true
31
+ })
32
+ }
33
+
34
+ // Host supplies legal choices. The browser only organizes them for display.
35
+ export function graphPresentation(graph, selectedId) {
36
+ const nodes = new Map(graph.nodes.map(node => [node.id, node]))
37
+ const children = new Map()
38
+ for (const node of graph.nodes) {
39
+ const siblings = children.get(node.parent) ?? []
40
+ siblings.push(node)
41
+ children.set(node.parent, siblings)
42
+ }
43
+ const active = graph.active ? nodes.get(graph.active.nodeId) ?? null : null
44
+ const legal = new Set(graph.legalNext.map(choice => choice.id))
45
+ const selected = nodes.get(selectedId) ?? active ?? nodes.get(graph.legalNext[0]?.id) ?? children.get(null)?.[0] ?? null
46
+ const conditionIds = new Set(chainEdges(graph).filter(edge => edge.to === selected?.id).map(edge => edge.from))
47
+ const conditions = [...conditionIds].map(id => nodes.get(id)).filter(Boolean)
48
+ const allLeaves = graph.nodes.filter(node => node.kind === 'leaf')
49
+ const leaves = allLeaves.filter(node => node.branchState !== 'NOT_SELECTED')
50
+ const count = status => leaves.filter(node => node.status === status).length
51
+ return {
52
+ nodes, children, active, legal, selected, conditions,
53
+ progress: {
54
+ total: leaves.length, complete: count('COMPLETE'), incomplete: count('INCOMPLETE'),
55
+ skipped: count('SKIPPED'), inactive: count('INACTIVE'), excluded: allLeaves.length - leaves.length, ready: legal.size,
56
+ },
57
+ }
58
+ }
59
+
60
+ function branchEdgeState(edge, source, target) {
61
+ if (source.branchState === 'NOT_SELECTED' || target.branchState === 'NOT_SELECTED') return 'branchExcluded'
62
+ if (edge.kind === 'prerequisite') {
63
+ if (source.status === 'COMPLETE') return 'prerequisiteMet'
64
+ if (source.status === 'INCOMPLETE' || source.status === 'SKIPPED' || source.blocked) return 'blocked'
65
+ return 'edgeWaiting'
66
+ }
67
+ const selected = source.selectedBranch
68
+ if (selected?.targetId === edge.to && (edge.kind === 'route' ? selected.outcome === edge.outcome : selected.case === edge.case))
69
+ return 'branchChosen'
70
+ return selected || source.status === 'SKIPPED' ? 'branchExcluded' : 'edgeWaiting'
71
+ }
72
+
73
+ // Rank by explicit dependencies. Containers stay in the tree unless they
74
+ // participate in a dependency. A leaf still shows which container it belongs to.
75
+ export function branchLayout(graph) {
76
+ const nodesById = new Map(graph.nodes.map(node => [node.id, node]))
77
+ const edges = chainEdges(graph).filter(edge => nodesById.has(edge.from) && nodesById.has(edge.to))
78
+ const connected = new Set(edges.flatMap(edge => [edge.from, edge.to]))
79
+ const nodes = graph.nodes.filter(node => node.kind === 'leaf' || connected.has(node.id))
80
+ const outgoing = new Map(nodes.map(node => [node.id, []]))
81
+ const incoming = new Map(nodes.map(node => [node.id, []]))
82
+ for (const edge of edges) {
83
+ outgoing.get(edge.from).push(edge)
84
+ incoming.get(edge.to).push(edge)
85
+ }
86
+ const incomingCount = new Map(nodes.map(node => [node.id, incoming.get(node.id).length]))
87
+ const rank = new Map(nodes.map(node => [node.id, 0]))
88
+ const queue = nodes.filter(node => incomingCount.get(node.id) === 0).map(node => node.id)
89
+ for (const id of queue) {
90
+ for (const { to: target } of outgoing.get(id)) {
91
+ rank.set(target, Math.max(rank.get(target), rank.get(id) + 1))
92
+ const remaining = incomingCount.get(target) - 1
93
+ incomingCount.set(target, remaining)
94
+ if (remaining === 0) queue.push(target)
95
+ }
96
+ }
97
+ const columns = Array.from({ length: 1 + Math.max(0, ...rank.values()) }, () => [])
98
+ for (const node of nodes) columns[rank.get(node.id)].push(node)
99
+
100
+ const padding = 26, cardWidth = 194, cardHeight = 84, columnGap = 104, rowGap = 26
101
+ const rows = Math.max(1, ...columns.map(column => column.length))
102
+ const bodyHeight = rows * (cardHeight + rowGap) - rowGap
103
+ const cards = columns.flatMap((column, index) => column.map((node, row) => ({
104
+ node,
105
+ group: nodesById.get(node.parent)?.label,
106
+ column: index,
107
+ additionalPrerequisites: 0,
108
+ x: padding + index * (cardWidth + columnGap),
109
+ y: padding + (rows - column.length) * (cardHeight + rowGap) / 2 + row * (cardHeight + rowGap),
110
+ })))
111
+ const cardById = new Map(cards.map(card => [card.node.id, card]))
112
+ const drawnEdges = new Set(edges.filter(edge => {
113
+ const source = cardById.get(edge.from), target = cardById.get(edge.to)
114
+ if (edge.kind !== 'prerequisite' || target.column === source.column + 1) return true
115
+ // The entry list still shows independent prerequisites that skip a column.
116
+ target.additionalPrerequisites += 1
117
+ return false
118
+ }))
119
+ for (const [id, group] of outgoing)
120
+ outgoing.set(id, group.filter(edge => drawnEdges.has(edge)).sort((a, b) => cardById.get(a.to).y - cardById.get(b.to).y))
121
+ for (const [id, group] of incoming)
122
+ incoming.set(id, group.filter(edge => drawnEdges.has(edge)).sort((a, b) => cardById.get(a.from).y - cardById.get(b.from).y))
123
+ const portOffset = (group, edge) => (group.indexOf(edge) - (group.length - 1) / 2) * Math.min(13, 52 / group.length)
124
+ const bendOffset = (group, edge) => 12 + 28 * (group.indexOf(edge) + 1) / (group.length + 1)
125
+ const pairKey = edge => JSON.stringify([edge.from, edge.to])
126
+ const parallel = new Map()
127
+ for (const edge of drawnEdges) {
128
+ const pair = pairKey(edge)
129
+ parallel.set(pair, (parallel.get(pair) ?? 0) + 1)
130
+ }
131
+ let laneCount = 0
132
+ const wires = [...drawnEdges].map(edge => {
133
+ const from = cardById.get(edge.from), to = cardById.get(edge.to)
134
+ const exits = outgoing.get(edge.from), entries = incoming.get(edge.to)
135
+ const startX = from.x + cardWidth, endX = to.x - 2
136
+ const startY = from.y + cardHeight / 2 + portOffset(exits, edge)
137
+ const endY = to.y + cardHeight / 2 + portOffset(entries, edge)
138
+ const state = branchEdgeState(edge, from.node, to.node)
139
+ const wire = { edge, from: from.node, to: to.node, state,
140
+ key: JSON.stringify([edge.from, edge.to, edge.kind, edge.outcome ?? edge.case]),
141
+ showLabel: edge.kind !== 'prerequisite' && (parallel.get(pairKey(edge)) === 1 || state === 'branchChosen') }
142
+ if (to.column - from.column > 1) {
143
+ // Route rank-skipping edges below the cards so they do not cross a task.
144
+ const laneY = padding + bodyHeight + 22 + laneCount++ * 16
145
+ const exitX = startX + bendOffset(exits, edge), entryX = endX - bendOffset(entries, edge)
146
+ return { ...wire, path: `M ${startX} ${startY} H ${exitX} V ${laneY} H ${entryX} V ${endY} H ${endX}`,
147
+ labelX: (startX + endX) / 2, labelY: laneY - 5 }
148
+ }
149
+ return { ...wire, path: `M ${startX} ${startY} C ${startX + 40} ${startY}, ${endX - 40} ${endY}, ${endX} ${endY}`,
150
+ labelX: (startX + endX) / 2, labelY: (startY + endY) / 2 - 7 }
151
+ })
152
+ return {
153
+ cards, wires, cardWidth, cardHeight,
154
+ width: columns.length * (cardWidth + columnGap) - columnGap + 2 * padding,
155
+ height: bodyHeight + 2 * padding + laneCount * 16 + (laneCount ? 12 : 0),
156
+ }
157
+ }
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { GraphError, canonical } from './core.mjs'
3
+ const fail = code => { throw new GraphError(code) }
4
+ export const workKey = row => `${row.rootCallId ?? row.callId}:${row.callSeq ?? row.seq}`
5
+ // Only this protocol's exact five names are non-work for count/disposition.
6
+ // UNKNOWN/PENDING tracking deliberately has no name-based exemption.
7
+ const protocolNames = new Set(['transaction_graph_read', 'transaction_graph_create', 'transaction_graph_begin', 'transaction_graph_finish', 'transaction_graph_skip'])
8
+ export const administrative = name => protocolNames.has(name)
9
+
10
+ // ToolExecution.parent/token are registry-owned identities, never call-name guesses.
11
+ export function ownedExecutionChain(exec, running) {
12
+ const rows = [...running], ancestors = new Set(), callIds = new Set()
13
+ let cursor = exec
14
+ for (let depth = 0; depth <= 32; depth++) {
15
+ const exact = rows.filter(row => row.token === cursor.token && row.callId === cursor.callId)
16
+ if (!cursor.token || exact.length !== 1 || exact[0].agent !== exec.agent || exact[0].rootCallId !== exec.rootCallId
17
+ || ancestors.has(cursor.token)) fail('OWNED_EXECUTION_ANCESTRY_UNAVAILABLE')
18
+ cursor = exact[0]; ancestors.add(cursor.token); callIds.add(cursor.callId)
19
+ if (cursor.parent === undefined) {
20
+ if (cursor.callId !== exec.rootCallId) fail('OWNED_EXECUTION_ROOT_MISMATCH')
21
+ const others = rows.filter(row => !ancestors.has(row.token))
22
+ return { ancestors, callIds, others }
23
+ }
24
+ const parent = rows.filter(row => row.token === cursor.parent)
25
+ if (parent.length !== 1) fail('OWNED_EXECUTION_PARENT_MISSING_OR_DUPLICATE')
26
+ cursor = parent[0]
27
+ }
28
+ fail('OWNED_EXECUTION_DEPTH_LIMIT')
29
+ }
30
+
31
+ export function unresolvedWork(state, activeProof, excludedCallIds = new Set()) {
32
+ if (!activeProof) return []
33
+ const acknowledged = new Set(state.acknowledgedWork.map(workKey)), result = new Map()
34
+ for (const row of state.uncertainties) {
35
+ if (!acknowledged.has(workKey(row)) && (row.rootResultSeq > activeProof.beginResultSeq || row.callSeq > activeProof.beginResultSeq))
36
+ result.set(workKey(row), { ...row, state: 'UNKNOWN' })
37
+ }
38
+ // A settled UNKNOWN child cannot hide inside the current read/finish root,
39
+ // whose own result has not yet committed. Do not wait for proof-index entry.
40
+ for (const row of state.dispatches) {
41
+ const root = state.calls[row.rootCallId]
42
+ if (root && row.outcome === 'UNKNOWN' && row.seq > activeProof.beginResultSeq) {
43
+ const work = { rootCallId: row.rootCallId, callSeq: root.seq, rootResultSeq: null, eventDigest: root.eventDigest }
44
+ if (!acknowledged.has(workKey(work))) result.set(workKey(work), { ...work, state: 'UNKNOWN' })
45
+ }
46
+ }
47
+ for (const [callId, row] of Object.entries(state.calls)) {
48
+ const work = { rootCallId: callId, callSeq: row.seq, rootResultSeq: null, eventDigest: row.eventDigest }
49
+ if (row.seq > activeProof.beginResultSeq && !excludedCallIds.has(callId) && !acknowledged.has(workKey(work)))
50
+ result.set(workKey(work), { ...work, state: 'PENDING' })
51
+ }
52
+ return [...result.values()]
53
+ }
54
+
55
+ const failedFact = fact => fact.outcome === 'FAIL' || fact.isError === true && fact.outcome === 'UNVERIFIED'
56
+ export const knownFailure = proof => failedFact({ outcome: proof.ownOutcome, isError: proof.isError })
57
+ || proof.ancestorOutcomes.some(failedFact)
58
+ export const notRunFact = proof => proof.ownOutcome === 'NOT_RUN' || proof.ancestorOutcomes.some(row => row.outcome === 'NOT_RUN')
59
+
60
+ export function incompleteDisposition({ requested, state, activeProof, evidence }) {
61
+ if (!['SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED'].includes(requested)) fail('EXPLICIT_INCOMPLETE_DISPOSITION_REQUIRED')
62
+ if (!activeProof) fail('ACTIVE_BEGIN_PROOF_REQUIRED')
63
+ const facts = evidence.filter(row => row.kind !== 'artifact')
64
+ if (facts.some(row => row.qualification === 'UNKNOWN' || row.ownOutcome === 'UNKNOWN')) fail('UNKNOWN_EVIDENCE_REQUIRES_TRUSTED_RECONCILIATION')
65
+ if (requested === 'FAILED' && !facts.some(knownFailure)) fail('FAILED_DISPOSITION_REQUIRES_KNOWN_TERMINAL_FAILURE')
66
+ if (requested === 'SKIPPED_NOT_RUN') {
67
+ const zeroWork = state.workStarted === activeProof.workStartedAtBegin
68
+ const noneExecuted = state.workExecuted === activeProof.workExecutedAtBegin
69
+ if (!noneExecuted || facts.some(knownFailure) || !zeroWork && !facts.some(notRunFact)) fail('NOT_RUN_DISPOSITION_CANNOT_HIDE_EXECUTED_WORK')
70
+ }
71
+ return requested
72
+ }
73
+
74
+ export function sameWorkSnapshot(a, b) { return canonical(a) === canonical(b) }