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.
- 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/broker.mjs +37 -3
- package/cli.mjs +5 -2
- package/installer.mjs +15 -5
- package/package.json +14 -4
- package/skill/SKILL.md +5 -1
- package/skill/references/chain-executor.md +90 -0
- package/skill/skill.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { executeCoordinatorOperation } from 'cli-swarm/coordinator'
|
|
4
|
+
import { loadOfficialSkillContext } from './installer.mjs'
|
|
5
|
+
import { invokeOfficialSkill } from './broker.mjs'
|
|
6
|
+
import { fail, sha256 } from './aimlock-local-fs.mjs'
|
|
7
|
+
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
8
|
+
import { saveExecution } from './aimlock-chain-store.mjs'
|
|
9
|
+
import { executeCommand } from './aimlock-chain-process.mjs'
|
|
10
|
+
|
|
11
|
+
export function resolveContexts(root, plan) {
|
|
12
|
+
return plan.skills.map((skill) => {
|
|
13
|
+
const context = loadOfficialSkillContext(resolve(root, skill.packageRoot))
|
|
14
|
+
if (context.skillName !== skill.skillId) fail('AIMLOCK_CHAIN_SKILL_INVALID', 'installed skill does not match plan skillId')
|
|
15
|
+
return { skillId: skill.skillId, context }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function verifyContexts(root, state) {
|
|
20
|
+
if (JSON.stringify(resolveContexts(root, state.plan)) !== JSON.stringify(state.contexts)) {
|
|
21
|
+
fail('AIMLOCK_CHAIN_SKILL_CHANGED', 'installed package metadata changed after execution initialization')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function skillContext(state, skillId) {
|
|
26
|
+
const found = state.contexts.find((entry) => entry.skillId === skillId)
|
|
27
|
+
if (!found) fail('AIMLOCK_CHAIN_SKILL_MISSING', 'execution needs an explicitly declared ' + skillId + ' package')
|
|
28
|
+
return found.context
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function startCall(session, kind, operation, input) {
|
|
32
|
+
const call = { callId: 'call-' + randomUUID(), kind, operation, inputDigest: sha256(JSON.stringify(input)),
|
|
33
|
+
requestId: null, status: 'started', pid: null, receipt: null, error: null,
|
|
34
|
+
startedAt: new Date().toISOString(), completedAt: null }
|
|
35
|
+
session.record.calls.push(call)
|
|
36
|
+
await saveExecution(session.file, session.state)
|
|
37
|
+
return call
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function failCall(session, call, error) {
|
|
41
|
+
call.status = call.requestId || call.pid !== null ? 'uncertain' : 'failed'
|
|
42
|
+
call.error = errorRecord(error)
|
|
43
|
+
call.completedAt = new Date().toISOString()
|
|
44
|
+
await saveExecution(session.file, session.state)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function callSkill(session, skillId, operation, input) {
|
|
48
|
+
const context = skillContext(session.state, skillId)
|
|
49
|
+
const call = await startCall(session, 'skill', operation, input)
|
|
50
|
+
try {
|
|
51
|
+
const dependencies = session.dependencies
|
|
52
|
+
const invocation = await invokeOfficialSkill(context, operation, input, {
|
|
53
|
+
environment: dependencies.environment, credentialAccess: dependencies.credentialAccess,
|
|
54
|
+
request: async (url, options) => {
|
|
55
|
+
const request = JSON.parse(options.body).input
|
|
56
|
+
call.requestId = request.requestId
|
|
57
|
+
call.status = 'dispatched'
|
|
58
|
+
await saveExecution(session.file, session.state)
|
|
59
|
+
return dependencies.request(url, options)
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
call.receipt = invocation
|
|
63
|
+
call.status = 'recorded'
|
|
64
|
+
call.completedAt = new Date().toISOString()
|
|
65
|
+
await saveExecution(session.file, session.state)
|
|
66
|
+
return invocation.response.output
|
|
67
|
+
} catch (error) {
|
|
68
|
+
await failCall(session, call, error)
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function callCoordinator(session, operation, input) {
|
|
74
|
+
const call = await startCall(session, 'coordinator', operation, input)
|
|
75
|
+
try {
|
|
76
|
+
const output = await executeCoordinatorOperation(operation, session.root, input)
|
|
77
|
+
call.status = 'recorded'
|
|
78
|
+
call.receipt = output
|
|
79
|
+
call.completedAt = new Date().toISOString()
|
|
80
|
+
await saveExecution(session.file, session.state)
|
|
81
|
+
return output
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await failCall(session, call, error)
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function callCommand(session, input) {
|
|
89
|
+
const call = await startCall(session, 'command', 'exec', input)
|
|
90
|
+
try {
|
|
91
|
+
const output = await executeCommand(session.root, input, call.callId, async (pid) => {
|
|
92
|
+
call.pid = pid
|
|
93
|
+
call.status = 'dispatched'
|
|
94
|
+
await saveExecution(session.file, session.state)
|
|
95
|
+
})
|
|
96
|
+
call.status = output.status === 'uncertain' ? 'uncertain' : 'recorded'
|
|
97
|
+
call.receipt = output
|
|
98
|
+
call.completedAt = new Date().toISOString()
|
|
99
|
+
await saveExecution(session.file, session.state)
|
|
100
|
+
return output
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await failCall(session, call, error)
|
|
103
|
+
throw error
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { stdin, stdout } from 'node:process'
|
|
2
|
+
import { answerExecution, executionStatus, initializeExecution, resumeExecution } from './aimlock-chain-executor.mjs'
|
|
3
|
+
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
4
|
+
import { fail } from './aimlock-local-fs.mjs'
|
|
5
|
+
|
|
6
|
+
const PLAN_MAX_BYTES = 1_048_576
|
|
7
|
+
export const CHAIN_USAGE = [
|
|
8
|
+
' cli-aimlock chain init <repositoryRoot> < execution-plan.json',
|
|
9
|
+
' cli-aimlock chain resume <repositoryRoot> <chainId>',
|
|
10
|
+
' cli-aimlock chain status <repositoryRoot> <chainId>',
|
|
11
|
+
' cli-aimlock chain answer <repositoryRoot> <chainId> <actorId>',
|
|
12
|
+
' Persist explicit steps; resume invokes real broker/coordinator/process operations.',
|
|
13
|
+
' answer requires a live terminal. Recorded results and nextStep never imply execution.',
|
|
14
|
+
].join('\n')
|
|
15
|
+
|
|
16
|
+
async function readPlan(input) {
|
|
17
|
+
let source = ''
|
|
18
|
+
for await (const chunk of input) {
|
|
19
|
+
source += chunk
|
|
20
|
+
if (Buffer.byteLength(source) > PLAN_MAX_BYTES) fail('AIMLOCK_CHAIN_INPUT_TOO_LARGE', 'execution plan exceeds 1 MiB')
|
|
21
|
+
}
|
|
22
|
+
if (!source.trim()) fail('AIMLOCK_CHAIN_INPUT_REQUIRED', 'execution plan is required on stdin')
|
|
23
|
+
return JSON.parse(source)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function dispatchChain(args) {
|
|
27
|
+
const [operation, repositoryRoot, chainId, actorId] = args
|
|
28
|
+
const expected = { init: 2, status: 3, resume: 3, answer: 4 }
|
|
29
|
+
if (!Object.hasOwn(expected, operation) || args.length !== expected[operation]) {
|
|
30
|
+
fail('AIMLOCK_CHAIN_USAGE_INVALID', CHAIN_USAGE)
|
|
31
|
+
}
|
|
32
|
+
const dependencies = { environment: process.env, request: fetch }
|
|
33
|
+
if (operation === 'init') return initializeExecution(repositoryRoot, await readPlan(stdin))
|
|
34
|
+
if (operation === 'status') return executionStatus(repositoryRoot, chainId)
|
|
35
|
+
if (operation === 'resume') return resumeExecution(repositoryRoot, chainId, dependencies)
|
|
36
|
+
return answerExecution(repositoryRoot, chainId, { input: stdin, output: stdout, actorId }, dependencies)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runChainCli(args) {
|
|
40
|
+
try {
|
|
41
|
+
const state = await dispatchChain(args)
|
|
42
|
+
stdout.write(JSON.stringify(state) + '\n')
|
|
43
|
+
if (state.status === 'blocked') process.exitCode = 2
|
|
44
|
+
else if (state.status === 'failed' || state.status === 'uncertain') process.exitCode = 1
|
|
45
|
+
} catch (error) {
|
|
46
|
+
stdout.write(JSON.stringify({ status: 'failed', error: errorRecord(error) }) + '\n')
|
|
47
|
+
process.exitCode = 1
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { assertChainNotSuspended } from './aimlock-coordination.mjs'
|
|
3
|
+
import { fail } from './aimlock-local-fs.mjs'
|
|
4
|
+
import { bindInput, errorRecord, validatePlan } from './aimlock-chain-model.mjs'
|
|
5
|
+
import { assertNewExecution, executionStatus, initialState, loadExecution, recoverInterrupted,
|
|
6
|
+
saveExecution, withExecutionLock } from './aimlock-chain-store.mjs'
|
|
7
|
+
import { callCommand, callCoordinator, callSkill, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
|
|
8
|
+
import { answerPending, prepareHuman } from './aimlock-chain-human.mjs'
|
|
9
|
+
import { skillOutcome } from './aimlock-chain-outcomes.mjs'
|
|
10
|
+
|
|
11
|
+
const COORDINATION_CODES = new Set(['AIMLOCK_COORDINATION_WAITING', 'AIMLOCK_COORDINATION_BLOCKED'])
|
|
12
|
+
const BLOCKED_LOCAL_STATUSES = new Set(['blocked', 'queued', 'denied'])
|
|
13
|
+
const FAILED_LOCAL_STATUSES = new Set(['failed', 'reclaimed'])
|
|
14
|
+
const QUEUE_BLOCK_CODES = new Set(['SWARM_COORD_QUEUE_GRANT_INVALID', 'SWARM_COORD_QUEUE_GRANT_EXPIRED',
|
|
15
|
+
'SWARM_COORD_QUEUE_NOT_FOUND', 'SWARM_COORD_QUEUE_STATE_INVALID', 'SWARM_COORD_TASK_BLOCKED',
|
|
16
|
+
'SWARM_COORD_BASELINE_HANDSHAKE_REQUIRED'])
|
|
17
|
+
|
|
18
|
+
export async function initializeExecution(repositoryRoot, planInput) {
|
|
19
|
+
const plan = validatePlan(planInput)
|
|
20
|
+
return withExecutionLock(repositoryRoot, plan.chainId, async (root, file) => {
|
|
21
|
+
await assertNewExecution(file)
|
|
22
|
+
const state = initialState(plan, resolveContexts(root, plan))
|
|
23
|
+
await saveExecution(file, state)
|
|
24
|
+
return state
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function failedStep(session, error) {
|
|
29
|
+
const { record, state, file } = session
|
|
30
|
+
record.error = errorRecord(error)
|
|
31
|
+
record.status = record.calls.at(-1)?.status === 'uncertain' ? 'uncertain' : 'failed'
|
|
32
|
+
record.completedAt = new Date().toISOString()
|
|
33
|
+
await saveExecution(file, state)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readyForWork(session, step) {
|
|
37
|
+
if (step.kind === 'coordinator' || step.skillId === 'confirm-protocol') return true
|
|
38
|
+
try {
|
|
39
|
+
await assertChainNotSuspended({ repositoryRoot: session.root, chainId: session.state.chainId })
|
|
40
|
+
return true
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!COORDINATION_CODES.has(error.code)) throw error
|
|
43
|
+
session.record.status = 'blocked'
|
|
44
|
+
session.record.error = errorRecord(error)
|
|
45
|
+
session.record.pending = { kind: 'coordination-guard' }
|
|
46
|
+
await saveExecution(session.file, session.state)
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function finish(record, output, status) {
|
|
52
|
+
record.output = output
|
|
53
|
+
record.status = status
|
|
54
|
+
record.pending = null
|
|
55
|
+
record.completedAt = new Date().toISOString()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function stillWaiting(session, output, pending) {
|
|
59
|
+
if (output.status === 'waiting') return true
|
|
60
|
+
const releasedByHuman = pending.decision?.status === 'resolved'
|
|
61
|
+
&& pending.decision.answer === 'resume:' + pending.input.agentId
|
|
62
|
+
if (output.status !== 'blocked' || (output.wakePackage?.reason !== 'event-received' && !releasedByHuman)) return false
|
|
63
|
+
const current = (await callCoordinator(session, 'status', {})).state
|
|
64
|
+
const task = current.tasks.find((item) => item.taskId === pending.input.taskId)
|
|
65
|
+
if (!task || ['completed', 'failed', 'reclaimed'].includes(task.status)) return false
|
|
66
|
+
return current.waits.some((wait) => wait.taskId === task.taskId && wait.status === 'active')
|
|
67
|
+
|| current.decisions.some((decision) => decision.status === 'pending' && decision.agents.includes(task.agentId))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function driveWait(session) {
|
|
71
|
+
const pending = session.record.pending
|
|
72
|
+
const output = await callCoordinator(session, 'wait-for-event', pending.input)
|
|
73
|
+
session.record.output = output
|
|
74
|
+
if (await stillWaiting(session, output, pending)) {
|
|
75
|
+
session.record.status = 'waiting'
|
|
76
|
+
await saveExecution(session.file, session.state)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (output.confirmProtocolRequests.length) {
|
|
80
|
+
await prepareHuman(session, output.confirmProtocolRequests[0].input.interaction, pending.input)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const releasedByHuman = pending.decision?.status === 'resolved'
|
|
84
|
+
&& pending.decision.answer === 'resume:' + pending.input.agentId
|
|
85
|
+
&& output.status === 'resolved'
|
|
86
|
+
if (releasedByHuman) {
|
|
87
|
+
finish(session.record, { ...output, status: 'resolved-by-human',
|
|
88
|
+
humanDecision: pending.decision, confirmation: pending.confirmation }, 'succeeded')
|
|
89
|
+
} else if (output.status === 'resolved' && output.wakePackage.reason === 'event-received') {
|
|
90
|
+
finish(session.record, output, 'succeeded')
|
|
91
|
+
} else {
|
|
92
|
+
finish(session.record, output, 'blocked')
|
|
93
|
+
session.record.error = { code: 'AIMLOCK_CHAIN_DEPENDENCY_UNFULFILLED',
|
|
94
|
+
message: 'Dependency ended without its event or a human decision releasing this task' }
|
|
95
|
+
}
|
|
96
|
+
await saveExecution(session.file, session.state)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function saveQueuePending(record, output, input) {
|
|
100
|
+
record.pending = { kind: 'coordinator-lock-queue', input: { queueId: output.queued.queueId,
|
|
101
|
+
taskId: input.taskId, agentId: input.agentId, chainId: input.chainId } }
|
|
102
|
+
record.status = 'waiting'
|
|
103
|
+
record.completedAt = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function driveQueue(session) {
|
|
107
|
+
await callCoordinator(session, 'tick', { now: new Date().toISOString() })
|
|
108
|
+
try {
|
|
109
|
+
const output = await callCoordinator(session, 'lock-queue-status', session.record.pending.input)
|
|
110
|
+
session.record.output = output
|
|
111
|
+
if (output.status === 'queued') session.record.status = 'waiting'
|
|
112
|
+
else finish(session.record, output, output.status === 'granted' ? 'succeeded' : 'blocked')
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (!QUEUE_BLOCK_CODES.has(error.code)) throw error
|
|
115
|
+
session.record.error = errorRecord(error)
|
|
116
|
+
finish(session.record, { status: 'blocked', queueId: session.record.pending.input.queueId,
|
|
117
|
+
error: session.record.error }, 'blocked')
|
|
118
|
+
}
|
|
119
|
+
await saveExecution(session.file, session.state)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function coordinatorStep(session, step, input) {
|
|
123
|
+
const output = await callCoordinator(session, step.operation, input)
|
|
124
|
+
session.record.output = output
|
|
125
|
+
if (step.operation === 'lock-acquire' && output.status === 'queued') {
|
|
126
|
+
saveQueuePending(session.record, output, input)
|
|
127
|
+
} else if (step.operation === 'dependency-wait') {
|
|
128
|
+
const waitInput = { taskId: input.taskId, agentId: input.waiter, chainId: input.chainId, waitId: output.wait.waitId }
|
|
129
|
+
session.record.pending = { kind: 'coordinator-wait', input: waitInput }
|
|
130
|
+
session.record.status = 'waiting'
|
|
131
|
+
await saveExecution(session.file, session.state)
|
|
132
|
+
await driveWait(session)
|
|
133
|
+
} else if (step.operation === 'wait-for-event') {
|
|
134
|
+
session.record.pending = { kind: 'coordinator-wait', input }
|
|
135
|
+
session.record.status = 'waiting'
|
|
136
|
+
await saveExecution(session.file, session.state)
|
|
137
|
+
if (await stillWaiting(session, output, session.record.pending)) {
|
|
138
|
+
session.record.status = 'waiting'
|
|
139
|
+
} else if (output.confirmProtocolRequests.length) {
|
|
140
|
+
await prepareHuman(session, output.confirmProtocolRequests[0].input.interaction, input)
|
|
141
|
+
} else if (output.status === 'resolved' && output.wakePackage.reason === 'event-received') {
|
|
142
|
+
finish(session.record, output, 'succeeded')
|
|
143
|
+
} else finish(session.record, output, 'blocked')
|
|
144
|
+
} else {
|
|
145
|
+
const status = BLOCKED_LOCAL_STATUSES.has(output.status) || output.allowed === false
|
|
146
|
+
|| output.undeclaredWait === true || output.decisions?.some((item) => item.confirmationRequired)
|
|
147
|
+
? 'blocked' : FAILED_LOCAL_STATUSES.has(output.status) ? 'failed' : 'succeeded'
|
|
148
|
+
finish(session.record, output, status)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function executeStep(session, step) {
|
|
153
|
+
if (!await readyForWork(session, step)) return
|
|
154
|
+
const input = bindInput(step, session.state)
|
|
155
|
+
session.record.input = input
|
|
156
|
+
session.record.status = 'running'
|
|
157
|
+
session.record.error = null
|
|
158
|
+
session.record.startedAt = new Date().toISOString()
|
|
159
|
+
session.record.attemptId = 'attempt-' + randomUUID()
|
|
160
|
+
await saveExecution(session.file, session.state)
|
|
161
|
+
if (step.kind === 'command') {
|
|
162
|
+
const output = await callCommand(session, input)
|
|
163
|
+
finish(session.record, output, output.status)
|
|
164
|
+
} else if (step.kind === 'coordinator') await coordinatorStep(session, step, input)
|
|
165
|
+
else {
|
|
166
|
+
const output = await callSkill(session, step.skillId, step.operation, input)
|
|
167
|
+
if (step.skillId === 'confirm-protocol' && step.operation === 'interaction-request' && output.status === 'succeeded') {
|
|
168
|
+
session.record.status = 'waiting'
|
|
169
|
+
session.record.output = output
|
|
170
|
+
session.record.pending = { kind: 'human', interaction: output.interaction, waitInput: null,
|
|
171
|
+
presentation: output.chatFallback, response: output,
|
|
172
|
+
continueWhen: Object.hasOwn(step, 'continueWhen') ? step.continueWhen : null }
|
|
173
|
+
} else {
|
|
174
|
+
const outcome = skillOutcome(step, output)
|
|
175
|
+
finish(session.record, output, outcome.status)
|
|
176
|
+
session.record.error = outcome.error
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await saveExecution(session.file, session.state)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function advance(root, file, state, dependencies) {
|
|
183
|
+
for (const step of state.plan.steps) {
|
|
184
|
+
const record = state.steps.find((item) => item.stepId === step.stepId)
|
|
185
|
+
if (record.status === 'succeeded') continue
|
|
186
|
+
if (record.status === 'blocked' && step.kind === 'coordinator' && step.operation === 'lock-acquire'
|
|
187
|
+
&& record.output?.status === 'queued' && record.input) saveQueuePending(record, record.output, record.input)
|
|
188
|
+
const session = { root, file, state, record, dependencies }
|
|
189
|
+
if (['failed', 'uncertain'].includes(record.status)) return state
|
|
190
|
+
if (record.status === 'waiting' && record.pending?.kind === 'human') return state
|
|
191
|
+
if (record.status === 'blocked' && record.pending?.kind !== 'coordination-guard') return state
|
|
192
|
+
if (step.dependsOn.some((id) => state.steps.find((item) => item.stepId === id).status !== 'succeeded')) {
|
|
193
|
+
fail('AIMLOCK_CHAIN_DEPENDENCY_INVALID', 'previous dependency has not succeeded')
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
if (record.status === 'waiting' && record.pending?.kind === 'coordinator-lock-queue') await driveQueue(session)
|
|
197
|
+
else if (record.status === 'waiting' && record.pending?.kind === 'coordinator-wait') await driveWait(session)
|
|
198
|
+
else await executeStep(session, step)
|
|
199
|
+
} catch (error) { await failedStep(session, error) }
|
|
200
|
+
if (record.status !== 'succeeded') return state
|
|
201
|
+
}
|
|
202
|
+
return state
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function resumeExecution(repositoryRoot, chainId, dependencies) {
|
|
206
|
+
return withExecutionLock(repositoryRoot, chainId, async (root, file) => {
|
|
207
|
+
const state = await loadExecution(file)
|
|
208
|
+
verifyContexts(root, state)
|
|
209
|
+
recoverInterrupted(state)
|
|
210
|
+
await saveExecution(file, state)
|
|
211
|
+
return advance(root, file, state, dependencies)
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function answerExecution(repositoryRoot, chainId, terminal, dependencies) {
|
|
216
|
+
return withExecutionLock(repositoryRoot, chainId, async (root, file) => {
|
|
217
|
+
const state = await loadExecution(file)
|
|
218
|
+
verifyContexts(root, state)
|
|
219
|
+
recoverInterrupted(state)
|
|
220
|
+
await saveExecution(file, state)
|
|
221
|
+
const record = state.steps.find((item) => item.status !== 'succeeded')
|
|
222
|
+
if (!record) fail('AIMLOCK_CHAIN_NOT_WAITING_FOR_HUMAN', 'execution is complete')
|
|
223
|
+
const session = { root, file, state, record, dependencies }
|
|
224
|
+
try { await answerPending(session, terminal) } catch (error) {
|
|
225
|
+
if (record.status !== 'running') throw error
|
|
226
|
+
await failedStep(session, error)
|
|
227
|
+
return state
|
|
228
|
+
}
|
|
229
|
+
return advance(root, file, state, dependencies)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export { executionStatus }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { createInterface } from 'node:readline/promises'
|
|
3
|
+
import { fail, identifier } from './aimlock-local-fs.mjs'
|
|
4
|
+
import { callCoordinator, callSkill } from './aimlock-chain-calls.mjs'
|
|
5
|
+
import { saveExecution } from './aimlock-chain-store.mjs'
|
|
6
|
+
import { answerMatchesContinuation } from './aimlock-chain-outcomes.mjs'
|
|
7
|
+
|
|
8
|
+
const ANSWER_PROMPT = '答案 / Answer / Ответ > '
|
|
9
|
+
|
|
10
|
+
export async function prepareHuman(session, interaction, waitInput) {
|
|
11
|
+
const output = await callSkill(session, 'confirm-protocol', 'interaction-request', { interaction })
|
|
12
|
+
if (output.status !== 'succeeded' || JSON.stringify(output.interaction) !== JSON.stringify(interaction)) {
|
|
13
|
+
fail('AIMLOCK_CHAIN_CONFIRM_REJECTED', 'Confirm Protocol did not accept the bound interaction')
|
|
14
|
+
}
|
|
15
|
+
session.record.status = 'waiting'
|
|
16
|
+
session.record.pending = { kind: 'human', interaction: output.interaction, waitInput,
|
|
17
|
+
presentation: output.chatFallback, response: output }
|
|
18
|
+
await saveExecution(session.file, session.state)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeSelection(interaction, source) {
|
|
22
|
+
const text = source.trim()
|
|
23
|
+
if (!text) fail('AIMLOCK_CHAIN_ANSWER_REQUIRED', 'an explicit answer is required')
|
|
24
|
+
if (interaction.type === 'input') return text
|
|
25
|
+
const selected = interaction.type === 'multi' ? text.split(',').map((part) => part.trim()) : [text]
|
|
26
|
+
const ids = interaction.options.map((option) => option.id)
|
|
27
|
+
if (new Set(selected).size !== selected.length || selected.some((item) => !ids.includes(item))) {
|
|
28
|
+
fail('AIMLOCK_CHAIN_ANSWER_INVALID', 'answer must use the displayed option IDs')
|
|
29
|
+
}
|
|
30
|
+
return interaction.type === 'multi' ? selected : selected[0]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readHumanAnswer(pending, input, output) {
|
|
34
|
+
if (input.isTTY !== true || output.isTTY !== true) {
|
|
35
|
+
fail('AIMLOCK_CHAIN_TTY_REQUIRED', 'the answer command requires an interactive terminal; redirected answers are not accepted')
|
|
36
|
+
}
|
|
37
|
+
output.write(pending.interaction.question + '\n')
|
|
38
|
+
if (pending.interaction.riskDescription) output.write(pending.interaction.riskDescription + '\n')
|
|
39
|
+
for (const option of pending.interaction.options) output.write(option.id + ': ' + option.label + '\n')
|
|
40
|
+
const terminal = createInterface({ input, output })
|
|
41
|
+
try {
|
|
42
|
+
return normalizeSelection(pending.interaction, await terminal.question(ANSWER_PROMPT))
|
|
43
|
+
} finally { terminal.close() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function verifyAnswer(output, pending, submitted) {
|
|
47
|
+
const audit = output.auditEntry
|
|
48
|
+
const callback = output.callbackRequest
|
|
49
|
+
if (output.status !== 'succeeded' || !audit || !callback
|
|
50
|
+
|| audit.requestId !== pending.interaction.requestId || audit.actorId !== submitted.actorId
|
|
51
|
+
|| JSON.stringify(audit.answer) !== JSON.stringify(submitted.answer) || audit.remembered !== false
|
|
52
|
+
|| audit.question !== pending.interaction.question
|
|
53
|
+
|| audit.risk !== pending.interaction.risk || audit.answeredAt !== submitted.answeredAt
|
|
54
|
+
|| audit.auditId !== submitted.auditId || callback.operation !== pending.interaction.callback.operation
|
|
55
|
+
|| callback.payload.requestId !== pending.interaction.requestId
|
|
56
|
+
|| JSON.stringify(callback.payload.answer) !== JSON.stringify(submitted.answer)) {
|
|
57
|
+
fail('AIMLOCK_CHAIN_CONFIRM_BINDING_INVALID', 'confirmed response is not bound to the displayed question and actual answer')
|
|
58
|
+
}
|
|
59
|
+
for (const [key, value] of Object.entries(pending.interaction.callback.payload)) {
|
|
60
|
+
if (JSON.stringify(callback.payload[key]) !== JSON.stringify(value)) {
|
|
61
|
+
fail('AIMLOCK_CHAIN_CONFIRM_BINDING_INVALID', 'callback payload changed')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function answerPending(session, terminal) {
|
|
67
|
+
const pending = session.record.pending
|
|
68
|
+
if (session.record.status !== 'waiting' || pending?.kind !== 'human') {
|
|
69
|
+
fail('AIMLOCK_CHAIN_NOT_WAITING_FOR_HUMAN', 'this step has no pending human question')
|
|
70
|
+
}
|
|
71
|
+
const actorId = identifier(terminal.actorId, 'actorId')
|
|
72
|
+
const answer = await readHumanAnswer(pending, terminal.input, terminal.output)
|
|
73
|
+
const submitted = { interaction: pending.interaction, answer, actorId, remembered: false,
|
|
74
|
+
answeredAt: new Date().toISOString(), auditId: 'audit-' + randomUUID() }
|
|
75
|
+
session.record.status = 'running'
|
|
76
|
+
await saveExecution(session.file, session.state)
|
|
77
|
+
const output = await callSkill(session, 'confirm-protocol', 'interaction-answer', submitted)
|
|
78
|
+
verifyAnswer(output, pending, submitted)
|
|
79
|
+
if (pending.waitInput !== null) {
|
|
80
|
+
const decisionId = pending.interaction.callback.payload.decisionId
|
|
81
|
+
if (pending.interaction.callback.operation !== 'resolve-human' || decisionId !== pending.interaction.requestId) {
|
|
82
|
+
fail('AIMLOCK_CHAIN_CONFIRM_BINDING_INVALID', 'coordinator decision callback does not match')
|
|
83
|
+
}
|
|
84
|
+
const resolution = await callCoordinator(session, 'resolve-human', { decisionId, answer, actorId })
|
|
85
|
+
session.record.output = { ...session.record.output, humanResolution: resolution, confirmation: output }
|
|
86
|
+
session.record.pending = { kind: 'coordinator-wait', input: pending.waitInput,
|
|
87
|
+
decision: resolution.decision, confirmation: output }
|
|
88
|
+
session.record.status = 'waiting'
|
|
89
|
+
} else {
|
|
90
|
+
session.record.output = { interaction: pending.interaction, ...output }
|
|
91
|
+
session.record.pending = null
|
|
92
|
+
const authorized = answerMatchesContinuation(pending.continueWhen, output.auditEntry.answer)
|
|
93
|
+
session.record.status = authorized ? 'succeeded' : 'blocked'
|
|
94
|
+
session.record.error = authorized ? null : { code: 'AIMLOCK_CHAIN_CONTINUATION_NOT_AUTHORIZED',
|
|
95
|
+
message: 'The actual answer did not satisfy an explicit continuation condition' }
|
|
96
|
+
session.record.completedAt = new Date().toISOString()
|
|
97
|
+
}
|
|
98
|
+
await saveExecution(session.file, session.state)
|
|
99
|
+
}
|