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/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
|
+
}
|
package/work-policy.mjs
ADDED
|
@@ -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) }
|