cli-aimlock 7.0.32 → 7.0.34

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.
@@ -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
+ }
@@ -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
- const wait = state.waits.find((item) => item.chainId === chainId && item.status === 'active')
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
  }