cli-aimlock 7.0.33 → 7.0.35
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/aimlock-chain-calls.mjs +105 -0
- package/aimlock-chain-cli.mjs +49 -0
- package/aimlock-chain-executor.mjs +233 -0
- package/aimlock-chain-human.mjs +99 -0
- package/aimlock-chain-model.mjs +187 -0
- package/aimlock-chain-outcomes.mjs +40 -0
- package/aimlock-chain-process.mjs +103 -0
- package/aimlock-chain-store.mjs +167 -0
- package/aimlock-coordination.mjs +34 -4
- package/aimlock-local-runner.mjs +61 -13
- package/aimlock-runtime.mjs +1 -1
- package/brain-client-files.mjs +88 -0
- package/brain-client.mjs +170 -0
- package/cli.mjs +8 -2
- package/package.json +17 -4
- package/skill/SKILL.md +14 -1
- package/skill/references/chain-executor.md +90 -0
- package/skill/skill.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { fail, identifier, sha256 } from './aimlock-local-fs.mjs'
|
|
2
|
+
|
|
3
|
+
export const PLAN_SCHEMA = 'aimlock.execution-plan/1.0'
|
|
4
|
+
export const STATE_SCHEMA = 'aimlock.execution-state/1.0'
|
|
5
|
+
export const TERMINAL_STEP_STATUSES = new Set(['succeeded', 'failed', 'uncertain'])
|
|
6
|
+
const STEP_KINDS = new Set(['skill', 'coordinator', 'command'])
|
|
7
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
8
|
+
const EVIDENCE_KINDS = new Set(['test', 'build', 'lint', 'security', 'benchmark'])
|
|
9
|
+
const MAX_STEPS = 256
|
|
10
|
+
const MAX_TIMEOUT_MS = 3_600_000
|
|
11
|
+
const ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
12
|
+
const RESERVED_ENVIRONMENT = /(?:TOKEN|SECRET|PRIVATE.?KEY|SIGNING|BROKER|CREDENTIAL|AUTHORIZATION|PASSWORD)|^(?:SSH_AUTH_SOCK|SSH_AGENT_PID|NODE_OPTIONS|LD_PRELOAD|DYLD_.*)$/i
|
|
13
|
+
|
|
14
|
+
export function object(value, label) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('AIMLOCK_CHAIN_INPUT_INVALID', label + ' must be an object')
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function exact(value, keys, label) {
|
|
20
|
+
object(value, label)
|
|
21
|
+
if (Object.keys(value).some((key) => !keys.includes(key)) || keys.some((key) => !Object.hasOwn(value, key))) {
|
|
22
|
+
fail('AIMLOCK_CHAIN_INPUT_INVALID', label + ' contains missing or unknown fields')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nonempty(value, label) {
|
|
27
|
+
if (typeof value !== 'string' || !value.trim()) fail('AIMLOCK_CHAIN_INPUT_INVALID', label + ' must be a non-empty string')
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pointerParts(pointer) {
|
|
32
|
+
if (pointer === '') return []
|
|
33
|
+
if (typeof pointer !== 'string' || !pointer.startsWith('/') || /~(?:[^01]|$)/.test(pointer)) {
|
|
34
|
+
fail('AIMLOCK_CHAIN_POINTER_INVALID', 'JSON Pointer must use RFC 6901 escaping')
|
|
35
|
+
}
|
|
36
|
+
const parts = pointer.slice(1).split('/').map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
37
|
+
if (parts.some((part) => FORBIDDEN_KEYS.has(part))) fail('AIMLOCK_CHAIN_POINTER_INVALID', 'unsafe property in JSON Pointer')
|
|
38
|
+
return parts
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readPointer(value, pointer) {
|
|
42
|
+
let cursor = value
|
|
43
|
+
for (const part of pointerParts(pointer)) {
|
|
44
|
+
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) {
|
|
45
|
+
fail('AIMLOCK_CHAIN_BINDING_MISSING', 'JSON Pointer has no recorded value: ' + pointer)
|
|
46
|
+
}
|
|
47
|
+
cursor = cursor[part]
|
|
48
|
+
}
|
|
49
|
+
return structuredClone(cursor)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writePointer(value, pointer, replacement) {
|
|
53
|
+
const parts = pointerParts(pointer)
|
|
54
|
+
if (!parts.length) return object(structuredClone(replacement), 'bound input')
|
|
55
|
+
let cursor = value
|
|
56
|
+
for (const part of parts.slice(0, -1)) {
|
|
57
|
+
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) {
|
|
58
|
+
fail('AIMLOCK_CHAIN_BINDING_MISSING', 'binding target parent does not exist: ' + pointer)
|
|
59
|
+
}
|
|
60
|
+
cursor = cursor[part]
|
|
61
|
+
}
|
|
62
|
+
const last = parts.at(-1)
|
|
63
|
+
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, last)) {
|
|
64
|
+
fail('AIMLOCK_CHAIN_BINDING_MISSING', 'binding target must be declared in input: ' + pointer)
|
|
65
|
+
}
|
|
66
|
+
cursor[last] = structuredClone(replacement)
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateCommand(input) {
|
|
71
|
+
exact(input, ['executable', 'args', 'workingDirectory', 'timeoutMs', 'evidenceKind', 'environment'], 'command input')
|
|
72
|
+
nonempty(input.executable, 'executable')
|
|
73
|
+
nonempty(input.workingDirectory, 'workingDirectory')
|
|
74
|
+
object(input.environment, 'command environment')
|
|
75
|
+
for (const [key, value] of Object.entries(input.environment)) {
|
|
76
|
+
if (!ENVIRONMENT_KEY.test(key) || RESERVED_ENVIRONMENT.test(key) || typeof value !== 'string' || value.includes('\u0000')) {
|
|
77
|
+
fail('AIMLOCK_CHAIN_ENVIRONMENT_INVALID', 'command environment contains an invalid or reserved credential variable')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!input.executable.includes('/') && !input.executable.includes('\\')) {
|
|
81
|
+
nonempty(input.environment.PATH, 'environment.PATH for executable lookup')
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(input.args) || input.args.some((arg) => typeof arg !== 'string')) {
|
|
84
|
+
fail('AIMLOCK_CHAIN_INPUT_INVALID', 'command args must be strings')
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > MAX_TIMEOUT_MS
|
|
87
|
+
|| !EVIDENCE_KINDS.has(input.evidenceKind)) fail('AIMLOCK_CHAIN_INPUT_INVALID', 'command limits or evidence kind are invalid')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateStep(step, seen, skills) {
|
|
91
|
+
const keys = ['stepId', 'kind', 'skillId', 'operation', 'input', 'dependsOn', 'bindings']
|
|
92
|
+
exact(step, Object.hasOwn(step, 'continueWhen') ? [...keys, 'continueWhen'] : keys, 'step')
|
|
93
|
+
if (Object.hasOwn(step, 'continueWhen')) {
|
|
94
|
+
exact(step.continueWhen, ['answer'], 'continueWhen')
|
|
95
|
+
const answer = step.continueWhen.answer
|
|
96
|
+
if ((typeof answer !== 'string' || !answer.trim())
|
|
97
|
+
&& (!Array.isArray(answer) || !answer.length || answer.some((item) => typeof item !== 'string' || !item.trim()))) {
|
|
98
|
+
fail('AIMLOCK_CHAIN_INPUT_INVALID', 'continueWhen.answer must be a non-empty structured answer')
|
|
99
|
+
}
|
|
100
|
+
if (step.kind !== 'skill' || step.skillId !== 'confirm-protocol' || step.operation !== 'interaction-request') {
|
|
101
|
+
fail('AIMLOCK_CHAIN_INPUT_INVALID', 'continueWhen applies only to an explicit Confirm interaction-request step')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
identifier(step.stepId, 'stepId')
|
|
105
|
+
if (seen.has(step.stepId) || !STEP_KINDS.has(step.kind)) fail('AIMLOCK_CHAIN_INPUT_INVALID', 'duplicate stepId or invalid kind')
|
|
106
|
+
object(step.input, 'step input')
|
|
107
|
+
if (!Array.isArray(step.dependsOn) || new Set(step.dependsOn).size !== step.dependsOn.length
|
|
108
|
+
|| step.dependsOn.some((id) => !seen.has(id))) {
|
|
109
|
+
fail('AIMLOCK_CHAIN_DEPENDENCY_INVALID', 'steps must be topologically ordered with existing unique dependencies')
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(step.bindings)) fail('AIMLOCK_CHAIN_INPUT_INVALID', 'bindings must be an array')
|
|
112
|
+
for (const binding of step.bindings) {
|
|
113
|
+
exact(binding, ['stepId', 'source', 'target'], 'binding')
|
|
114
|
+
if (!step.dependsOn.includes(binding.stepId)) fail('AIMLOCK_CHAIN_DEPENDENCY_INVALID', 'binding source must be a dependency')
|
|
115
|
+
pointerParts(binding.source)
|
|
116
|
+
pointerParts(binding.target)
|
|
117
|
+
}
|
|
118
|
+
if (new Set(step.bindings.map((binding) => binding.target)).size !== step.bindings.length) {
|
|
119
|
+
fail('AIMLOCK_CHAIN_INPUT_INVALID', 'binding targets must be unique')
|
|
120
|
+
}
|
|
121
|
+
if (step.kind === 'skill') {
|
|
122
|
+
if (!skills.has(step.skillId)) fail('AIMLOCK_CHAIN_SKILL_MISSING', 'step skill must be declared')
|
|
123
|
+
nonempty(step.operation, 'operation')
|
|
124
|
+
if (step.skillId === 'confirm-protocol' && step.operation === 'interaction-answer') {
|
|
125
|
+
fail('AIMLOCK_CHAIN_HUMAN_REQUIRED', 'interaction-answer is supplied only by the interactive answer command')
|
|
126
|
+
}
|
|
127
|
+
} else if (step.skillId !== null) fail('AIMLOCK_CHAIN_INPUT_INVALID', 'non-skill steps require skillId=null')
|
|
128
|
+
if (step.kind === 'coordinator') {
|
|
129
|
+
nonempty(step.operation, 'operation')
|
|
130
|
+
if (step.operation === 'resolve-human') fail('AIMLOCK_CHAIN_HUMAN_REQUIRED', 'resolve-human cannot be supplied in the plan')
|
|
131
|
+
}
|
|
132
|
+
if (step.kind === 'command') {
|
|
133
|
+
if (step.operation !== 'exec' || step.bindings.length) fail('AIMLOCK_CHAIN_INPUT_INVALID', 'commands use exec and cannot bind executable input')
|
|
134
|
+
validateCommand(step.input)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function validatePlan(plan) {
|
|
139
|
+
exact(plan, ['schemaVersion', 'chainId', 'skills', 'steps'], 'plan')
|
|
140
|
+
if (plan.schemaVersion !== PLAN_SCHEMA) fail('AIMLOCK_CHAIN_SCHEMA_INVALID', 'unsupported execution plan')
|
|
141
|
+
identifier(plan.chainId, 'chainId')
|
|
142
|
+
if (!Array.isArray(plan.skills) || !Array.isArray(plan.steps) || !plan.steps.length || plan.steps.length > MAX_STEPS) {
|
|
143
|
+
fail('AIMLOCK_CHAIN_INPUT_INVALID', 'skills must be an array and steps must contain 1..256 entries')
|
|
144
|
+
}
|
|
145
|
+
const skills = new Set()
|
|
146
|
+
for (const skill of plan.skills) {
|
|
147
|
+
exact(skill, ['skillId', 'packageRoot'], 'skill')
|
|
148
|
+
identifier(skill.skillId, 'skillId')
|
|
149
|
+
nonempty(skill.packageRoot, 'packageRoot')
|
|
150
|
+
if (skills.has(skill.skillId)) fail('AIMLOCK_CHAIN_INPUT_INVALID', 'duplicate skillId')
|
|
151
|
+
skills.add(skill.skillId)
|
|
152
|
+
}
|
|
153
|
+
const seen = new Set()
|
|
154
|
+
for (const step of plan.steps) { validateStep(step, seen, skills); seen.add(step.stepId) }
|
|
155
|
+
return structuredClone(plan)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function planDigest(plan) { return sha256(JSON.stringify(plan)) }
|
|
159
|
+
|
|
160
|
+
export function bindInput(step, state) {
|
|
161
|
+
let input = structuredClone(step.input)
|
|
162
|
+
for (const binding of step.bindings) {
|
|
163
|
+
const recorded = state.steps.find((item) => item.stepId === binding.stepId)
|
|
164
|
+
if (!recorded || recorded.status !== 'succeeded') fail('AIMLOCK_CHAIN_DEPENDENCY_INVALID', 'binding source has not succeeded')
|
|
165
|
+
input = writePointer(input, binding.target, readPointer(recorded.output, binding.source))
|
|
166
|
+
}
|
|
167
|
+
return input
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function chainStatus(state) {
|
|
171
|
+
if (state.steps.every((step) => step.status === 'succeeded')) return 'succeeded'
|
|
172
|
+
if (state.steps.some((step) => step.status === 'uncertain')) return 'uncertain'
|
|
173
|
+
if (state.steps.some((step) => step.status === 'failed')) return 'failed'
|
|
174
|
+
if (state.steps.some((step) => step.status === 'running')) return 'running'
|
|
175
|
+
if (state.steps.some((step) => ['blocked', 'waiting'].includes(step.status))) return 'blocked'
|
|
176
|
+
return 'ready'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function errorRecord(error) {
|
|
180
|
+
if (!(error instanceof Error)) return { name: 'ThrownValue', message: String(error) }
|
|
181
|
+
const value = { name: error.name, message: error.message }
|
|
182
|
+
for (const key of ['code', 'transportCode', 'operation', 'retryable']) {
|
|
183
|
+
if (Object.hasOwn(error, key)) value[key] = error[key]
|
|
184
|
+
}
|
|
185
|
+
if (error.cause instanceof Error) value.cause = errorRecord(error.cause)
|
|
186
|
+
return value
|
|
187
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const VALIDATOR_PENDING = new Set(['sandbox-run', 'fuzz-input', 'perf-benchmark', 'intrusive-test'])
|
|
2
|
+
const VALIDATOR_STATIC = new Set(['validate-structure', 'security-scan', 'compliance-audit'])
|
|
3
|
+
const ACCEPTED_VERDICTS = new Set(['pass', 'pass-with-risk'])
|
|
4
|
+
|
|
5
|
+
function blocked(code, message) { return { status: 'blocked', error: { code, message } } }
|
|
6
|
+
|
|
7
|
+
export function skillOutcome(step, output) {
|
|
8
|
+
if (output.status !== 'succeeded') return { status: output.status, error: null }
|
|
9
|
+
if (output.allowed === false || output.autoAccept === false || output.escalate === true
|
|
10
|
+
|| output.validation?.valid === false || (Object.hasOwn(output, 'trafficLight') && output.trafficLight !== 'green')) {
|
|
11
|
+
return blocked('AIMLOCK_CHAIN_GATE_BLOCKED', 'The protocol call succeeded but its gate did not allow continuation')
|
|
12
|
+
}
|
|
13
|
+
if (step.skillId !== 'validator') return { status: 'succeeded', error: null }
|
|
14
|
+
if (VALIDATOR_PENDING.has(step.operation)) {
|
|
15
|
+
return blocked('AIMLOCK_CHAIN_EXECUTION_PENDING', 'Validator returned an execution descriptor; no test execution is established')
|
|
16
|
+
}
|
|
17
|
+
if (step.operation === 'verdict' && (!ACCEPTED_VERDICTS.has(output.report?.verdict)
|
|
18
|
+
|| output.report.evidenceValid !== true || output.report.evidenceCount < 1
|
|
19
|
+
|| output.report.riskLedgerValid !== true)) {
|
|
20
|
+
return blocked('AIMLOCK_CHAIN_VERDICT_NOT_ACCEPTED', 'Validator did not issue a complete passing verdict with valid evidence')
|
|
21
|
+
}
|
|
22
|
+
if (step.operation === 'functional-verify' && (!Array.isArray(output.evidence) || !output.evidence.length
|
|
23
|
+
|| output.summary?.failed !== 0 || output.evidence.some((evidence) => evidence.exitCode !== 0))) {
|
|
24
|
+
return blocked('AIMLOCK_CHAIN_FUNCTIONAL_NOT_PASSED', 'Functional verification contains missing or failed execution evidence')
|
|
25
|
+
}
|
|
26
|
+
if (VALIDATOR_STATIC.has(step.operation) && output.findings.some((finding) => ['P0', 'P1'].includes(finding.severity))) {
|
|
27
|
+
return blocked('AIMLOCK_CHAIN_STATIC_FINDINGS', 'Static verification has unresolved P0/P1 findings')
|
|
28
|
+
}
|
|
29
|
+
return { status: 'succeeded', error: null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function answerMatchesContinuation(condition, answer) {
|
|
33
|
+
if (!condition) return false
|
|
34
|
+
const expected = condition.answer
|
|
35
|
+
if (Array.isArray(expected) || Array.isArray(answer)) {
|
|
36
|
+
return Array.isArray(expected) && Array.isArray(answer)
|
|
37
|
+
&& JSON.stringify([...expected].sort()) === JSON.stringify([...answer].sort())
|
|
38
|
+
}
|
|
39
|
+
return expected === answer
|
|
40
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { fail, resolvedProjectPath, sha256 } from './aimlock-local-fs.mjs'
|
|
4
|
+
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
5
|
+
|
|
6
|
+
const OUTPUT_LIMIT_BYTES = 1_048_576
|
|
7
|
+
const CLOSE_DEADLINE_MS = 1_000
|
|
8
|
+
|
|
9
|
+
function terminate(child) {
|
|
10
|
+
if (child.pid === undefined) return
|
|
11
|
+
if (process.platform === 'win32') child.kill('SIGKILL')
|
|
12
|
+
else {
|
|
13
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch (error) {
|
|
14
|
+
if (error.code !== 'ESRCH') throw error
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function commandCompletion(child, input, startedAt) {
|
|
20
|
+
let stdout = '', stderr = '', outputBytes = 0, failure = null, terminationError = null
|
|
21
|
+
let settled = false, reapTimer = null, timeout = null, resolveCompletion
|
|
22
|
+
const completed = new Promise((accept) => { resolveCompletion = accept })
|
|
23
|
+
const finish = (exitCode, signal, unreaped) => {
|
|
24
|
+
if (settled) return
|
|
25
|
+
settled = true
|
|
26
|
+
clearTimeout(timeout)
|
|
27
|
+
clearTimeout(reapTimer)
|
|
28
|
+
resolveCompletion({ stdout, stderr, exitCode, signal, error: failure, terminationError,
|
|
29
|
+
unreaped, durationMs: Date.now() - startedAt })
|
|
30
|
+
}
|
|
31
|
+
const requestTermination = () => {
|
|
32
|
+
try { terminate(child) } catch (error) { terminationError = errorRecord(error) }
|
|
33
|
+
}
|
|
34
|
+
const stop = (error) => {
|
|
35
|
+
if (settled) return
|
|
36
|
+
if (failure === null) failure = error
|
|
37
|
+
if (reapTimer === null) {
|
|
38
|
+
reapTimer = setTimeout(() => {
|
|
39
|
+
requestTermination()
|
|
40
|
+
child.stdout.destroy()
|
|
41
|
+
child.stderr.destroy()
|
|
42
|
+
child.unref()
|
|
43
|
+
failure = { code: 'AIMLOCK_CHAIN_COMMAND_UNREAPED',
|
|
44
|
+
message: 'Command cleanup could not be confirmed within the bounded close deadline',
|
|
45
|
+
cause: failure }
|
|
46
|
+
finish(null, null, true)
|
|
47
|
+
}, CLOSE_DEADLINE_MS)
|
|
48
|
+
}
|
|
49
|
+
requestTermination()
|
|
50
|
+
}
|
|
51
|
+
const capture = (stream, value) => {
|
|
52
|
+
if (settled) return
|
|
53
|
+
outputBytes += Buffer.byteLength(value)
|
|
54
|
+
if (outputBytes > OUTPUT_LIMIT_BYTES) {
|
|
55
|
+
stop({ code: 'AIMLOCK_CHAIN_OUTPUT_LIMIT', message: 'Command output exceeded the capture limit' })
|
|
56
|
+
} else if (stream === 'stdout') stdout += value
|
|
57
|
+
else stderr += value
|
|
58
|
+
}
|
|
59
|
+
child.stdout.setEncoding('utf8')
|
|
60
|
+
child.stderr.setEncoding('utf8')
|
|
61
|
+
child.stdout.on('data', (value) => capture('stdout', value))
|
|
62
|
+
child.stderr.on('data', (value) => capture('stderr', value))
|
|
63
|
+
child.once('error', (error) => stop(errorRecord(error)))
|
|
64
|
+
child.once('close', (exitCode, signal) => finish(exitCode, signal, false))
|
|
65
|
+
timeout = setTimeout(() => {
|
|
66
|
+
stop({ code: 'AIMLOCK_CHAIN_COMMAND_TIMEOUT', message: 'Command exceeded its declared timeout' })
|
|
67
|
+
}, input.timeoutMs)
|
|
68
|
+
return { completed, stop }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function executeCommand(root, input, callId, onSpawn) {
|
|
72
|
+
const workingDirectory = input.workingDirectory === '.' ? root
|
|
73
|
+
: (await resolvedProjectPath(root, input.workingDirectory)).target
|
|
74
|
+
if (workingDirectory !== root) {
|
|
75
|
+
const checked = await resolvedProjectPath(root, input.workingDirectory)
|
|
76
|
+
if (!checked.status.isDirectory()) fail('AIMLOCK_CHAIN_CWD_INVALID', 'command workingDirectory must be a directory')
|
|
77
|
+
}
|
|
78
|
+
const startedAt = Date.now()
|
|
79
|
+
const child = spawn(input.executable, input.args, { cwd: resolve(workingDirectory),
|
|
80
|
+
shell: false, env: input.environment, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
81
|
+
const completion = commandCompletion(child, input, startedAt)
|
|
82
|
+
if (child.pid !== undefined) {
|
|
83
|
+
try { await onSpawn(child.pid) } catch (error) {
|
|
84
|
+
completion.stop(errorRecord(error))
|
|
85
|
+
await completion.completed
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const output = await completion.completed
|
|
90
|
+
const command = [input.executable, ...input.args].map((part) => JSON.stringify(part)).join(' ')
|
|
91
|
+
const summary = output.error ? output.error.message
|
|
92
|
+
: output.signal ? 'Process terminated by ' + output.signal : 'Process exited with code ' + output.exitCode
|
|
93
|
+
const evidence = { schemaVersion: 'cli.tax.test-evidence/1.0', evidenceId: callId,
|
|
94
|
+
kind: input.evidenceKind, runner: 'local', producer: 'local-cli-process', command,
|
|
95
|
+
exitCode: output.exitCode, durationMs: output.durationMs, summary,
|
|
96
|
+
stdoutSha256: sha256(output.stdout), stderrSha256: sha256(output.stderr),
|
|
97
|
+
sandboxed: false, independentRunnerVerified: false }
|
|
98
|
+
if (output.exitCode === null) {
|
|
99
|
+
return { status: output.unreaped ? 'uncertain' : 'failed', ...output, evidence: [], termination: summary }
|
|
100
|
+
}
|
|
101
|
+
return { status: !output.error && output.exitCode === 0 ? 'succeeded' : 'failed',
|
|
102
|
+
...output, evidence: [evidence] }
|
|
103
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rmdir, unlink } from 'node:fs/promises'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
+
import { atomicJson, ensureManagedDirectory, fail, identifier, repositoryRoot, resolvedProjectPath } from './aimlock-local-fs.mjs'
|
|
6
|
+
import { STATE_SCHEMA, chainStatus, planDigest, validatePlan } from './aimlock-chain-model.mjs'
|
|
7
|
+
|
|
8
|
+
const LOCK_WAIT_MS = 2_000
|
|
9
|
+
const LOCK_POLL_MS = 20
|
|
10
|
+
const RECORD_STATUSES = new Set(['pending', 'running', 'waiting', 'blocked', 'failed', 'uncertain', 'succeeded'])
|
|
11
|
+
|
|
12
|
+
function alive(pid) {
|
|
13
|
+
if (!Number.isSafeInteger(pid) || pid < 1) fail('AIMLOCK_CHAIN_LOCK_INVALID', 'execution lock has no valid process owner')
|
|
14
|
+
try { process.kill(pid, 0); return true } catch (error) {
|
|
15
|
+
if (error.code === 'ESRCH') return false
|
|
16
|
+
if (error.code === 'EPERM') return true
|
|
17
|
+
throw error
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function ownerOf(directory) {
|
|
22
|
+
try {
|
|
23
|
+
const file = resolve(directory, 'owner.json')
|
|
24
|
+
const status = await lstat(file)
|
|
25
|
+
if (status.isSymbolicLink() || !status.isFile()) fail('AIMLOCK_CHAIN_LOCK_INVALID', 'execution lock owner is not a regular file')
|
|
26
|
+
const owner = JSON.parse(await readFile(file, 'utf8'))
|
|
27
|
+
identifier(owner.lockId, 'execution lockId')
|
|
28
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) fail('AIMLOCK_CHAIN_LOCK_INVALID', 'execution lock owner pid is invalid')
|
|
29
|
+
return owner
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') return null
|
|
32
|
+
throw error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function retireLock(directory, retiredDirectory, owner) {
|
|
37
|
+
const retired = resolve(retiredDirectory, identifier(owner.lockId, 'execution lockId'))
|
|
38
|
+
try { await rename(directory, retired) } catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT') return
|
|
40
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error
|
|
41
|
+
const previous = await ownerOf(retired)
|
|
42
|
+
if (!previous || previous.lockId !== owner.lockId || previous.pid !== owner.pid) {
|
|
43
|
+
fail('AIMLOCK_CHAIN_LOCK_INVALID', 'retired lock identity does not match')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Keep this non-empty retirement record: a stale contender cannot move a newer active lock over it.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function inspectLock(directory) {
|
|
50
|
+
try {
|
|
51
|
+
const status = await lstat(directory)
|
|
52
|
+
if (status.isSymbolicLink() || !status.isDirectory()) fail('AIMLOCK_CHAIN_LOCK_INVALID', 'execution lock is not a real directory')
|
|
53
|
+
return ownerOf(directory)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') return null
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function acquire(directory, retiredDirectory) {
|
|
61
|
+
const owner = { lockId: randomUUID(), pid: process.pid, createdAt: new Date().toISOString() }
|
|
62
|
+
const candidate = resolve(dirname(directory), 'candidate-' + owner.lockId)
|
|
63
|
+
await mkdir(candidate, { mode: 0o700 })
|
|
64
|
+
await atomicJson(resolve(candidate, 'owner.json'), owner)
|
|
65
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
66
|
+
let published = false
|
|
67
|
+
try {
|
|
68
|
+
while (true) {
|
|
69
|
+
try {
|
|
70
|
+
// Publishing the populated directory is atomic; an interrupted preparation never occupies lock.
|
|
71
|
+
await rename(candidate, directory)
|
|
72
|
+
published = true
|
|
73
|
+
return owner
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error
|
|
76
|
+
}
|
|
77
|
+
const current = await inspectLock(directory)
|
|
78
|
+
if (current && !alive(current.pid)) { await retireLock(directory, retiredDirectory, current); continue }
|
|
79
|
+
if (Date.now() >= deadline) fail('AIMLOCK_CHAIN_BUSY', 'another process owns this execution or its lock is invalid')
|
|
80
|
+
await delay(LOCK_POLL_MS)
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
if (!published) {
|
|
84
|
+
await unlink(resolve(candidate, 'owner.json'))
|
|
85
|
+
await rmdir(candidate)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function withExecutionLock(rootValue, chainId, action) {
|
|
91
|
+
const root = await repositoryRoot(rootValue)
|
|
92
|
+
identifier(chainId, 'chainId')
|
|
93
|
+
const directory = await ensureManagedDirectory(root, 'executions', chainId)
|
|
94
|
+
const retiredDirectory = await ensureManagedDirectory(root, 'executions', chainId, 'retired')
|
|
95
|
+
const lockDirectory = resolve(directory, 'lock')
|
|
96
|
+
const owner = await acquire(lockDirectory, retiredDirectory)
|
|
97
|
+
try { return await action(root, resolve(directory, 'state.json')) } finally {
|
|
98
|
+
const current = await ownerOf(lockDirectory)
|
|
99
|
+
if (!current || current.lockId !== owner.lockId) fail('AIMLOCK_CHAIN_LOCK_INVALID', 'execution lock ownership changed')
|
|
100
|
+
await retireLock(lockDirectory, retiredDirectory, owner)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function initialState(plan, contexts) {
|
|
105
|
+
const validated = validatePlan(plan)
|
|
106
|
+
const now = new Date().toISOString()
|
|
107
|
+
return { schemaVersion: STATE_SCHEMA, chainId: plan.chainId, plan: validated, planDigest: planDigest(validated),
|
|
108
|
+
contexts, createdAt: now, updatedAt: now, status: 'ready',
|
|
109
|
+
steps: plan.steps.map(({ stepId }) => ({ stepId, status: 'pending', input: null, output: null,
|
|
110
|
+
pending: null, calls: [], error: null, startedAt: null, completedAt: null, attemptId: null })) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function saveExecution(file, state) {
|
|
114
|
+
state.updatedAt = new Date().toISOString()
|
|
115
|
+
state.status = chainStatus(state)
|
|
116
|
+
await atomicJson(file, state)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function loadExecution(file) {
|
|
120
|
+
const status = await lstat(file)
|
|
121
|
+
if (!status.isFile() || status.isSymbolicLink()) fail('AIMLOCK_CHAIN_STATE_INVALID', 'execution state must be a regular file')
|
|
122
|
+
const state = JSON.parse(await readFile(file, 'utf8'))
|
|
123
|
+
const plan = validatePlan(state.plan)
|
|
124
|
+
if (state.schemaVersion !== STATE_SCHEMA || state.chainId !== plan.chainId || state.planDigest !== planDigest(plan)
|
|
125
|
+
|| !Array.isArray(state.steps) || state.steps.length !== plan.steps.length
|
|
126
|
+
|| state.steps.some((step, index) => step.stepId !== plan.steps[index].stepId || !RECORD_STATUSES.has(step.status)
|
|
127
|
+
|| !Array.isArray(step.calls))) fail('AIMLOCK_CHAIN_STATE_INVALID', 'execution state does not match its immutable plan')
|
|
128
|
+
state.status = chainStatus(state)
|
|
129
|
+
return state
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function executionStatus(rootValue, chainId) {
|
|
133
|
+
const root = await repositoryRoot(rootValue)
|
|
134
|
+
identifier(chainId, 'chainId')
|
|
135
|
+
const file = await resolvedProjectPath(root, '.aimlock/executions/' + chainId + '/state.json')
|
|
136
|
+
return loadExecution(file.target)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function assertNewExecution(file) {
|
|
140
|
+
try { await lstat(file) } catch (error) {
|
|
141
|
+
if (error.code === 'ENOENT') return
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
fail('AIMLOCK_CHAIN_EXISTS', 'execution already exists; use resume or a new chainId')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function recoverInterrupted(state) {
|
|
148
|
+
for (const step of state.steps) {
|
|
149
|
+
const lastCall = step.calls.at(-1)
|
|
150
|
+
if (step.status === 'waiting' && step.pending?.kind === 'coordinator-wait'
|
|
151
|
+
&& lastCall?.kind === 'coordinator' && lastCall.operation === 'wait-for-event'
|
|
152
|
+
&& lastCall.status === 'started') {
|
|
153
|
+
lastCall.status = 'interrupted-read'
|
|
154
|
+
lastCall.completedAt = new Date().toISOString()
|
|
155
|
+
}
|
|
156
|
+
if (step.status !== 'running') continue
|
|
157
|
+
if (step.pending?.kind === 'coordinator-wait'
|
|
158
|
+
&& step.calls.at(-1)?.kind === 'coordinator' && step.calls.at(-1)?.operation === 'wait-for-event') {
|
|
159
|
+
step.calls.at(-1).status = 'interrupted-read'
|
|
160
|
+
step.status = 'waiting'
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
step.status = 'uncertain'
|
|
164
|
+
step.error = { code: 'AIMLOCK_CHAIN_INTERRUPTED', message: 'The previous process stopped during an operation. Effects are uncertain; automatic replay is forbidden.' }
|
|
165
|
+
step.completedAt = new Date().toISOString()
|
|
166
|
+
}
|
|
167
|
+
}
|
package/aimlock-coordination.mjs
CHANGED
|
@@ -25,6 +25,37 @@ async function regularFile(path, code, message) {
|
|
|
25
25
|
return path
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function failureHandled(task, tasks, inspected = new Set()) {
|
|
29
|
+
if (inspected.has(task.taskId)) return false
|
|
30
|
+
inspected.add(task.taskId)
|
|
31
|
+
const replacement = tasks.find((item) => item.supersedesTaskId === task.taskId)
|
|
32
|
+
return replacement && (['active', 'completed'].includes(replacement.status)
|
|
33
|
+
|| (['failed', 'reclaimed'].includes(replacement.status) && failureHandled(replacement, tasks, inspected)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parkedAfterDecision(task, tasks, decisions) {
|
|
37
|
+
if (task.status !== 'blocked' || task.blockedReason !== 'human-decision') return false
|
|
38
|
+
const decision = decisions.findLast((item) => item.agents.includes(task.agentId))
|
|
39
|
+
return decision?.status === 'resolved'
|
|
40
|
+
&& tasks.some((active) => active.status === 'active' && decision.answer === 'resume:' + active.agentId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertChainRunnable(state, chainId) {
|
|
44
|
+
const tasks = state.tasks.filter((task) => task.chainId === chainId)
|
|
45
|
+
if (!tasks.length) return
|
|
46
|
+
const wait = state.waits.find((item) => item.chainId === chainId && item.status === 'active')
|
|
47
|
+
if (wait) fail('AIMLOCK_COORDINATION_WAITING', 'chain ' + chainId + ' is suspended until ' + wait.event + ' or ' + wait.deadlineAt)
|
|
48
|
+
const pending = state.decisions.some((decision) => decision.status === 'pending'
|
|
49
|
+
&& tasks.some((task) => decision.agents.includes(task.agentId)))
|
|
50
|
+
const unresolved = tasks.some((task) => ['failed', 'reclaimed'].includes(task.status)
|
|
51
|
+
&& !failureHandled(task, tasks))
|
|
52
|
+
if (!tasks.some((task) => task.status === 'active') || pending || unresolved
|
|
53
|
+
|| tasks.some((task) => task.status === 'waiting'
|
|
54
|
+
|| (task.status === 'blocked' && !parkedAfterDecision(task, tasks, state.decisions)))) {
|
|
55
|
+
fail('AIMLOCK_COORDINATION_BLOCKED', 'chain has no active task or has unresolved failures, waits, or human decisions')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
async function verifyLeaseAgainstState(state, root, input) {
|
|
29
60
|
const leasePath = safeRelativePath(input.coordinationLeasePath, 'coordinationLeasePath')
|
|
30
61
|
if (!leasePath.startsWith('.coord/leases/')) {
|
|
@@ -37,6 +68,7 @@ async function verifyLeaseAgainstState(state, root, input) {
|
|
|
37
68
|
await regularFile(publicPath, 'AIMLOCK_COORDINATION_AUTHORITY_INVALID', 'coordination public key must be a regular file')
|
|
38
69
|
const publicKey = await readFile(publicPath, 'utf8')
|
|
39
70
|
const chainId = identifier(input.chainId, 'chainId')
|
|
71
|
+
assertChainRunnable(state, chainId)
|
|
40
72
|
const targetPaths = input.targetPaths.map((path) => safeRelativePath(path, 'targetPath'))
|
|
41
73
|
const signatureValid = typeof lease.signature === 'string'
|
|
42
74
|
&& verify(null, Buffer.from(JSON.stringify(leasePayload(lease))), publicKey,
|
|
@@ -55,6 +87,7 @@ async function verifyLeaseAgainstState(state, root, input) {
|
|
|
55
87
|
&& issuedAt <= Date.now() && expiresAt > Date.now()
|
|
56
88
|
&& signatureValid
|
|
57
89
|
&& lock?.status === 'active' && lock.leaseId === lease.leaseId
|
|
90
|
+
&& state.tasks.some((task) => task.taskId === lock.taskId && task.status === 'active')
|
|
58
91
|
&& lock.chainId === lease.chainId && lock.agentId === lease.agentId
|
|
59
92
|
&& lock.expiresAt === lease.expiresAt
|
|
60
93
|
if (!valid) {
|
|
@@ -82,10 +115,7 @@ async function assertChainNotSuspended(input) {
|
|
|
82
115
|
if (!statePath.exists) return { suspended: false }
|
|
83
116
|
const chainId = identifier(input.chainId, 'chainId')
|
|
84
117
|
return withCoordinationReadLock(root, async (state) => {
|
|
85
|
-
|
|
86
|
-
if (wait) {
|
|
87
|
-
fail('AIMLOCK_COORDINATION_WAITING', `chain ${chainId} is suspended until ${wait.event} or ${wait.deadlineAt}`)
|
|
88
|
-
}
|
|
118
|
+
assertChainRunnable(state, chainId)
|
|
89
119
|
return { suspended: false }
|
|
90
120
|
})
|
|
91
121
|
}
|