cli-aimlock 7.0.36 → 7.0.38
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/README.md +6 -0
- package/aimlock-chain-calls.mjs +24 -5
- package/aimlock-chain-cli.mjs +2 -1
- package/aimlock-chain-executor.mjs +32 -17
- package/aimlock-chain-model.mjs +1 -1
- package/aimlock-coordination.mjs +4 -3
- package/aimlock-runtime.mjs +1 -1
- package/aimlock-tasks-cli.mjs +202 -0
- package/brain-client.mjs +2 -1
- package/broker-failures.mjs +68 -0
- package/broker-recovery.mjs +110 -0
- package/broker-transport-attempt.mjs +86 -0
- package/broker-transport.mjs +107 -0
- package/broker.mjs +67 -59
- package/cli.mjs +4 -1
- package/installer.mjs +23 -2
- package/package.json +11 -4
- package/skill/SKILL.md +18 -2
- package/skill/references/task-routing.md +13 -0
- package/skill/skill.json +1 -1
package/README.md
CHANGED
|
@@ -65,3 +65,9 @@ Source: https://github.com/88208555/aimlock-clitax.git
|
|
|
65
65
|
Brain Client 服务端在同一次 runtime 请求的事务中绑定真实响应、生成并持久化权威评分与评语,再返回已提交回执。broker 只验证 `feedbackReceiptId`、`feedbackInvocationId` 和权威摘要,不发起第二次评价写入,也不生成分数或评语。`not-reported`、验证不完整、P0/P1 findings、`blocked` 或 `failed` 都不得生成好评;缺凭证、缺回执、摘要不匹配、响应非法或 HTTP 失败都会显式失败。
|
|
66
66
|
|
|
67
67
|
本地 CLI 不提供手工评分或评语提交命令,人类不能选择技能分数或填写技能评价。日常聊天不属于评价协议。
|
|
68
|
+
|
|
69
|
+
## 网络中断与原回执恢复
|
|
70
|
+
|
|
71
|
+
仅在 TLS 握手前确定尚未发送 HTTP 请求时,broker 才允许最多 3 次连接尝试,并受总超时约束。请求发出后发生断线或响应中断,只用 GET 查询原 requestId 的服务端回执,禁止重发 POST;未取得有效回执时保留不确定状态,不得假定成功或继续依赖步骤。
|
|
72
|
+
|
|
73
|
+
`npx cli-aimlock@latest recover <operation> <requestId>` 可重新查询原调用,不会重做操作或重复计费。链恢复不会跳过人工确认,也不会自动重跑结果不确定的本地命令。代理连接需 Node.js 22.21+ 或 24.5+;不支持的运行时会明确报错。
|
package/aimlock-chain-calls.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { executeCoordinatorOperation } from 'cli-swarm/coordinator'
|
|
4
4
|
import { loadOfficialSkillContext } from './installer.mjs'
|
|
5
|
-
import { invokeOfficialSkill } from './broker.mjs'
|
|
5
|
+
import { invokeOfficialSkill, recoverOfficialSkill } from './broker.mjs'
|
|
6
6
|
import { fail, sha256 } from './aimlock-local-fs.mjs'
|
|
7
7
|
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
8
8
|
import { saveExecution } from './aimlock-chain-store.mjs'
|
|
@@ -52,10 +52,12 @@ export async function callSkill(session, skillId, operation, input) {
|
|
|
52
52
|
const invocation = await invokeOfficialSkill(context, operation, input, {
|
|
53
53
|
environment: dependencies.environment, credentialAccess: dependencies.credentialAccess,
|
|
54
54
|
request: async (url, options) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
if (options.method === 'POST') {
|
|
56
|
+
const request = JSON.parse(options.body).input
|
|
57
|
+
call.requestId = request.requestId
|
|
58
|
+
call.status = 'dispatched'
|
|
59
|
+
await saveExecution(session.file, session.state)
|
|
60
|
+
}
|
|
59
61
|
return dependencies.request(url, options)
|
|
60
62
|
},
|
|
61
63
|
})
|
|
@@ -103,3 +105,20 @@ export async function callCommand(session, input) {
|
|
|
103
105
|
throw error
|
|
104
106
|
}
|
|
105
107
|
}
|
|
108
|
+
|
|
109
|
+
export async function recoverSkillCall(session, skillId) {
|
|
110
|
+
const call = session.record.calls.at(-1)
|
|
111
|
+
if (!call || call.kind !== 'skill' || !call.requestId || !['uncertain', 'dispatched', 'recorded'].includes(call.status)) {
|
|
112
|
+
fail('AIMLOCK_CHAIN_RECOVERY_INVALID', 'Only an uncertain skill request can be recovered by receipt query')
|
|
113
|
+
}
|
|
114
|
+
call.status = 'uncertain'
|
|
115
|
+
await saveExecution(session.file, session.state)
|
|
116
|
+
const invocation = await recoverOfficialSkill(skillContext(session.state, skillId),
|
|
117
|
+
call.operation, call.requestId, session.dependencies)
|
|
118
|
+
call.receipt = invocation
|
|
119
|
+
call.status = 'recorded'
|
|
120
|
+
call.error = null
|
|
121
|
+
call.completedAt = new Date().toISOString()
|
|
122
|
+
await saveExecution(session.file, session.state)
|
|
123
|
+
return invocation.response.output
|
|
124
|
+
}
|
package/aimlock-chain-cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stdin, stdout } from 'node:process'
|
|
2
2
|
import { answerExecution, executionStatus, initializeExecution, resumeExecution } from './aimlock-chain-executor.mjs'
|
|
3
3
|
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
4
|
+
import { createBrokerTransport } from './broker-transport.mjs'
|
|
4
5
|
import { fail } from './aimlock-local-fs.mjs'
|
|
5
6
|
|
|
6
7
|
const PLAN_MAX_BYTES = 1_048_576
|
|
@@ -29,7 +30,7 @@ export async function dispatchChain(args) {
|
|
|
29
30
|
if (!Object.hasOwn(expected, operation) || args.length !== expected[operation]) {
|
|
30
31
|
fail('AIMLOCK_CHAIN_USAGE_INVALID', CHAIN_USAGE)
|
|
31
32
|
}
|
|
32
|
-
const dependencies = { environment: process.env, request:
|
|
33
|
+
const dependencies = { environment: process.env, request: createBrokerTransport({ environment: process.env }) }
|
|
33
34
|
if (operation === 'init') return initializeExecution(repositoryRoot, await readPlan(stdin))
|
|
34
35
|
if (operation === 'status') return executionStatus(repositoryRoot, chainId)
|
|
35
36
|
if (operation === 'resume') return resumeExecution(repositoryRoot, chainId, dependencies)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { decisionResumesTask, decisionTargetsTask } from 'cli-swarm/coordinator'
|
|
1
2
|
import { randomUUID } from 'node:crypto'
|
|
2
3
|
import { assertChainNotSuspended } from './aimlock-coordination.mjs'
|
|
3
4
|
import { fail } from './aimlock-local-fs.mjs'
|
|
4
5
|
import { bindInput, errorRecord, validatePlan } from './aimlock-chain-model.mjs'
|
|
5
6
|
import { assertNewExecution, executionStatus, initialState, loadExecution, recoverInterrupted,
|
|
6
7
|
saveExecution, withExecutionLock } from './aimlock-chain-store.mjs'
|
|
7
|
-
import { callCommand, callCoordinator, callSkill, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
|
|
8
|
+
import { callCommand, callCoordinator, callSkill, recoverSkillCall, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
|
|
8
9
|
import { answerPending, prepareHuman } from './aimlock-chain-human.mjs'
|
|
9
10
|
import { skillOutcome } from './aimlock-chain-outcomes.mjs'
|
|
10
11
|
|
|
@@ -57,14 +58,13 @@ function finish(record, output, status) {
|
|
|
57
58
|
|
|
58
59
|
async function stillWaiting(session, output, pending) {
|
|
59
60
|
if (output.status === 'waiting') return true
|
|
60
|
-
const releasedByHuman = pending.decision
|
|
61
|
-
&& pending.decision.answer === 'resume:' + pending.input.agentId
|
|
61
|
+
const releasedByHuman = pending.decision && decisionResumesTask(pending.decision, pending.input)
|
|
62
62
|
if (output.status !== 'blocked' || (output.wakePackage?.reason !== 'event-received' && !releasedByHuman)) return false
|
|
63
63
|
const current = (await callCoordinator(session, 'status', {})).state
|
|
64
64
|
const task = current.tasks.find((item) => item.taskId === pending.input.taskId)
|
|
65
65
|
if (!task || ['completed', 'failed', 'reclaimed'].includes(task.status)) return false
|
|
66
66
|
return current.waits.some((wait) => wait.taskId === task.taskId && wait.status === 'active')
|
|
67
|
-
|| current.decisions.some((decision) => decision.status === 'pending'
|
|
67
|
+
|| current.decisions.some((decision) => decisionTargetsTask(decision, task) && decision.status === 'pending')
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
async function driveWait(session) {
|
|
@@ -80,8 +80,7 @@ async function driveWait(session) {
|
|
|
80
80
|
await prepareHuman(session, output.confirmProtocolRequests[0].input.interaction, pending.input)
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
-
const releasedByHuman = pending.decision
|
|
84
|
-
&& pending.decision.answer === 'resume:' + pending.input.agentId
|
|
83
|
+
const releasedByHuman = pending.decision && decisionResumesTask(pending.decision, pending.input)
|
|
85
84
|
&& output.status === 'resolved'
|
|
86
85
|
if (releasedByHuman) {
|
|
87
86
|
finish(session.record, { ...output, status: 'resolved-by-human',
|
|
@@ -149,6 +148,20 @@ async function coordinatorStep(session, step, input) {
|
|
|
149
148
|
}
|
|
150
149
|
}
|
|
151
150
|
|
|
151
|
+
function finishSkillStep(session, step, output) {
|
|
152
|
+
if (step.skillId === 'confirm-protocol' && step.operation === 'interaction-request' && output.status === 'succeeded') {
|
|
153
|
+
session.record.status = 'waiting'
|
|
154
|
+
session.record.output = output
|
|
155
|
+
session.record.pending = { kind: 'human', interaction: output.interaction, waitInput: null,
|
|
156
|
+
presentation: output.chatFallback, response: output,
|
|
157
|
+
continueWhen: Object.hasOwn(step, 'continueWhen') ? step.continueWhen : null }
|
|
158
|
+
} else {
|
|
159
|
+
const outcome = skillOutcome(step, output)
|
|
160
|
+
finish(session.record, output, outcome.status)
|
|
161
|
+
session.record.error = outcome.error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
152
165
|
async function executeStep(session, step) {
|
|
153
166
|
if (!await readyForWork(session, step)) return
|
|
154
167
|
const input = bindInput(step, session.state)
|
|
@@ -164,17 +177,7 @@ async function executeStep(session, step) {
|
|
|
164
177
|
} else if (step.kind === 'coordinator') await coordinatorStep(session, step, input)
|
|
165
178
|
else {
|
|
166
179
|
const output = await callSkill(session, step.skillId, step.operation, input)
|
|
167
|
-
|
|
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
|
-
}
|
|
180
|
+
finishSkillStep(session, step, output)
|
|
178
181
|
}
|
|
179
182
|
await saveExecution(session.file, session.state)
|
|
180
183
|
}
|
|
@@ -186,6 +189,18 @@ async function advance(root, file, state, dependencies) {
|
|
|
186
189
|
if (record.status === 'blocked' && step.kind === 'coordinator' && step.operation === 'lock-acquire'
|
|
187
190
|
&& record.output?.status === 'queued' && record.input) saveQueuePending(record, record.output, record.input)
|
|
188
191
|
const session = { root, file, state, record, dependencies }
|
|
192
|
+
if (record.status === 'uncertain' && step.kind === 'skill'
|
|
193
|
+
&& record.calls.at(-1)?.operation === step.operation) {
|
|
194
|
+
try {
|
|
195
|
+
finishSkillStep(session, step, await recoverSkillCall(session, step.skillId))
|
|
196
|
+
await saveExecution(file, state)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await failedStep(session, error)
|
|
199
|
+
return state
|
|
200
|
+
}
|
|
201
|
+
if (record.status === 'succeeded') continue
|
|
202
|
+
return state
|
|
203
|
+
}
|
|
189
204
|
if (['failed', 'uncertain'].includes(record.status)) return state
|
|
190
205
|
if (record.status === 'waiting' && record.pending?.kind === 'human') return state
|
|
191
206
|
if (record.status === 'blocked' && record.pending?.kind !== 'coordination-guard') return state
|
package/aimlock-chain-model.mjs
CHANGED
|
@@ -179,7 +179,7 @@ export function chainStatus(state) {
|
|
|
179
179
|
export function errorRecord(error) {
|
|
180
180
|
if (!(error instanceof Error)) return { name: 'ThrownValue', message: String(error) }
|
|
181
181
|
const value = { name: error.name, message: error.message }
|
|
182
|
-
for (const key of ['code', 'transportCode', 'operation', 'retryable']) {
|
|
182
|
+
for (const key of ['code', 'transportCode', 'operation', 'requestId', 'stage', 'transport', 'recovery', 'receiptStatus', 'retryable']) {
|
|
183
183
|
if (Object.hasOwn(error, key)) value[key] = error[key]
|
|
184
184
|
}
|
|
185
185
|
if (error.cause instanceof Error) value.cause = errorRecord(error.cause)
|
package/aimlock-coordination.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decisionResumesTask, decisionTargetsTask } from 'cli-swarm/coordinator'
|
|
1
2
|
import { verify } from 'node:crypto'
|
|
2
3
|
import { lstat, readFile } from 'node:fs/promises'
|
|
3
4
|
import { basename, resolve } from 'node:path'
|
|
@@ -35,9 +36,9 @@ function failureHandled(task, tasks, inspected = new Set()) {
|
|
|
35
36
|
|
|
36
37
|
function parkedAfterDecision(task, tasks, decisions) {
|
|
37
38
|
if (task.status !== 'blocked' || task.blockedReason !== 'human-decision') return false
|
|
38
|
-
const decision = decisions.findLast((item) => item
|
|
39
|
+
const decision = decisions.findLast((item) => decisionTargetsTask(item, task))
|
|
39
40
|
return decision?.status === 'resolved'
|
|
40
|
-
&& tasks.some((active) => active.status === 'active' && decision
|
|
41
|
+
&& tasks.some((active) => active.status === 'active' && decisionResumesTask(decision, active))
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function assertChainRunnable(state, chainId) {
|
|
@@ -46,7 +47,7 @@ function assertChainRunnable(state, chainId) {
|
|
|
46
47
|
const wait = state.waits.find((item) => item.chainId === chainId && item.status === 'active')
|
|
47
48
|
if (wait) fail('AIMLOCK_COORDINATION_WAITING', 'chain ' + chainId + ' is suspended until ' + wait.event + ' or ' + wait.deadlineAt)
|
|
48
49
|
const pending = state.decisions.some((decision) => decision.status === 'pending'
|
|
49
|
-
&& tasks.some((task) => decision
|
|
50
|
+
&& tasks.some((task) => decisionTargetsTask(decision, task)))
|
|
50
51
|
const unresolved = tasks.some((task) => ['failed', 'reclaimed'].includes(task.status)
|
|
51
52
|
&& !failureHandled(task, tasks))
|
|
52
53
|
if (!tasks.some((task) => task.status === 'active') || pending || unresolved
|
package/aimlock-runtime.mjs
CHANGED
|
@@ -209,7 +209,7 @@ const RESPONSE_SCHEMA = "aimlock.skill.response/1.1";
|
|
|
209
209
|
const ERROR_SCHEMA = "aimlock.skill.error/1.0";
|
|
210
210
|
const CONTRACT_SCHEMA = "aimlock.scope-contract/1.0";
|
|
211
211
|
const COMPILER_NAME = "aimlock";
|
|
212
|
-
const COMPILER_VERSION = "v7.0.
|
|
212
|
+
const COMPILER_VERSION = "v7.0.38";
|
|
213
213
|
const KEEP_ALIVE_SECONDS = 90;
|
|
214
214
|
const KEEP_ALIVE_MESSAGE = "智能目标持续执行中,请勿关闭!";
|
|
215
215
|
const BYPASS_LINE_BUDGET = 500;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { stdin, stdout } from 'node:process'
|
|
2
|
+
import { executeCoordinatorOperation } from 'cli-swarm/coordinator'
|
|
3
|
+
import { errorRecord } from './aimlock-chain-model.mjs'
|
|
4
|
+
import { fail } from './aimlock-local-fs.mjs'
|
|
5
|
+
|
|
6
|
+
const TASK_INPUT_MAX_BYTES = 1_048_576
|
|
7
|
+
const MAX_TASK_DELIVERY_TIMEOUT_MS = 2_147_483_647
|
|
8
|
+
export const TASK_DELIVERY_TIMEOUT_MS = 30_000
|
|
9
|
+
export const TASK_OPERATIONS = Object.freeze([
|
|
10
|
+
'task-describe', 'task-checkpoint', 'task-resume', 'message-route', 'message-status',
|
|
11
|
+
'message-accept', 'message-complete', 'message-delivery-start', 'message-delivery-report',
|
|
12
|
+
'message-resolve', 'handoff-release', 'handoff-resume',
|
|
13
|
+
])
|
|
14
|
+
export const TASKS_USAGE = [
|
|
15
|
+
' cli-aimlock tasks <operation> <repositoryRoot> < task-message.json',
|
|
16
|
+
' cli-aimlock tasks capabilities <repositoryRoot>',
|
|
17
|
+
` Operations: ${TASK_OPERATIONS.join(', ')}.`,
|
|
18
|
+
' Message routing preserves the original plan. A resume package does not execute work.',
|
|
19
|
+
].join('\n')
|
|
20
|
+
|
|
21
|
+
function taskCapabilities(capabilities) {
|
|
22
|
+
if (!capabilities || !Array.isArray(capabilities.operations)
|
|
23
|
+
|| !capabilities.operationSchemas || typeof capabilities.operationSchemas !== 'object') {
|
|
24
|
+
fail('AIMLOCK_TASKS_CAPABILITIES_INVALID', 'coordinator task capabilities are invalid')
|
|
25
|
+
}
|
|
26
|
+
for (const operation of TASK_OPERATIONS) {
|
|
27
|
+
if (!capabilities.operations.includes(operation) || !Object.hasOwn(capabilities.operationSchemas, operation)) {
|
|
28
|
+
fail('AIMLOCK_TASKS_OPERATION_UNAVAILABLE', `coordinator task operation is unavailable: ${operation}`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { operations: [...TASK_OPERATIONS], operationSchemas: Object.fromEntries(
|
|
32
|
+
TASK_OPERATIONS.map((operation) => [operation, capabilities.operationSchemas[operation]]),
|
|
33
|
+
) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readTaskInput(input) {
|
|
37
|
+
const chunks = []
|
|
38
|
+
let byteLength = 0
|
|
39
|
+
for await (const chunk of input) {
|
|
40
|
+
if (typeof chunk !== 'string' && !(chunk instanceof Uint8Array)) {
|
|
41
|
+
fail('AIMLOCK_TASKS_INPUT_INVALID', 'task input must be UTF-8 JSON')
|
|
42
|
+
}
|
|
43
|
+
const bytes = Buffer.from(chunk)
|
|
44
|
+
byteLength += bytes.byteLength
|
|
45
|
+
if (byteLength > TASK_INPUT_MAX_BYTES) fail('AIMLOCK_TASKS_INPUT_TOO_LARGE', 'task input exceeds 1 MiB')
|
|
46
|
+
chunks.push(bytes)
|
|
47
|
+
}
|
|
48
|
+
if (byteLength === 0) fail('AIMLOCK_TASKS_INPUT_REQUIRED', 'task input is required on stdin')
|
|
49
|
+
let value
|
|
50
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) }
|
|
51
|
+
catch { fail('AIMLOCK_TASKS_INPUT_INVALID', 'task input must be valid UTF-8 JSON') }
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
53
|
+
fail('AIMLOCK_TASKS_INPUT_INVALID', 'task input must be a JSON object')
|
|
54
|
+
}
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function dispatchTasks(args, dependencies = {}) {
|
|
59
|
+
const [operation, repositoryRoot] = args
|
|
60
|
+
if (args.length !== 2 || (operation !== 'capabilities' && !TASK_OPERATIONS.includes(operation))) {
|
|
61
|
+
fail('AIMLOCK_TASKS_USAGE_INVALID', TASKS_USAGE)
|
|
62
|
+
}
|
|
63
|
+
const execute = dependencies.executeCoordinatorOperation ?? executeCoordinatorOperation
|
|
64
|
+
const input = dependencies.input ?? stdin
|
|
65
|
+
if (operation === 'capabilities') return taskCapabilities(await execute(operation, repositoryRoot, {}))
|
|
66
|
+
return execute(operation, repositoryRoot, await readTaskInput(input))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runTasksCli(args) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await dispatchTasks(args)
|
|
72
|
+
stdout.write(JSON.stringify(result) + '\n')
|
|
73
|
+
if (result.status === 'blocked' || result.status === 'waiting') process.exitCode = 2
|
|
74
|
+
else if (result.status === 'failed' || result.status === 'uncertain') process.exitCode = 1
|
|
75
|
+
} catch (error) {
|
|
76
|
+
stdout.write(JSON.stringify({ status: 'failed', error: errorRecord(error) }) + '\n')
|
|
77
|
+
process.exitCode = 1
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceIdentity(input) {
|
|
82
|
+
const identity = {}
|
|
83
|
+
for (const field of ['taskId', 'agentId', 'chainId']) {
|
|
84
|
+
if (typeof input?.[field] !== 'string' || !input[field].trim()) {
|
|
85
|
+
fail('AIMLOCK_TASKS_IDENTITY_REQUIRED', `task identity requires ${field}`)
|
|
86
|
+
}
|
|
87
|
+
identity[field] = input[field]
|
|
88
|
+
}
|
|
89
|
+
return identity
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function receiptMatches(receipt, delivery) {
|
|
93
|
+
return receipt && typeof receipt === 'object'
|
|
94
|
+
&& receipt.requestId === delivery.requestId && receipt.targetTaskId === delivery.targetTaskId
|
|
95
|
+
&& typeof receipt.receiptId === 'string' && receipt.receiptId.length > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function deliveryStatus(execute, root, source, delivery) {
|
|
99
|
+
const status = await execute('message-status', root, { ...source, requestId: delivery.requestId })
|
|
100
|
+
if (!status || !Object.hasOwn(status, 'receipt')) {
|
|
101
|
+
fail('AIMLOCK_TASKS_RECEIPT_INVALID', 'coordinator message status has no receipt field')
|
|
102
|
+
}
|
|
103
|
+
if (status.receipt !== null && !receiptMatches(status.receipt, delivery)) {
|
|
104
|
+
fail('AIMLOCK_TASKS_RECEIPT_INVALID', 'coordinator receipt does not match the target delivery')
|
|
105
|
+
}
|
|
106
|
+
return status.receipt
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function awaitHostDelivery(adapter, delivery, timeoutMs) {
|
|
110
|
+
let timer
|
|
111
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
112
|
+
timer = setTimeout(() => reject(Object.assign(new Error(
|
|
113
|
+
`host delivery timed out after ${timeoutMs} ms; the actual delivery was not canceled`,
|
|
114
|
+
), { code: 'AIMLOCK_TASKS_DELIVERY_TIMEOUT' })), timeoutMs)
|
|
115
|
+
})
|
|
116
|
+
try {
|
|
117
|
+
return await Promise.race([Promise.resolve().then(() => adapter.deliver(delivery)), deadline])
|
|
118
|
+
} finally { clearTimeout(timer) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function deliverTaskMessage(root, source, delivery, adapter, execute, timeoutMs) {
|
|
122
|
+
const recorded = await deliveryStatus(execute, root, source, delivery)
|
|
123
|
+
if (recorded) return { requestId: delivery.requestId, status: 'accepted', receipt: recorded, recovered: true }
|
|
124
|
+
const claim = await execute('message-delivery-start', root, { ...source, requestId: delivery.requestId })
|
|
125
|
+
if (!claim || typeof claim.claimed !== 'boolean'
|
|
126
|
+
|| (claim.claimed && (typeof claim.attemptId !== 'string' || !claim.attemptId))) {
|
|
127
|
+
fail('AIMLOCK_TASKS_DELIVERY_CLAIM_INVALID', 'coordinator delivery claim is invalid')
|
|
128
|
+
}
|
|
129
|
+
if (!claim.claimed) {
|
|
130
|
+
const receipt = await deliveryStatus(execute, root, source, delivery)
|
|
131
|
+
if (receipt) return { requestId: delivery.requestId, status: 'accepted', receipt, recovered: true }
|
|
132
|
+
fail('AIMLOCK_TASKS_DELIVERY_UNCERTAIN', 'delivery was already attempted; query its receipt before any further action')
|
|
133
|
+
}
|
|
134
|
+
let delivered
|
|
135
|
+
let deliveryError
|
|
136
|
+
try { delivered = await awaitHostDelivery(adapter, { ...delivery, attemptId: claim.attemptId }, timeoutMs) }
|
|
137
|
+
catch (error) { deliveryError = errorRecord(error) }
|
|
138
|
+
const receipt = await deliveryStatus(execute, root, source, delivery)
|
|
139
|
+
if (!receipt) {
|
|
140
|
+
const detail = deliveryError ? `: ${deliveryError.message}` : ''
|
|
141
|
+
fail('AIMLOCK_TASKS_DELIVERY_UNCERTAIN', `target acceptance has not been recorded${detail}`)
|
|
142
|
+
}
|
|
143
|
+
if (!deliveryError && (!receiptMatches(delivered, delivery) || delivered.status !== 'accepted'
|
|
144
|
+
|| delivered.receiptId !== receipt.receiptId)) {
|
|
145
|
+
fail('AIMLOCK_TASKS_RECEIPT_INVALID', 'adapter receipt does not match the recorded target acceptance')
|
|
146
|
+
}
|
|
147
|
+
return { requestId: delivery.requestId, status: 'accepted', receipt, recovered: Boolean(deliveryError),
|
|
148
|
+
...(deliveryError ? { deliveryError } : {}) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function reportDeliveryFailure(root, source, delivery, error, execute, failures) {
|
|
152
|
+
const failure = errorRecord(error)
|
|
153
|
+
failures.push({ requestId: delivery.requestId, stage: 'delivery', error: failure })
|
|
154
|
+
try {
|
|
155
|
+
await execute('message-delivery-report', root, { ...source, requestId: delivery.requestId,
|
|
156
|
+
errorCode: typeof failure.code === 'string' ? failure.code : 'AIMLOCK_TASKS_DELIVERY_FAILED',
|
|
157
|
+
errorMessage: failure.message })
|
|
158
|
+
} catch (reportError) {
|
|
159
|
+
failures.push({ requestId: delivery.requestId, stage: 'delivery-report', error: errorRecord(reportError) })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A host adapter delivers messages; only the coordinator may issue durable acceptance receipts. */
|
|
164
|
+
export async function handleTaskMessage(root, input, adapter, { deliveryTimeoutMs = TASK_DELIVERY_TIMEOUT_MS } = {}) {
|
|
165
|
+
if (!adapter || typeof adapter.deliver !== 'function' || typeof adapter.continueTask !== 'function') {
|
|
166
|
+
fail('AIMLOCK_TASKS_ADAPTER_REQUIRED', 'host adapter requires deliver and continueTask functions')
|
|
167
|
+
}
|
|
168
|
+
if (!Number.isSafeInteger(deliveryTimeoutMs) || deliveryTimeoutMs <= 0
|
|
169
|
+
|| deliveryTimeoutMs > MAX_TASK_DELIVERY_TIMEOUT_MS) {
|
|
170
|
+
fail('AIMLOCK_TASKS_TIMEOUT_INVALID', 'deliveryTimeoutMs must be a positive integer within the timer limit')
|
|
171
|
+
}
|
|
172
|
+
const source = sourceIdentity(input)
|
|
173
|
+
const execute = executeCoordinatorOperation
|
|
174
|
+
const routed = await execute('message-route', root, input)
|
|
175
|
+
if (!routed || !Array.isArray(routed.deliveries)
|
|
176
|
+
|| Object.keys(source).some((field) => routed.source?.[field] !== source[field])) {
|
|
177
|
+
fail('AIMLOCK_TASKS_ROUTE_INVALID', 'coordinator message route does not match the source task')
|
|
178
|
+
}
|
|
179
|
+
const deliveries = []
|
|
180
|
+
const failures = []
|
|
181
|
+
for (const delivery of routed.deliveries) {
|
|
182
|
+
if (delivery.status !== 'pending-delivery') continue
|
|
183
|
+
try { deliveries.push(await deliverTaskMessage(root, source, delivery, adapter, execute, deliveryTimeoutMs)) }
|
|
184
|
+
catch (error) { await reportDeliveryFailure(root, source, delivery, error, execute, failures) }
|
|
185
|
+
}
|
|
186
|
+
let continuation = null
|
|
187
|
+
let continuationDispatched = false
|
|
188
|
+
try {
|
|
189
|
+
continuation = await execute('task-resume', root, source)
|
|
190
|
+
if (!continuation || typeof continuation.canContinue !== 'boolean'
|
|
191
|
+
|| Object.keys(source).some((field) => continuation[field] !== source[field])) {
|
|
192
|
+
fail('AIMLOCK_TASKS_RESUME_INVALID', 'coordinator resume package does not match the original task')
|
|
193
|
+
}
|
|
194
|
+
if (continuation.canContinue) {
|
|
195
|
+
await adapter.continueTask(continuation)
|
|
196
|
+
continuationDispatched = true
|
|
197
|
+
}
|
|
198
|
+
} catch (error) { failures.push({ requestId: null, stage: 'continuation', error: errorRecord(error) }) }
|
|
199
|
+
return { status: failures.length ? 'failed' : continuationDispatched ? 'continued' : 'waiting',
|
|
200
|
+
messageId: routed.messageId, requests: routed.requests, deliveries, continuation,
|
|
201
|
+
continuationDispatched, failures }
|
|
202
|
+
}
|
package/brain-client.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { inspectBrainTarget, brainStateDirectory, saveBrainRequest } from './bra
|
|
|
4
4
|
export { inspectBrainTarget } from './brain-client-files.mjs'
|
|
5
5
|
import { resolve } from 'node:path'
|
|
6
6
|
import { brainClientAuthorization, transportFailureCode } from './broker.mjs'
|
|
7
|
+
import { createBrokerTransport } from './broker-transport.mjs'
|
|
7
8
|
import { executeCommand } from './aimlock-chain-process.mjs'
|
|
8
9
|
|
|
9
10
|
export const BRAIN_ENDPOINT = 'https://cli.tax/api/v1/brain'
|
|
@@ -45,7 +46,7 @@ export async function invokeBrain(operation, input, dependencies = {}) {
|
|
|
45
46
|
if (Buffer.byteLength(body) > MAX_INPUT_BYTES) throw new Error('Brain request exceeds the size limit')
|
|
46
47
|
let response
|
|
47
48
|
try {
|
|
48
|
-
response = await (dependencies.request ??
|
|
49
|
+
response = await (dependencies.request ?? createBrokerTransport({ environment }))(endpoint, {
|
|
49
50
|
method: 'POST', redirect: 'error', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
|
|
50
51
|
body, signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
51
52
|
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const TRANSPORT_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/
|
|
2
|
+
const NETWORK_TRANSPORT_ERROR = 'NETWORK_TRANSPORT'
|
|
3
|
+
const SKILL_INVOCATION_ERROR = 'SKILL_INVOCATION_FAILED'
|
|
4
|
+
const TRANSPORT_STAGES = new Set(['configuration', 'connecting', 'tls', 'request-sent', 'response-body', 'complete'])
|
|
5
|
+
const MAX_TRANSPORT_ATTEMPTS = 3
|
|
6
|
+
|
|
7
|
+
export function transportDiagnostics(value) {
|
|
8
|
+
if (!value || typeof value !== 'object' || !Number.isSafeInteger(value.attempts)
|
|
9
|
+
|| value.attempts < 0 || value.attempts > MAX_TRANSPORT_ATTEMPTS
|
|
10
|
+
|| !TRANSPORT_STAGES.has(value.stage) || typeof value.submitted !== 'boolean') return null
|
|
11
|
+
return { attempts: value.attempts, stage: value.stage, submitted: value.submitted }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class OfficialSkillInvocationError extends Error {
|
|
15
|
+
constructor(context, request, transportCode, stage, transport) {
|
|
16
|
+
super(`${context.displayName} ${request.operation} invocation failed: network transport ${transportCode}`)
|
|
17
|
+
this.name = 'OfficialSkillInvocationError'
|
|
18
|
+
this.code = NETWORK_TRANSPORT_ERROR
|
|
19
|
+
this.requestId = request.requestId
|
|
20
|
+
this.operation = request.operation
|
|
21
|
+
const diagnostic = transportDiagnostics(transport)
|
|
22
|
+
this.stage = diagnostic ? diagnostic.stage : stage
|
|
23
|
+
if (diagnostic) this.transport = diagnostic
|
|
24
|
+
this.retryable = false
|
|
25
|
+
this.transportCode = transportCode
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class OfficialSkillResponseError extends Error {
|
|
30
|
+
constructor(request, stage, message) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = 'OfficialSkillResponseError'
|
|
33
|
+
this.code = SKILL_INVOCATION_ERROR
|
|
34
|
+
this.requestId = request.requestId
|
|
35
|
+
this.operation = request.operation
|
|
36
|
+
this.stage = stage
|
|
37
|
+
this.retryable = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function transportFailureCode(error) {
|
|
42
|
+
const inspected = new Set()
|
|
43
|
+
let candidate = error
|
|
44
|
+
while (candidate && typeof candidate === 'object' && !inspected.has(candidate)) {
|
|
45
|
+
inspected.add(candidate)
|
|
46
|
+
const code = typeof candidate.code === 'string' ? candidate.code.trim() : ''
|
|
47
|
+
if (TRANSPORT_ERROR_CODE_PATTERN.test(code)) return code
|
|
48
|
+
const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''
|
|
49
|
+
if (name === 'AbortError' || name === 'TimeoutError') return name
|
|
50
|
+
candidate = candidate.cause
|
|
51
|
+
}
|
|
52
|
+
return 'UNKNOWN_TRANSPORT_ERROR'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function officialSkillFailureResponse(error) {
|
|
56
|
+
if (error instanceof OfficialSkillInvocationError || error instanceof OfficialSkillResponseError) {
|
|
57
|
+
const failure = { code: error.code, message: error.message, requestId: error.requestId,
|
|
58
|
+
operation: error.operation, stage: error.stage, retryable: error.retryable }
|
|
59
|
+
if (typeof error.transportCode === 'string') failure.transportCode = error.transportCode
|
|
60
|
+
if (typeof error.receiptStatus === 'string') failure.receiptStatus = error.receiptStatus
|
|
61
|
+
if (error.recovery) failure.recovery = error.recovery
|
|
62
|
+
const diagnostic = transportDiagnostics(error.transport)
|
|
63
|
+
if (diagnostic) failure.transport = diagnostic
|
|
64
|
+
return { ok: false, error: failure }
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: { code: SKILL_INVOCATION_ERROR,
|
|
67
|
+
message: error instanceof Error ? error.message : 'Skill invocation failed', retryable: false } }
|
|
68
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { OfficialSkillResponseError, transportFailureCode, transportDiagnostics } from './broker-failures.mjs'
|
|
2
|
+
|
|
3
|
+
export const SKILL_RECEIPT_SCHEMA = 'skill-runtime-receipt/1.0'
|
|
4
|
+
export const SKILL_RECEIPT_HEADER = 'X-Skill-Receipt'
|
|
5
|
+
const RECEIPT_API_PATH = '/api/v1/skill-invocations'
|
|
6
|
+
const QUERY_TIMEOUT_MS = 5_000
|
|
7
|
+
const QUERY_ATTEMPTS = 3
|
|
8
|
+
const QUERY_DELAY_MS = 250
|
|
9
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed'])
|
|
10
|
+
const RECEIPT_STATUSES = new Set([...TERMINAL_STATUSES, 'pending', 'expired', 'not-found'])
|
|
11
|
+
|
|
12
|
+
class ReceiptRecoveryError extends OfficialSkillResponseError {
|
|
13
|
+
constructor(request, status, message, transportCode) {
|
|
14
|
+
super(request, 'receipt-query', message)
|
|
15
|
+
this.name = 'ReceiptRecoveryError'
|
|
16
|
+
this.code = 'SKILL_INVOCATION_UNCERTAIN'
|
|
17
|
+
this.receiptStatus = status
|
|
18
|
+
if (transportCode) this.transportCode = transportCode
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateReceipt(response, payload, context, request) {
|
|
23
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
|
24
|
+
|| payload.schemaVersion !== SKILL_RECEIPT_SCHEMA || payload.runtimeCode !== context.runtimeCode
|
|
25
|
+
|| payload.requestId !== request.requestId || !RECEIPT_STATUSES.has(payload.status)) {
|
|
26
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Receipt query returned invalid or mismatched execution identity')
|
|
27
|
+
}
|
|
28
|
+
const expectedHttpStatus = payload.status === 'pending' ? 202
|
|
29
|
+
: payload.status === 'expired' ? 410 : payload.status === 'not-found' ? 404 : 200
|
|
30
|
+
if (response.status !== expectedHttpStatus || (payload.status !== 'not-found'
|
|
31
|
+
&& !Number.isFinite(Date.parse(payload.expiresAt)))) {
|
|
32
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Receipt status or expiration metadata is invalid')
|
|
33
|
+
}
|
|
34
|
+
if (TERMINAL_STATUSES.has(payload.status) && (!Number.isInteger(payload.httpStatus)
|
|
35
|
+
|| payload.httpStatus < 100 || payload.httpStatus > 599 || !payload.response
|
|
36
|
+
|| typeof payload.response !== 'object' || Array.isArray(payload.response))) {
|
|
37
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Receipt has no valid recorded HTTP result')
|
|
38
|
+
}
|
|
39
|
+
if (payload.status === 'failed' && (payload.httpStatus < 400
|
|
40
|
+
|| typeof payload.response.error !== 'string' || typeof payload.response.code !== 'string')) {
|
|
41
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Failed receipt has no valid recorded error')
|
|
42
|
+
}
|
|
43
|
+
return payload
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function fetchReceipt(context, request, dependencies, authorization) {
|
|
47
|
+
const endpoint = new URL(context.endpoint)
|
|
48
|
+
const path = `${RECEIPT_API_PATH}/${encodeURIComponent(context.runtimeCode)}/${encodeURIComponent(request.requestId)}`
|
|
49
|
+
let response
|
|
50
|
+
let payload
|
|
51
|
+
try {
|
|
52
|
+
response = await dependencies.request(new URL(path, endpoint.origin).href, {
|
|
53
|
+
method: 'GET', redirect: 'error', headers: { Authorization: authorization },
|
|
54
|
+
signal: AbortSignal.timeout(QUERY_TIMEOUT_MS),
|
|
55
|
+
})
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new ReceiptRecoveryError(request, 'query-failed', 'Receipt query failed; execution outcome remains uncertain',
|
|
58
|
+
transportFailureCode(error))
|
|
59
|
+
}
|
|
60
|
+
if (response.status === 401 || response.status === 403) {
|
|
61
|
+
throw new ReceiptRecoveryError(request, 'unauthorized', `Receipt query authorization failed: HTTP ${response.status}`)
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
payload = await response.json()
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof SyntaxError) throw new ReceiptRecoveryError(request, 'invalid', 'Receipt query returned invalid JSON')
|
|
67
|
+
throw new ReceiptRecoveryError(request, 'query-failed', 'Receipt body was interrupted; execution outcome remains uncertain',
|
|
68
|
+
transportFailureCode(error))
|
|
69
|
+
}
|
|
70
|
+
return { receipt: validateReceipt(response, payload, context, request), transport: transportDiagnostics(response.transport) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function receiptOutcome(receipt, context, request, validateInvocation, transport) {
|
|
74
|
+
if (receipt.status === 'failed') {
|
|
75
|
+
throw new OfficialSkillResponseError(request, 'http-response',
|
|
76
|
+
`${context.displayName} ${request.operation} failed: HTTP ${receipt.httpStatus} (recorded receipt)`)
|
|
77
|
+
}
|
|
78
|
+
if (receipt.httpStatus !== 200 || receipt.response.ok !== true) {
|
|
79
|
+
throw new ReceiptRecoveryError(request, 'invalid', 'Completed receipt does not contain a successful HTTP response')
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const invocation = validateInvocation(receipt.response)
|
|
83
|
+
return { ...invocation, recovery: { status: 'completed', requestId: request.requestId },
|
|
84
|
+
...(transport ? { transport } : {}) }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new OfficialSkillResponseError(request, 'receipt-validation',
|
|
87
|
+
error instanceof Error ? error.message : 'Recorded skill response validation failed')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function queryOfficialSkillReceipt(context, request, dependencies, authorization, validateInvocation) {
|
|
92
|
+
let lastFailure
|
|
93
|
+
for (let attempt = 0; attempt < QUERY_ATTEMPTS; attempt += 1) {
|
|
94
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, QUERY_DELAY_MS))
|
|
95
|
+
let fetched
|
|
96
|
+
try {
|
|
97
|
+
fetched = await fetchReceipt(context, request, dependencies, authorization)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof ReceiptRecoveryError) || error.receiptStatus !== 'query-failed') throw error
|
|
100
|
+
lastFailure = error
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const { receipt, transport } = fetched
|
|
104
|
+
if (TERMINAL_STATUSES.has(receipt.status)) return receiptOutcome(receipt, context, request, validateInvocation, transport)
|
|
105
|
+
lastFailure = new ReceiptRecoveryError(request, receipt.status,
|
|
106
|
+
`Invocation outcome is ${receipt.status}; query this requestId again before any new execution`)
|
|
107
|
+
if (receipt.status !== 'pending') throw lastFailure
|
|
108
|
+
}
|
|
109
|
+
throw lastFailure
|
|
110
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
function responseValue(message, chunks, state) {
|
|
2
|
+
const headers = new Headers()
|
|
3
|
+
for (let index = 0; index < message.rawHeaders.length; index += 2) {
|
|
4
|
+
headers.append(message.rawHeaders[index], message.rawHeaders[index + 1])
|
|
5
|
+
}
|
|
6
|
+
const status = message.statusCode
|
|
7
|
+
if (!Number.isInteger(status) || status < 200 || status > 599) throw new Error('BROKER_RESPONSE_STATUS_INVALID')
|
|
8
|
+
const body = Buffer.concat(chunks)
|
|
9
|
+
const emptyBody = status === 204 || status === 205 || status === 304
|
|
10
|
+
const response = new Response(emptyBody ? null : body, { status, headers })
|
|
11
|
+
response.transport = Object.freeze({ ...state, stage: 'complete' })
|
|
12
|
+
return response
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** No write/end/flushHeaders is permitted before the destination TLS socket is authenticated. */
|
|
16
|
+
export function attemptBrokerHttps(input, dependencies) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const { state, signal, failure } = dependencies
|
|
19
|
+
const agent = dependencies.createAgent({ ...input.agentOptions, keepAlive: false, maxSockets: 1, maxCachedSessions: 0 })
|
|
20
|
+
let request
|
|
21
|
+
let settled = false
|
|
22
|
+
let connectTimer
|
|
23
|
+
const cleanup = () => {
|
|
24
|
+
clearTimeout(connectTimer)
|
|
25
|
+
signal.removeEventListener('abort', abort)
|
|
26
|
+
agent.destroy()
|
|
27
|
+
}
|
|
28
|
+
const finishError = code => {
|
|
29
|
+
if (settled) return
|
|
30
|
+
settled = true
|
|
31
|
+
const error = failure(code)
|
|
32
|
+
request?.destroy()
|
|
33
|
+
cleanup()
|
|
34
|
+
reject(error)
|
|
35
|
+
}
|
|
36
|
+
const abort = () => finishError('ABORT_ERR')
|
|
37
|
+
const receive = response => {
|
|
38
|
+
state.stage = 'response-body'
|
|
39
|
+
const chunks = []
|
|
40
|
+
let bytes = 0
|
|
41
|
+
response.on('error', error => finishError(error.code))
|
|
42
|
+
response.on('aborted', () => finishError('BROKER_RESPONSE_TRUNCATED'))
|
|
43
|
+
response.on('close', () => { if (!response.complete) finishError('BROKER_RESPONSE_TRUNCATED') })
|
|
44
|
+
response.on('data', chunk => {
|
|
45
|
+
bytes += chunk.length
|
|
46
|
+
if (bytes > dependencies.maxResponseBytes) finishError('BROKER_RESPONSE_TOO_LARGE')
|
|
47
|
+
else chunks.push(chunk)
|
|
48
|
+
})
|
|
49
|
+
response.on('end', () => {
|
|
50
|
+
if (settled) return
|
|
51
|
+
if (!response.complete) return finishError('BROKER_RESPONSE_TRUNCATED')
|
|
52
|
+
const coding = response.headers['content-encoding']
|
|
53
|
+
if (coding !== undefined && coding !== 'identity') return finishError('BROKER_RESPONSE_ENCODING_UNSUPPORTED')
|
|
54
|
+
let value
|
|
55
|
+
try { value = responseValue(response, chunks, state) }
|
|
56
|
+
catch (error) { return finishError(error.message) }
|
|
57
|
+
settled = true
|
|
58
|
+
cleanup()
|
|
59
|
+
resolve(value)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
request = dependencies.request(input.target, { method: input.method, headers: input.headers, agent,
|
|
64
|
+
rejectUnauthorized: true }, receive)
|
|
65
|
+
request.on('error', error => finishError(error.code))
|
|
66
|
+
request.once('socket', socket => {
|
|
67
|
+
state.stage = 'tls'
|
|
68
|
+
const send = () => {
|
|
69
|
+
if (settled) return
|
|
70
|
+
if (signal.aborted) return abort()
|
|
71
|
+
if (socket.encrypted !== true || socket.authorized !== true) return finishError('BROKER_TLS_UNAUTHORIZED')
|
|
72
|
+
clearTimeout(connectTimer)
|
|
73
|
+
state.stage = 'request-sent'
|
|
74
|
+
state.submitted = true
|
|
75
|
+
try { request.end(input.body) } catch (error) { finishError(error.code) }
|
|
76
|
+
}
|
|
77
|
+
if (socket.encrypted === true && socket.authorized === true) send()
|
|
78
|
+
else socket.once('secureConnect', send)
|
|
79
|
+
})
|
|
80
|
+
connectTimer = setTimeout(() => finishError('ETIMEDOUT'), dependencies.connectTimeoutMs)
|
|
81
|
+
connectTimer.unref()
|
|
82
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
83
|
+
if (signal.aborted) abort()
|
|
84
|
+
} catch (error) { finishError(error.code) }
|
|
85
|
+
})
|
|
86
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Agent, request as httpsRequest } from 'node:https'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { attemptBrokerHttps } from './broker-transport-attempt.mjs'
|
|
4
|
+
|
|
5
|
+
export const BROKER_TRANSPORT_TIMEOUT_MS = 120_000
|
|
6
|
+
export const BROKER_TRANSPORT_CONNECT_TIMEOUT_MS = 8_000
|
|
7
|
+
export const BROKER_TRANSPORT_MAX_ATTEMPTS = 3
|
|
8
|
+
export const BROKER_TRANSPORT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
9
|
+
const RETRY_DELAY_MS = 150
|
|
10
|
+
const PROXY_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy']
|
|
11
|
+
const TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH'])
|
|
12
|
+
const CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,79}$/
|
|
13
|
+
|
|
14
|
+
export class BrokerTransportError extends Error {
|
|
15
|
+
constructor(code, transport) {
|
|
16
|
+
const safeCode = typeof code === 'string' && CODE_PATTERN.test(code) ? code : 'BROKER_TRANSPORT_FAILED'
|
|
17
|
+
super(`HTTPS broker transport failed (${safeCode})`)
|
|
18
|
+
this.name = 'BrokerTransportError'
|
|
19
|
+
this.code = safeCode
|
|
20
|
+
this.transport = Object.freeze({ ...transport })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Native Agent proxyEnv: https://nodejs.org/api/https.html#new-agentoptions */
|
|
25
|
+
export function brokerProxyEnvironment(environment, nodeVersion = process.versions.node) {
|
|
26
|
+
const proxyEnv = {}
|
|
27
|
+
for (const key of PROXY_KEYS) {
|
|
28
|
+
if (environment[key] !== undefined) {
|
|
29
|
+
if (typeof environment[key] !== 'string') throw new Error('BROKER_PROXY_ENV_INVALID')
|
|
30
|
+
proxyEnv[key] = environment[key]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const hasProxy = PROXY_KEYS.slice(0, 4).some(key => typeof proxyEnv[key] === 'string' && proxyEnv[key].trim())
|
|
34
|
+
const version = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(nodeVersion)
|
|
35
|
+
if (!version) throw new Error('BROKER_NODE_VERSION_INVALID')
|
|
36
|
+
const major = Number(version[1]), minor = Number(version[2])
|
|
37
|
+
const supportsProxy = major > 24 || major === 24 && minor >= 5 || major === 22 && minor >= 21
|
|
38
|
+
if (hasProxy && !supportsProxy) throw new Error('BROKER_PROXY_UNSUPPORTED_NODE')
|
|
39
|
+
for (const key of PROXY_KEYS.slice(0, 4)) {
|
|
40
|
+
if (!proxyEnv[key]) continue
|
|
41
|
+
let proxy
|
|
42
|
+
try { proxy = new URL(proxyEnv[key]) } catch { throw new Error('BROKER_PROXY_ENV_INVALID') }
|
|
43
|
+
if (!['http:', 'https:'].includes(proxy.protocol)) throw new Error('BROKER_PROXY_ENV_INVALID')
|
|
44
|
+
}
|
|
45
|
+
return supportsProxy ? { proxyEnv } : {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requestInput(url, options) {
|
|
49
|
+
const target = new URL(url)
|
|
50
|
+
if (target.protocol !== 'https:' || target.username || target.password) throw new Error('BROKER_HTTPS_REQUIRED')
|
|
51
|
+
const method = options.method === undefined ? 'GET' : options.method
|
|
52
|
+
if (typeof method !== 'string' || !/^[A-Z]+$/.test(method)) throw new Error('BROKER_METHOD_INVALID')
|
|
53
|
+
const headers = new Headers(options.headers)
|
|
54
|
+
headers.set('accept-encoding', 'identity')
|
|
55
|
+
const source = options.body
|
|
56
|
+
if (source !== undefined && source !== null && typeof source !== 'string' && !(source instanceof Uint8Array)) {
|
|
57
|
+
throw new Error('BROKER_BODY_INVALID')
|
|
58
|
+
}
|
|
59
|
+
const body = source === undefined || source === null ? undefined : Buffer.from(source)
|
|
60
|
+
if (body !== undefined) headers.set('content-length', String(body.length))
|
|
61
|
+
return { target, method, headers: Object.fromEntries(headers), body }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function overallSignal(external) {
|
|
65
|
+
const controller = new AbortController()
|
|
66
|
+
const abort = () => controller.abort(external.reason)
|
|
67
|
+
if (external?.aborted) controller.abort(external.reason)
|
|
68
|
+
else external?.addEventListener('abort', abort, { once: true })
|
|
69
|
+
const timer = setTimeout(() => controller.abort(new Error('ETIMEDOUT')), BROKER_TRANSPORT_TIMEOUT_MS)
|
|
70
|
+
timer.unref()
|
|
71
|
+
return { signal: controller.signal, cleanup() {
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
external?.removeEventListener('abort', abort)
|
|
74
|
+
} }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Dependencies support isolated native TLS tests; production callers supply only environment. */
|
|
78
|
+
export function createBrokerTransport({ environment = process.env, request = httpsRequest,
|
|
79
|
+
createAgent = options => new Agent(options), nodeVersion = process.versions.node } = {}) {
|
|
80
|
+
return async function brokerRequest(url, options = {}) {
|
|
81
|
+
const state = { attempts: 0, stage: 'configuration', submitted: false }
|
|
82
|
+
let configured
|
|
83
|
+
try { configured = { ...requestInput(url, options), agentOptions: brokerProxyEnvironment(environment, nodeVersion) } }
|
|
84
|
+
catch (error) { throw new BrokerTransportError(error.message, state) }
|
|
85
|
+
const overall = overallSignal(options.signal)
|
|
86
|
+
try {
|
|
87
|
+
for (let number = 1; number <= BROKER_TRANSPORT_MAX_ATTEMPTS; number += 1) {
|
|
88
|
+
if (overall.signal.aborted) throw new BrokerTransportError('ABORT_ERR', state)
|
|
89
|
+
state.attempts = number
|
|
90
|
+
state.stage = 'connecting'
|
|
91
|
+
try {
|
|
92
|
+
return await attemptBrokerHttps(configured, { request, createAgent, signal: overall.signal, state,
|
|
93
|
+
connectTimeoutMs: BROKER_TRANSPORT_CONNECT_TIMEOUT_MS, maxResponseBytes: BROKER_TRANSPORT_MAX_RESPONSE_BYTES,
|
|
94
|
+
failure: code => new BrokerTransportError(code, state) })
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const failure = error instanceof BrokerTransportError ? error : new BrokerTransportError(error.code, state)
|
|
97
|
+
const reconnect = !state.submitted && !overall.signal.aborted && TRANSIENT_CODES.has(failure.code)
|
|
98
|
+
&& number < BROKER_TRANSPORT_MAX_ATTEMPTS
|
|
99
|
+
if (!reconnect) throw failure
|
|
100
|
+
try { await delay(RETRY_DELAY_MS, undefined, { signal: overall.signal }) }
|
|
101
|
+
catch { throw new BrokerTransportError('ABORT_ERR', state) }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new BrokerTransportError('BROKER_TRANSPORT_ATTEMPTS_EXHAUSTED', state)
|
|
105
|
+
} finally { overall.cleanup() }
|
|
106
|
+
}
|
|
107
|
+
}
|
package/broker.mjs
CHANGED
|
@@ -2,6 +2,9 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
2
2
|
import { constants } from 'node:fs'
|
|
3
3
|
import { lstat, open } from 'node:fs/promises'
|
|
4
4
|
import { resolve, win32 } from 'node:path'
|
|
5
|
+
import { OfficialSkillInvocationError, OfficialSkillResponseError, transportFailureCode, transportDiagnostics } from './broker-failures.mjs'
|
|
6
|
+
import { queryOfficialSkillReceipt, SKILL_RECEIPT_HEADER, SKILL_RECEIPT_SCHEMA } from './broker-recovery.mjs'
|
|
7
|
+
export { officialSkillFailureResponse, transportFailureCode } from './broker-failures.mjs'
|
|
5
8
|
|
|
6
9
|
export const LOOKUP_TIMEOUT_MS = 8000
|
|
7
10
|
export const CALL_TIMEOUT_MS = 120_000
|
|
@@ -24,20 +27,6 @@ const VALIDATION_STATES = new Set(['passed', 'failed', 'incomplete'])
|
|
|
24
27
|
const EVALUATION_SCHEMA = 'skill-automatic-evaluation/1.0'
|
|
25
28
|
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
26
29
|
const REQUEST_SCHEMA_PATTERN = /^([A-Za-z0-9.-]+\.skill)\.request\/([0-9]+\.[0-9]+)$/
|
|
27
|
-
const TRANSPORT_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/
|
|
28
|
-
const NETWORK_TRANSPORT_ERROR = 'NETWORK_TRANSPORT'
|
|
29
|
-
const SKILL_INVOCATION_ERROR = 'SKILL_INVOCATION_FAILED'
|
|
30
|
-
|
|
31
|
-
class OfficialSkillInvocationError extends Error {
|
|
32
|
-
constructor(context, operation, transportCode) {
|
|
33
|
-
super(`${context.displayName} ${operation} invocation failed: network transport ${transportCode}`)
|
|
34
|
-
this.name = 'OfficialSkillInvocationError'
|
|
35
|
-
this.code = NETWORK_TRANSPORT_ERROR
|
|
36
|
-
this.operation = operation
|
|
37
|
-
this.retryable = false
|
|
38
|
-
this.transportCode = transportCode
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
30
|
|
|
42
31
|
function asObject(value, label) {
|
|
43
32
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -214,12 +203,21 @@ export function authoritativeEvaluation(value, expected) {
|
|
|
214
203
|
return evaluation
|
|
215
204
|
}
|
|
216
205
|
|
|
217
|
-
async function responsePayload(response,
|
|
206
|
+
async function responsePayload(response, context, request) {
|
|
207
|
+
const label = `${context.displayName} ${request.operation} response`
|
|
208
|
+
let payload
|
|
218
209
|
try {
|
|
219
|
-
|
|
220
|
-
} catch {
|
|
221
|
-
|
|
210
|
+
payload = await response.json()
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof SyntaxError) {
|
|
213
|
+
throw new OfficialSkillResponseError(request, 'response-parse', `${label} is not valid JSON (HTTP ${response.status})`)
|
|
214
|
+
}
|
|
215
|
+
throw new OfficialSkillInvocationError(context, request, transportFailureCode(error), 'response-body', error?.transport)
|
|
216
|
+
}
|
|
217
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
218
|
+
throw new OfficialSkillResponseError(request, 'response-validation', `${label} must be an object`)
|
|
222
219
|
}
|
|
220
|
+
return payload
|
|
223
221
|
}
|
|
224
222
|
|
|
225
223
|
function invocationRequest(context, operation, input) {
|
|
@@ -236,43 +234,6 @@ function invocationRequest(context, operation, input) {
|
|
|
236
234
|
}
|
|
237
235
|
}
|
|
238
236
|
|
|
239
|
-
export function transportFailureCode(error) {
|
|
240
|
-
const inspected = new Set()
|
|
241
|
-
let candidate = error
|
|
242
|
-
while (candidate && typeof candidate === 'object' && !inspected.has(candidate)) {
|
|
243
|
-
inspected.add(candidate)
|
|
244
|
-
const code = typeof candidate.code === 'string' ? candidate.code.trim() : ''
|
|
245
|
-
if (TRANSPORT_ERROR_CODE_PATTERN.test(code)) return code
|
|
246
|
-
const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''
|
|
247
|
-
if (name === 'AbortError' || name === 'TimeoutError') return name
|
|
248
|
-
candidate = candidate.cause
|
|
249
|
-
}
|
|
250
|
-
return 'UNKNOWN_TRANSPORT_ERROR'
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export function officialSkillFailureResponse(error) {
|
|
254
|
-
if (error instanceof OfficialSkillInvocationError) {
|
|
255
|
-
return {
|
|
256
|
-
ok: false,
|
|
257
|
-
error: {
|
|
258
|
-
code: error.code,
|
|
259
|
-
message: error.message,
|
|
260
|
-
operation: error.operation,
|
|
261
|
-
retryable: error.retryable,
|
|
262
|
-
transportCode: error.transportCode,
|
|
263
|
-
},
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return {
|
|
267
|
-
ok: false,
|
|
268
|
-
error: {
|
|
269
|
-
code: SKILL_INVOCATION_ERROR,
|
|
270
|
-
message: error instanceof Error ? error.message : 'Skill invocation failed',
|
|
271
|
-
retryable: false,
|
|
272
|
-
},
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
237
|
export async function invokeOfficialSkill(context, operation, input, dependencies) {
|
|
277
238
|
const environment = asObject(dependencies.environment, 'broker environment')
|
|
278
239
|
if (typeof dependencies.request !== 'function') {
|
|
@@ -284,17 +245,64 @@ export async function invokeOfficialSkill(context, operation, input, dependencie
|
|
|
284
245
|
try {
|
|
285
246
|
response = await dependencies.request(context.endpoint, {
|
|
286
247
|
method: 'POST',
|
|
287
|
-
headers: { 'Content-Type': 'application/json', Authorization: authorization
|
|
248
|
+
headers: { 'Content-Type': 'application/json', Authorization: authorization,
|
|
249
|
+
[SKILL_RECEIPT_HEADER]: SKILL_RECEIPT_SCHEMA },
|
|
288
250
|
body: JSON.stringify({ input: requestEnvelope }),
|
|
289
251
|
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
290
252
|
})
|
|
291
253
|
} catch (error) {
|
|
292
|
-
|
|
254
|
+
const failure = new OfficialSkillInvocationError(context, requestEnvelope, transportFailureCode(error), 'request', error?.transport)
|
|
255
|
+
return recoverTransportFailure(context, requestEnvelope, dependencies, authorization, failure)
|
|
256
|
+
}
|
|
257
|
+
let payload
|
|
258
|
+
try {
|
|
259
|
+
payload = await responsePayload(response, context, requestEnvelope)
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (!(error instanceof OfficialSkillInvocationError)) throw error
|
|
262
|
+
return recoverTransportFailure(context, requestEnvelope, dependencies, authorization, error)
|
|
293
263
|
}
|
|
294
|
-
const payload = await responsePayload(response, `${context.displayName} ${operation} response`)
|
|
295
264
|
if (!response.ok || payload.ok !== true) {
|
|
296
|
-
throw new
|
|
265
|
+
throw new OfficialSkillResponseError(requestEnvelope, 'http-response', `${context.displayName} ${operation} failed: HTTP ${response.status}`)
|
|
297
266
|
}
|
|
267
|
+
try {
|
|
268
|
+
const invocation = validateInvocationResponse(context, operation, payload, requestEnvelope)
|
|
269
|
+
const transport = transportDiagnostics(response.transport)
|
|
270
|
+
return transport ? { ...invocation, transport } : invocation
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new OfficialSkillResponseError(requestEnvelope, 'response-validation',
|
|
273
|
+
error instanceof Error ? error.message : 'Skill response validation failed')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function recoverTransportFailure(context, request, dependencies, authorization, failure) {
|
|
278
|
+
if (failure.transport?.submitted === false) throw failure
|
|
279
|
+
try {
|
|
280
|
+
const invocation = await queryOfficialSkillReceipt(context, request, dependencies, authorization,
|
|
281
|
+
(payload) => validateInvocationResponse(context, request.operation, payload, request))
|
|
282
|
+
return failure.transport ? { ...invocation, transport: failure.transport } : invocation
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (!(error instanceof OfficialSkillResponseError) || error.code !== 'SKILL_INVOCATION_UNCERTAIN') throw error
|
|
285
|
+
failure.recovery = { status: error.receiptStatus, message: error.message }
|
|
286
|
+
throw failure
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function recoverOfficialSkill(context, operation, requestId, dependencies) {
|
|
291
|
+
const normalizedOperation = requiredString(operation, 'skill operation')
|
|
292
|
+
const normalizedRequestId = requiredString(requestId, 'skill requestId')
|
|
293
|
+
if (!IDENTIFIER_PATTERN.test(normalizedOperation) || !IDENTIFIER_PATTERN.test(normalizedRequestId)) {
|
|
294
|
+
throw new Error('skill recovery identity is invalid')
|
|
295
|
+
}
|
|
296
|
+
expectedResponseSchema(context.schemaVersion)
|
|
297
|
+
const environment = asObject(dependencies.environment, 'broker environment')
|
|
298
|
+
if (typeof dependencies.request !== 'function') throw new Error('broker request dependency is required')
|
|
299
|
+
const authorization = await brainClientAuthorization(context, environment, dependencies.credentialAccess)
|
|
300
|
+
const request = { schemaVersion: context.schemaVersion, requestId: normalizedRequestId, operation: normalizedOperation }
|
|
301
|
+
return queryOfficialSkillReceipt(context, request, dependencies, authorization,
|
|
302
|
+
(payload) => validateInvocationResponse(context, normalizedOperation, payload, request))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function validateInvocationResponse(context, operation, payload, requestEnvelope) {
|
|
298
306
|
const invocationId = payload.feedbackInvocationId
|
|
299
307
|
if (typeof invocationId !== 'string' || !INVOCATION_PATTERN.test(invocationId)) {
|
|
300
308
|
throw new Error(`${context.displayName} ${operation} response is missing a valid feedbackInvocationId`)
|
package/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { TASKS_USAGE, runTasksCli } from './aimlock-tasks-cli.mjs'
|
|
2
3
|
import { realpathSync } from 'node:fs'
|
|
3
4
|
import { dirname, resolve } from 'node:path'
|
|
4
5
|
import { cwd, stdin, stdout } from 'node:process'
|
|
@@ -95,7 +96,7 @@ export function localAimlockApplicability(facts) {
|
|
|
95
96
|
export function aimlockUsage(context) {
|
|
96
97
|
const usage = defaultUsage(context)
|
|
97
98
|
if (!usage.includes(COMMON_RUN_USAGE)) throw new Error('Shared CLI run usage contract changed')
|
|
98
|
-
return usage.replace(COMMON_RUN_USAGE, AIMLOCK_RUN_USAGE) + '\n\n' + CHAIN_USAGE
|
|
99
|
+
return usage.replace(COMMON_RUN_USAGE, AIMLOCK_RUN_USAGE) + '\n\n' + CHAIN_USAGE + '\n\n' + TASKS_USAGE
|
|
99
100
|
+ '\n\nRead-time renewal: local budget-auto-renew-request <repositoryRoot> prepares one Confirm Protocol approval;'
|
|
100
101
|
+ '\nlocal budget-auto-renew activates the approved chain/scope/policy; budget-auto-renew-stop revokes or completes it.'
|
|
101
102
|
}
|
|
@@ -196,6 +197,8 @@ const cliPath = fileURLToPath(import.meta.url)
|
|
|
196
197
|
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === cliPath) {
|
|
197
198
|
if (process.argv[2] === 'brain') {
|
|
198
199
|
await runBrainCli(process.argv.slice(3))
|
|
200
|
+
} else if (process.argv[2] === 'tasks') {
|
|
201
|
+
await runTasksCli(process.argv.slice(3))
|
|
199
202
|
} else if (process.argv[2] === 'chain') {
|
|
200
203
|
await runChainCli(process.argv.slice(3))
|
|
201
204
|
} else if (process.argv[2] === 'local') {
|
package/installer.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
brokerCommandInput,
|
|
14
14
|
invokeCommandInput,
|
|
15
15
|
invokeOfficialSkill,
|
|
16
|
+
recoverOfficialSkill,
|
|
16
17
|
officialSkillFailureResponse,
|
|
17
18
|
} from './broker.mjs'
|
|
18
19
|
|
|
@@ -26,9 +27,12 @@ export {
|
|
|
26
27
|
callOfficialSkill,
|
|
27
28
|
invokeCommandInput,
|
|
28
29
|
invokeOfficialSkill,
|
|
30
|
+
recoverOfficialSkill,
|
|
29
31
|
officialSkillFailureResponse,
|
|
30
32
|
} from './broker.mjs'
|
|
31
33
|
|
|
34
|
+
import { createBrokerTransport } from './broker-transport.mjs'
|
|
35
|
+
|
|
32
36
|
const INSTALL_META = 'install-meta.json'
|
|
33
37
|
const BROKER_STDIN_MAX_BYTES = 1_048_576
|
|
34
38
|
|
|
@@ -91,7 +95,8 @@ export function installTarget(skillName, explicit) {
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
export async function fetchLatestVersion(context) {
|
|
94
|
-
const
|
|
98
|
+
const request = createBrokerTransport({ environment: process.env })
|
|
99
|
+
const response = await request(context.latestEndpoint, { signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) })
|
|
95
100
|
if (!response.ok) throw new Error(`cli.tax skill lookup failed: HTTP ${response.status}`)
|
|
96
101
|
const data = asObject(await response.json(), 'cli.tax skill lookup')
|
|
97
102
|
return {
|
|
@@ -162,6 +167,8 @@ export function defaultUsage(context, extraLines) {
|
|
|
162
167
|
' Invoke through the restricted local broker; a valid real HTTP invocation submits one authority-bound evaluation.',
|
|
163
168
|
` npx ${context.npmName}@latest broker`,
|
|
164
169
|
' Read one {"operation":"...","input":{...}} request from JSON stdin.',
|
|
170
|
+
` npx ${context.npmName}@latest recover <operation> <requestId>`,
|
|
171
|
+
' Query an uncertain invocation without resending or charging again.',
|
|
165
172
|
'Credential: CLITAX_BRAIN_CLIENT_TOKEN_FILE (the broker reads it; never pass the token).',
|
|
166
173
|
`Endpoint: ${context.endpoint}`,
|
|
167
174
|
]
|
|
@@ -170,7 +177,7 @@ export function defaultUsage(context, extraLines) {
|
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
function brokerDependencies() {
|
|
173
|
-
return { environment: process.env, request:
|
|
180
|
+
return { environment: process.env, request: createBrokerTransport({ environment: process.env }) }
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
async function readBrokerSource(input) {
|
|
@@ -201,6 +208,19 @@ async function runBrokerInvocation(context, commandInput) {
|
|
|
201
208
|
}
|
|
202
209
|
}
|
|
203
210
|
|
|
211
|
+
async function runBrokerRecovery(context, args) {
|
|
212
|
+
if (args.length !== 3) throw new Error('recover requires operation and original requestId')
|
|
213
|
+
try {
|
|
214
|
+
const invocation = await recoverOfficialSkill(context, args[1], args[2], brokerDependencies())
|
|
215
|
+
console.log(JSON.stringify(invocation))
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const response = officialSkillFailureResponse(error)
|
|
218
|
+
console.log(JSON.stringify({ response }))
|
|
219
|
+
console.error(response.error.message)
|
|
220
|
+
process.exitCode = 1
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
204
224
|
export async function runIntakeHandshake(context, spec) {
|
|
205
225
|
const invocation = await invokeOfficialSkill(context, 'capabilities', {}, brokerDependencies())
|
|
206
226
|
const capabilities = invocation.response
|
|
@@ -253,6 +273,7 @@ export async function dispatchOfficialSkillCli(options) {
|
|
|
253
273
|
if (command === 'install') await installOfficialSkill(context, argument)
|
|
254
274
|
else if (command === 'check') await checkOfficialSkill(context, argument)
|
|
255
275
|
else if (command === 'run') await options.runCommand(context)
|
|
276
|
+
else if (command === 'recover') await runBrokerRecovery(context, args)
|
|
256
277
|
else if (command === 'invoke') await runBrokerInvocation(context, invokeCommandInput(args))
|
|
257
278
|
else if (command === 'broker') {
|
|
258
279
|
await runBrokerInvocation(context, brokerCommandInput(await readBrokerSource(stdin)))
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"cli-aimlock": "./cli.mjs"
|
|
4
4
|
},
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"cli-swarm": "7.0.
|
|
6
|
+
"cli-swarm": "7.0.38"
|
|
7
7
|
},
|
|
8
8
|
"description": "Aimlock skill installer for CLI.Tax: lock a user request into an executable aim and route Blueprint, Swarm, and Calctool.",
|
|
9
9
|
"exports": {
|
|
@@ -11,12 +11,17 @@
|
|
|
11
11
|
"./local-runner": "./aimlock-local-runner.mjs",
|
|
12
12
|
"./runtime": "./aimlock-runtime.mjs",
|
|
13
13
|
"./chain-executor": "./aimlock-chain-executor.mjs",
|
|
14
|
-
"./brain-client": "./brain-client.mjs"
|
|
14
|
+
"./brain-client": "./brain-client.mjs",
|
|
15
|
+
"./tasks": "./aimlock-tasks-cli.mjs"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"cli.mjs",
|
|
18
19
|
"installer.mjs",
|
|
19
20
|
"broker.mjs",
|
|
21
|
+
"broker-failures.mjs",
|
|
22
|
+
"broker-recovery.mjs",
|
|
23
|
+
"broker-transport.mjs",
|
|
24
|
+
"broker-transport-attempt.mjs",
|
|
20
25
|
"README.md",
|
|
21
26
|
"skill/SKILL.md",
|
|
22
27
|
"skill/skill.json",
|
|
@@ -27,6 +32,7 @@
|
|
|
27
32
|
"aimlock-chain-human.mjs",
|
|
28
33
|
"aimlock-chain-executor.mjs",
|
|
29
34
|
"aimlock-chain-cli.mjs",
|
|
35
|
+
"aimlock-tasks-cli.mjs",
|
|
30
36
|
"aimlock-chain-outcomes.mjs",
|
|
31
37
|
"aimlock-context-map.mjs",
|
|
32
38
|
"aimlock-coordination.mjs",
|
|
@@ -40,7 +46,8 @@
|
|
|
40
46
|
"aimlock-runtime.mjs",
|
|
41
47
|
"brain-client.mjs",
|
|
42
48
|
"brain-client-files.mjs",
|
|
43
|
-
"skill/references/chain-executor.md"
|
|
49
|
+
"skill/references/chain-executor.md",
|
|
50
|
+
"skill/references/task-routing.md"
|
|
44
51
|
],
|
|
45
52
|
"license": "UNLICENSED",
|
|
46
53
|
"name": "cli-aimlock",
|
|
@@ -49,5 +56,5 @@
|
|
|
49
56
|
"url": "https://github.com/88208555/aimlock-clitax.git"
|
|
50
57
|
},
|
|
51
58
|
"type": "module",
|
|
52
|
-
"version": "7.0.
|
|
59
|
+
"version": "7.0.38"
|
|
53
60
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: "Aimlock 仅用于大型、深度、跨模块、高风险、需要
|
|
|
5
5
|
|
|
6
6
|
# Aimlock Skill
|
|
7
7
|
|
|
8
|
-
Package version: v7.0.
|
|
8
|
+
Package version: v7.0.38
|
|
9
9
|
|
|
10
10
|
Endpoint: https://cli.tax/R3mQ8kWpXn
|
|
11
11
|
|
|
@@ -129,7 +129,7 @@ Call `interrupt` before acting on an interruption:
|
|
|
129
129
|
- forced stop → `stop`;
|
|
130
130
|
- status query → `status`;
|
|
131
131
|
- related addition → `fuse`;
|
|
132
|
-
- unrelated request → `spawn
|
|
132
|
+
- unrelated request → first query existing task ownership through `tasks message-route`; reuse its owner when resolved. Unresolved work remains a recorded pending request. Legacy `spawn` is a recommendation only and must not replace the current goal or create an unauthorized conversation.
|
|
133
133
|
|
|
134
134
|
For an active incomplete goal, the caller sends exactly every 90 seconds:
|
|
135
135
|
|
|
@@ -188,3 +188,19 @@ Aimlock returns the protocol; it does not start a timer.
|
|
|
188
188
|
4. 运行 `npx cli-aimlock@latest brain check <repositoryRoot> <handoff.json>` 执行批准的检查并回传产物哈希和结果。普通 IDE 回传属于 client-reported,不能据此声称可信验证通过。
|
|
189
189
|
5. 只有已批准的可信 runner 生成与本次计划和报告绑定的签名收据后,才运行 `npx cli-aimlock@latest brain validate <repositoryRoot> <validation.json>`。没有可信收据时保持已回传状态,不伪造验证。
|
|
190
190
|
6. 请求发送后结果不确定时,先用 `brain status <repositoryRoot> <status.json>` 按 requestId 或 planId 查询;禁止自动重发规划或重复计费。
|
|
191
|
+
|
|
192
|
+
## 网络中断与原回执恢复
|
|
193
|
+
|
|
194
|
+
仅在 TLS 握手前确定尚未发送 HTTP 请求时,broker 才允许最多 3 次连接尝试,并受总超时约束。请求发出后发生断线或响应中断,只用 GET 查询原 requestId 的服务端回执,禁止重发 POST;未取得有效回执时保留不确定状态,不得假定成功或继续依赖步骤。
|
|
195
|
+
|
|
196
|
+
`npx cli-aimlock@latest recover <operation> <requestId>` 可重新查询原调用,不会重做操作或重复计费。链恢复不会跳过人工确认,也不会自动重跑结果不确定的本地命令。代理连接需 Node.js 22.21+ 或 24.5+;不支持的运行时会明确报错。
|
|
197
|
+
|
|
198
|
+
## 新消息归属与原任务连续性
|
|
199
|
+
|
|
200
|
+
多任务协作时先登记任务目标、原始验收项和宿主定位;每条用户新需求在执行前使用 [task-routing.md](references/task-routing.md) 的 `task-describe → message-route → message-accept → task-resume` 协议。先查已有任务归属,再决定当前任务补充、转交、歧义暂存或显式接手。同一条消息的独立需求分别路由,不能用新消息覆盖原目标。
|
|
201
|
+
|
|
202
|
+
转交只是消息状态,不等于原任务完成。接收方按 requestId 去重,只有持久接收回执才算接手;来源任务继续不依赖该交接的工作。退出、上下文压缩或宿主重启后,必须先读取 `task-resume` 恢复原目标、检查点、未完成项与待投递请求。
|
|
203
|
+
|
|
204
|
+
用户明确要求在当前任务处理时,保留原目标;已由其他任务负责的范围需 `handoff-release` 完成安全交接,再重新验证 Aimlock 范围、快照与写入权限。禁止把别人的签名租约、预算或通过证据当作当前任务的新授权。归属不明只暂存新需求,不暂停原任务。
|
|
205
|
+
|
|
206
|
+
这些操作使用同一协调根目录的持久台账。宿主必须在新消息入口调用并消费结果;技能不能拦截未接入的 IDE,也不会自动创建会话、Git 分支、常驻服务或跨机器复制私密消息。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Aimlock task message entry
|
|
2
|
+
|
|
3
|
+
Use `cli-aimlock tasks capabilities <coordinationRoot>` to discover the task registration, routing, acknowledgement, handoff and resume schemas. JSON inputs use stdin. This entry calls the installed `cli-swarm/coordinator` directly and preserves its persistent ledger.
|
|
4
|
+
|
|
5
|
+
Before executing each new user requirement, restore the original task with `task-resume`, classify distinct items and call `message-route`. Reuse an existing owner when identified. Preserve uncertain requests and the current task's unfinished requirements. Do not append new user messages as replacement execution plans or overwrite chain state.
|
|
6
|
+
|
|
7
|
+
The installed Swarm reference `references/task-routing.md` defines the shared protocol, matching evidence, receipt validation and handoff lifecycle. Do not duplicate or maintain a separate routing algorithm in Aimlock.
|
|
8
|
+
|
|
9
|
+
The `cli-aimlock/tasks` export provides `handleTaskMessage(root,input,adapter,options)`. The IDE supplies an authorized destination-aware `deliver` function and `continueTask`. Each delivery must return the destination's persisted acceptance receipt. Atomic delivery claims prevent blind retries after process interruption. The helper checks authoritative message status and reports explicit failures; only a runnable source task continues. Delivery is bounded to 30 seconds unless the host sets a positive `options.deliveryTimeoutMs`. Timeouts preserve uncertain delivery without claiming it was cancelled; a runnable original task still continues.
|
|
10
|
+
|
|
11
|
+
Explicit forced assignment must preserve the current task checkpoint. Complete `handoff-release` at the previous owner's safe boundary, then obtain a fresh Aimlock contract/snapshot/pass for the receiving scope. After completion, the previous owner uses `handoff-resume` to refresh actual file fingerprints before rebuilding its own snapshot and continuing. Credentials, budgets and previous test receipts are never transferred as new authorization.
|
|
12
|
+
|
|
13
|
+
Status questions and explicit stop/cancel retain their existing host behavior. New requirements alone never cancel an unfinished goal. Each host must call this entry at its message boundary and consume pending inboxes after restart. The package cannot intercept unrelated hosts, create tasks or Git branches, install a background service, or grant access to unrelated accounts.
|
package/skill/skill.json
CHANGED