cli-aimlock 7.0.35 → 7.0.37
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 +8 -2
- package/aimlock-chain-calls.mjs +24 -5
- package/aimlock-chain-cli.mjs +2 -1
- package/aimlock-chain-executor.mjs +28 -12
- package/aimlock-chain-model.mjs +1 -1
- package/aimlock-chain-store.mjs +2 -0
- package/aimlock-local-runner.mjs +9 -173
- package/aimlock-read-budget-renewal.mjs +198 -0
- package/aimlock-read-budget-schemas.mjs +31 -0
- package/aimlock-read-budget-state.mjs +71 -0
- package/aimlock-read-budget.mjs +127 -0
- package/aimlock-runtime.mjs +2 -2
- 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 +8 -0
- package/installer.mjs +23 -2
- package/package.json +10 -2
- package/skill/SKILL.md +24 -4
- package/skill/skill.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ When active, Aimlock uses Lock for one file through 500 changed lines and Probe
|
|
|
23
23
|
- 把需求锁成可执行目标,阻止思考漂移、执行漂移、范围膨胀
|
|
24
24
|
- Bypass / Lock / Probe / Swarm 分档:小改绕过;深度修改才进入门禁
|
|
25
25
|
- 改前文件快照,禁止创建 git 分支
|
|
26
|
-
- Lock / Probe / Swarm 分别强制 3 / 10 / 30 个文件与 2 / 8 /
|
|
26
|
+
- Lock / Probe / Swarm 分别强制 3 / 10 / 30 个文件与 2 / 8 / 60 分钟读取预算;Probe、Swarm 另有限制 30K / 100K 估算 token
|
|
27
27
|
- Ed25519 写入凭证绑定 chainId、快照摘要、路径集合和最长 300 秒有效期
|
|
28
28
|
- `probe.targetSymbols` 可从新鲜的 ContextBase 项目地图解析真实目标文件;缺失、歧义或陈旧条目直接阻断
|
|
29
29
|
- 服务端按当前需求实时发现专项技能,只返回命中项;非计算需求不出现 Calctool
|
|
@@ -43,7 +43,7 @@ cli-aimlock local gate-issue .
|
|
|
43
43
|
cli-aimlock local guarded-write .
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
每个命令从 stdin 读取 JSON
|
|
46
|
+
每个命令从 stdin 读取 JSON。读取预算使用进程间原子锁,耗尽后只允许执行、输出方案或明确阻塞;文件/token 追加预算必须携带 Confirm Protocol 的低风险确认回执。长任务可先用 `budget-auto-renew-request` 生成目标、路径、续期间隔与次数上限,再凭一次真实回执调用 `budget-auto-renew`;同任务后续只自动续时间,累计额度与每次续期保留审计。达到次数/文件/token 上限仍明确阻断。撤销或完成时调用 `budget-auto-renew-stop`;本地执行链成功也会关闭预算。无凭证写入仅豁免 `.aimlock/logs/` 与 `.aimlock/tmp/`。
|
|
47
47
|
|
|
48
48
|
Swarm 模式下,`chain-plan` 会在 `swarm` 前插入 `coordinator.conflict-scan`。`gate-issue` 必须显式声明 `coordinationRequired`;为 true 时凭证绑定 `.coord/leases/` 中的签名文件锁,`guarded-write` 在同一拦截点同时校验门禁与活动租约。存在活动 `dependency-wait` 的 chain 会被 `budget-read` 拒绝。
|
|
49
49
|
|
|
@@ -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)
|
|
@@ -4,7 +4,7 @@ import { fail } from './aimlock-local-fs.mjs'
|
|
|
4
4
|
import { bindInput, errorRecord, validatePlan } from './aimlock-chain-model.mjs'
|
|
5
5
|
import { assertNewExecution, executionStatus, initialState, loadExecution, recoverInterrupted,
|
|
6
6
|
saveExecution, withExecutionLock } from './aimlock-chain-store.mjs'
|
|
7
|
-
import { callCommand, callCoordinator, callSkill, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
|
|
7
|
+
import { callCommand, callCoordinator, callSkill, recoverSkillCall, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
|
|
8
8
|
import { answerPending, prepareHuman } from './aimlock-chain-human.mjs'
|
|
9
9
|
import { skillOutcome } from './aimlock-chain-outcomes.mjs'
|
|
10
10
|
|
|
@@ -149,6 +149,20 @@ async function coordinatorStep(session, step, input) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function finishSkillStep(session, step, output) {
|
|
153
|
+
if (step.skillId === 'confirm-protocol' && step.operation === 'interaction-request' && output.status === 'succeeded') {
|
|
154
|
+
session.record.status = 'waiting'
|
|
155
|
+
session.record.output = output
|
|
156
|
+
session.record.pending = { kind: 'human', interaction: output.interaction, waitInput: null,
|
|
157
|
+
presentation: output.chatFallback, response: output,
|
|
158
|
+
continueWhen: Object.hasOwn(step, 'continueWhen') ? step.continueWhen : null }
|
|
159
|
+
} else {
|
|
160
|
+
const outcome = skillOutcome(step, output)
|
|
161
|
+
finish(session.record, output, outcome.status)
|
|
162
|
+
session.record.error = outcome.error
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
152
166
|
async function executeStep(session, step) {
|
|
153
167
|
if (!await readyForWork(session, step)) return
|
|
154
168
|
const input = bindInput(step, session.state)
|
|
@@ -164,17 +178,7 @@ async function executeStep(session, step) {
|
|
|
164
178
|
} else if (step.kind === 'coordinator') await coordinatorStep(session, step, input)
|
|
165
179
|
else {
|
|
166
180
|
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
|
-
}
|
|
181
|
+
finishSkillStep(session, step, output)
|
|
178
182
|
}
|
|
179
183
|
await saveExecution(session.file, session.state)
|
|
180
184
|
}
|
|
@@ -186,6 +190,18 @@ async function advance(root, file, state, dependencies) {
|
|
|
186
190
|
if (record.status === 'blocked' && step.kind === 'coordinator' && step.operation === 'lock-acquire'
|
|
187
191
|
&& record.output?.status === 'queued' && record.input) saveQueuePending(record, record.output, record.input)
|
|
188
192
|
const session = { root, file, state, record, dependencies }
|
|
193
|
+
if (record.status === 'uncertain' && step.kind === 'skill'
|
|
194
|
+
&& record.calls.at(-1)?.operation === step.operation) {
|
|
195
|
+
try {
|
|
196
|
+
finishSkillStep(session, step, await recoverSkillCall(session, step.skillId))
|
|
197
|
+
await saveExecution(file, state)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
await failedStep(session, error)
|
|
200
|
+
return state
|
|
201
|
+
}
|
|
202
|
+
if (record.status === 'succeeded') continue
|
|
203
|
+
return state
|
|
204
|
+
}
|
|
189
205
|
if (['failed', 'uncertain'].includes(record.status)) return state
|
|
190
206
|
if (record.status === 'waiting' && record.pending?.kind === 'human') return state
|
|
191
207
|
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-chain-store.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'
|
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
5
|
import { atomicJson, ensureManagedDirectory, fail, identifier, repositoryRoot, resolvedProjectPath } from './aimlock-local-fs.mjs'
|
|
6
6
|
import { STATE_SCHEMA, chainStatus, planDigest, validatePlan } from './aimlock-chain-model.mjs'
|
|
7
|
+
import { completeReadBudgetIfExists } from './aimlock-read-budget-renewal.mjs'
|
|
7
8
|
|
|
8
9
|
const LOCK_WAIT_MS = 2_000
|
|
9
10
|
const LOCK_POLL_MS = 20
|
|
@@ -114,6 +115,7 @@ export async function saveExecution(file, state) {
|
|
|
114
115
|
state.updatedAt = new Date().toISOString()
|
|
115
116
|
state.status = chainStatus(state)
|
|
116
117
|
await atomicJson(file, state)
|
|
118
|
+
if (state.status === 'succeeded') await completeReadBudgetIfExists(resolve(dirname(file), '../../..'), state.chainId)
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
export async function loadExecution(file) {
|
package/aimlock-local-runner.mjs
CHANGED
|
@@ -3,22 +3,15 @@ import {
|
|
|
3
3
|
lstat,
|
|
4
4
|
readFile,
|
|
5
5
|
readdir,
|
|
6
|
-
writeFile,
|
|
7
6
|
} from 'node:fs/promises'
|
|
8
7
|
import { dirname, extname, relative, resolve } from 'node:path'
|
|
9
8
|
import { promisify } from 'node:util'
|
|
10
9
|
import {
|
|
11
10
|
LOCAL_SCHEMA,
|
|
12
11
|
appendAudit,
|
|
13
|
-
atomicJson,
|
|
14
|
-
ensureManagedDirectory,
|
|
15
12
|
fail,
|
|
16
|
-
identifier,
|
|
17
|
-
managedPath,
|
|
18
13
|
repositoryRoot,
|
|
19
14
|
resolvedProjectPath,
|
|
20
|
-
safeRelativePath,
|
|
21
|
-
withFileLock,
|
|
22
15
|
} from './aimlock-local-fs.mjs'
|
|
23
16
|
import {
|
|
24
17
|
PASS_SCHEMA,
|
|
@@ -27,24 +20,19 @@ import {
|
|
|
27
20
|
verifyMutationPassFile,
|
|
28
21
|
} from './aimlock-local-gate.mjs'
|
|
29
22
|
import { resolveContextMapTargets } from './aimlock-context-map.mjs'
|
|
30
|
-
import {
|
|
23
|
+
import { BUDGET_SCHEMA, TOKEN_ESTIMATE_ALGORITHM, READ_BUDGETS, initializeReadBudget } from './aimlock-read-budget-state.mjs'
|
|
24
|
+
import { checkCachedReadAccess, extendReadBudget, readBudgetStatus, readFileWithinBudget } from './aimlock-read-budget.mjs'
|
|
25
|
+
import { authorizeReadBudgetRenewal, requestReadBudgetRenewal, stopReadBudgetRenewal } from './aimlock-read-budget-renewal.mjs'
|
|
26
|
+
import { AUTO_RENEW_OPERATION_SCHEMAS } from './aimlock-read-budget-schemas.mjs'
|
|
31
27
|
|
|
32
28
|
const execFile = promisify(execFileCallback)
|
|
33
|
-
const BUDGET_SCHEMA = 'aimlock.read-budget/1.0'
|
|
34
|
-
const CONFIRMATION_SCHEMA = 'confirm-protocol.skill.response/1.0'
|
|
35
29
|
const MAX_DISCOVERED_FILES = 1_000
|
|
36
30
|
const MAX_SOURCE_BYTES = 1_048_576
|
|
37
|
-
const TOKEN_ESTIMATE_ALGORITHM = 'utf8-bytes-div-4-ceil'
|
|
38
31
|
const SOURCE_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx'])
|
|
39
32
|
const IGNORED_DIRECTORIES = new Set([
|
|
40
33
|
'.aimlock', '.git', '.runtime', 'coverage', 'dist', 'node_modules',
|
|
41
34
|
])
|
|
42
35
|
const MODE_ORDER = Object.freeze(['lock', 'probe', 'swarm'])
|
|
43
|
-
const READ_BUDGETS = Object.freeze({
|
|
44
|
-
lock: Object.freeze({ maxFiles: 3, maxTokenEstimate: null, maxDurationMs: 120_000 }),
|
|
45
|
-
probe: Object.freeze({ maxFiles: 10, maxTokenEstimate: 30_000, maxDurationMs: 480_000 }),
|
|
46
|
-
swarm: Object.freeze({ maxFiles: 30, maxTokenEstimate: 100_000, maxDurationMs: 900_000 }),
|
|
47
|
-
})
|
|
48
36
|
const HIGH_RISK_PATTERN = /生产数据|支付|用户隐私|密码|密钥|凭证|线上环境|production/i
|
|
49
37
|
const IMPORT_PATTERN = /(?:import|export)\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
50
38
|
const schema = (required, properties) => ({ type: 'object', additionalProperties: false,
|
|
@@ -67,6 +55,7 @@ const BUDGET_CONFIRMATION_SCHEMA = schema(['schemaVersion', 'requestId', 'status
|
|
|
67
55
|
}), nextStep: objectValueSchema,
|
|
68
56
|
})
|
|
69
57
|
const LOCAL_OPERATION_SCHEMAS = Object.freeze({
|
|
58
|
+
...AUTO_RENEW_OPERATION_SCHEMAS,
|
|
70
59
|
capabilities: schema([], {}),
|
|
71
60
|
probe: schema(['goal', 'targetHints'], { goal: stringSchema, targetHints: stringArraySchema,
|
|
72
61
|
targetSymbols: { type: 'array', items: objectValueSchema } }),
|
|
@@ -266,167 +255,11 @@ function reassessMode(input) {
|
|
|
266
255
|
}
|
|
267
256
|
}
|
|
268
257
|
|
|
269
|
-
async function readBudget(root, chainId) {
|
|
270
|
-
const id = identifier(chainId, 'chainId')
|
|
271
|
-
const path = managedPath(root, 'runs', id, 'read-budget.json')
|
|
272
|
-
const state = JSON.parse(await readFile(path, 'utf8'))
|
|
273
|
-
if (state.schemaVersion !== BUDGET_SCHEMA || state.chainId !== id) {
|
|
274
|
-
fail('AIMLOCK_BUDGET_INVALID', 'read budget authority is invalid')
|
|
275
|
-
}
|
|
276
|
-
return { path, state }
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function budgetView(state, now = Date.now()) {
|
|
280
|
-
const elapsedMs = now - Date.parse(state.startedAt)
|
|
281
|
-
const remainingFiles = Math.max(0, state.maxFiles - state.uniqueFiles.length)
|
|
282
|
-
const remainingTokenEstimate = state.maxTokenEstimate === null ? null
|
|
283
|
-
: Math.max(0, state.maxTokenEstimate - state.tokenEstimate)
|
|
284
|
-
const remainingDurationMs = Math.max(0, state.maxDurationMs - elapsedMs)
|
|
285
|
-
const decisionRequired = remainingFiles === 0 || remainingDurationMs === 0
|
|
286
|
-
|| remainingTokenEstimate === 0
|
|
287
|
-
return { ...state, elapsedMs, remainingFiles, remainingTokenEstimate, remainingDurationMs,
|
|
288
|
-
decisionRequired, nextActions: decisionRequired ? ['execute', 'plan', 'blocked'] : [] }
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function initializeReadBudget(input) {
|
|
292
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
293
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
294
|
-
const limits = READ_BUDGETS[input.mode]
|
|
295
|
-
if (!limits) fail('AIMLOCK_MODE_INVALID', 'mode must be lock, probe, or swarm')
|
|
296
|
-
const directory = await ensureManagedDirectory(root, 'runs', chainId)
|
|
297
|
-
const state = {
|
|
298
|
-
schemaVersion: BUDGET_SCHEMA,
|
|
299
|
-
chainId,
|
|
300
|
-
mode: input.mode,
|
|
301
|
-
startedAt: new Date().toISOString(),
|
|
302
|
-
...limits,
|
|
303
|
-
uniqueFiles: [],
|
|
304
|
-
readCalls: 0,
|
|
305
|
-
tokenEstimate: 0,
|
|
306
|
-
tokenEstimateAlgorithm: TOKEN_ESTIMATE_ALGORITHM,
|
|
307
|
-
extensions: [],
|
|
308
|
-
}
|
|
309
|
-
await writeFile(resolve(directory, 'read-budget.json'), `${JSON.stringify(state)}\n`, {
|
|
310
|
-
flag: 'wx', mode: 0o600,
|
|
311
|
-
})
|
|
312
|
-
await appendAudit(root, { event: 'read-budget-initialized', chainId, mode: input.mode })
|
|
313
|
-
return budgetView(state)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
async function readFileWithinBudget(input) {
|
|
317
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
318
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
319
|
-
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
320
|
-
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
321
|
-
return withFileLock(budgetPath, async () => {
|
|
322
|
-
const authority = await readBudget(root, chainId)
|
|
323
|
-
const path = safeRelativePath(input.path)
|
|
324
|
-
const state = authority.state
|
|
325
|
-
const before = budgetView(state)
|
|
326
|
-
if (before.remainingDurationMs === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read deadline exhausted')
|
|
327
|
-
const isNew = !state.uniqueFiles.includes(path)
|
|
328
|
-
if (isNew && before.remainingFiles === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read file budget exhausted')
|
|
329
|
-
const projectFile = await resolvedProjectPath(root, path)
|
|
330
|
-
if (!projectFile.status.isFile()) fail('AIMLOCK_READ_NOT_FILE', `${path} is not a file`)
|
|
331
|
-
const tokenEstimate = Math.ceil(projectFile.status.size / 4)
|
|
332
|
-
if (before.remainingTokenEstimate !== null && tokenEstimate > before.remainingTokenEstimate) {
|
|
333
|
-
fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
334
|
-
}
|
|
335
|
-
const content = await readFile(projectFile.target, 'utf8')
|
|
336
|
-
const updated = {
|
|
337
|
-
...state,
|
|
338
|
-
uniqueFiles: isNew ? [...state.uniqueFiles, path] : state.uniqueFiles,
|
|
339
|
-
readCalls: state.readCalls + 1,
|
|
340
|
-
tokenEstimate: state.tokenEstimate + tokenEstimate,
|
|
341
|
-
}
|
|
342
|
-
await atomicJson(authority.path, updated)
|
|
343
|
-
await appendAudit(root, { event: 'read-consumed', chainId, path, tokenEstimate })
|
|
344
|
-
return { schemaVersion: LOCAL_SCHEMA, path, content, budget: budgetView(updated) }
|
|
345
|
-
})
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function checkCachedReadAccess(input) {
|
|
349
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
350
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
351
|
-
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
352
|
-
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
353
|
-
return withFileLock(budgetPath, async () => {
|
|
354
|
-
const { state } = await readBudget(root, chainId)
|
|
355
|
-
const path = safeRelativePath(input.path)
|
|
356
|
-
const budget = budgetView(state)
|
|
357
|
-
if (budget.remainingDurationMs === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read deadline exhausted')
|
|
358
|
-
if (budget.remainingTokenEstimate === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
359
|
-
if (!state.uniqueFiles.includes(path)) fail('AIMLOCK_CACHE_UNCHARGED', 'cached source was not read by this chain')
|
|
360
|
-
return { schemaVersion: LOCAL_SCHEMA, path, budget }
|
|
361
|
-
})
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async function readBudgetStatus(input) {
|
|
365
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
366
|
-
return budgetView((await readBudget(root, input.chainId)).state)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function confirmedBudgetExtension(input) {
|
|
370
|
-
const confirmation = input.confirmation
|
|
371
|
-
const audit = confirmation?.auditEntry
|
|
372
|
-
const callback = confirmation?.callbackRequest
|
|
373
|
-
const payload = callback?.payload
|
|
374
|
-
const fields = ['files', 'tokenEstimate', 'durationMs']
|
|
375
|
-
if (!confirmation || confirmation.schemaVersion !== CONFIRMATION_SCHEMA || confirmation.status !== 'succeeded'
|
|
376
|
-
|| audit?.schemaVersion !== 'confirm.audit-entry/1.0' || audit.risk !== 'low' || audit.answer !== 'approve'
|
|
377
|
-
|| typeof audit.remembered !== 'boolean' || !Number.isFinite(Date.parse(audit.answeredAt))
|
|
378
|
-
|| callback?.operation !== 'budget-extend' || payload?.answer !== 'approve'
|
|
379
|
-
|| payload.chainId !== input.chainId || payload.requestId !== audit.requestId
|
|
380
|
-
|| !payload.additions || fields.some((key) => payload.additions[key] !== input.additions?.[key])) {
|
|
381
|
-
fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a low-risk Confirm Protocol interaction-answer bound to this chain and exact additions is required')
|
|
382
|
-
}
|
|
383
|
-
identifier(audit.actorId, 'actorId')
|
|
384
|
-
identifier(audit.requestId, 'requestId')
|
|
385
|
-
return identifier(audit.auditId, 'auditId')
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function extendReadBudget(input) {
|
|
389
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
390
|
-
const confirmationId = confirmedBudgetExtension(input)
|
|
391
|
-
const additions = input.additions
|
|
392
|
-
if (!additions || !Number.isSafeInteger(additions.files) || additions.files < 0
|
|
393
|
-
|| !Number.isSafeInteger(additions.tokenEstimate) || additions.tokenEstimate < 0
|
|
394
|
-
|| !Number.isSafeInteger(additions.durationMs) || additions.durationMs < 0
|
|
395
|
-
|| additions.files + additions.tokenEstimate + additions.durationMs === 0) {
|
|
396
|
-
fail('AIMLOCK_EXTENSION_INVALID', 'budget additions must contain a positive integer increase')
|
|
397
|
-
}
|
|
398
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
399
|
-
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
400
|
-
return withFileLock(budgetPath, async () => {
|
|
401
|
-
const authority = await readBudget(root, chainId)
|
|
402
|
-
const state = authority.state
|
|
403
|
-
if (state.extensions.some((item) => item.confirmationId === confirmationId)) {
|
|
404
|
-
fail('AIMLOCK_CONFIRMATION_REPLAYED', 'this budget confirmation has already been applied')
|
|
405
|
-
}
|
|
406
|
-
const updated = {
|
|
407
|
-
...state,
|
|
408
|
-
maxFiles: state.maxFiles + additions.files,
|
|
409
|
-
maxTokenEstimate: state.maxTokenEstimate === null && additions.tokenEstimate === 0
|
|
410
|
-
? null : (state.maxTokenEstimate ?? 0) + additions.tokenEstimate,
|
|
411
|
-
maxDurationMs: state.maxDurationMs + additions.durationMs,
|
|
412
|
-
extensions: [...state.extensions, {
|
|
413
|
-
confirmationId,
|
|
414
|
-
additions,
|
|
415
|
-
at: new Date().toISOString(),
|
|
416
|
-
}],
|
|
417
|
-
}
|
|
418
|
-
await atomicJson(authority.path, updated)
|
|
419
|
-
await appendAudit(root, { event: 'read-budget-extended', chainId,
|
|
420
|
-
confirmationId, additions })
|
|
421
|
-
return budgetView(updated)
|
|
422
|
-
})
|
|
423
|
-
}
|
|
424
|
-
|
|
425
258
|
const LOCAL_CAPABILITIES = Object.freeze({
|
|
426
259
|
schemaVersion: LOCAL_SCHEMA,
|
|
427
260
|
operations: Object.freeze([
|
|
428
261
|
'capabilities', 'probe', 'reassess', 'budget-init', 'budget-read', 'budget-status',
|
|
429
|
-
'budget-extend', 'gate-issue', 'gate-verify', 'guarded-write',
|
|
262
|
+
'budget-extend', 'budget-auto-renew-request', 'budget-auto-renew', 'budget-auto-renew-stop', 'gate-issue', 'gate-verify', 'guarded-write',
|
|
430
263
|
]),
|
|
431
264
|
operationSchemas: LOCAL_OPERATION_SCHEMAS,
|
|
432
265
|
writeBoundary: 'Only writes routed through guarded-write are physically intercepted. The IDE host must route batch writes through this runner.',
|
|
@@ -442,6 +275,9 @@ export {
|
|
|
442
275
|
LOCAL_SCHEMA,
|
|
443
276
|
PASS_SCHEMA,
|
|
444
277
|
READ_BUDGETS,
|
|
278
|
+
authorizeReadBudgetRenewal,
|
|
279
|
+
requestReadBudgetRenewal,
|
|
280
|
+
stopReadBudgetRenewal,
|
|
445
281
|
checkCachedReadAccess,
|
|
446
282
|
extendReadBudget,
|
|
447
283
|
guardedWriteFile,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { appendAudit, atomicJson, fail, identifier, managedPath, repositoryRoot,
|
|
4
|
+
resolvedProjectPath, safeRelativePath, sha256, withFileLock } from './aimlock-local-fs.mjs'
|
|
5
|
+
import { readBudget, budgetView, requiredRenewalIntervals } from './aimlock-read-budget-state.mjs'
|
|
6
|
+
|
|
7
|
+
const RENEWAL_SCHEMA = 'aimlock.read-budget-auto-renew/1.0'
|
|
8
|
+
const MIN_INTERVAL_MS = 60_000
|
|
9
|
+
const MAX_INTERVAL_MS = 86_400_000
|
|
10
|
+
const MAX_RENEWALS = 1_000
|
|
11
|
+
const RENEWAL_LIMITS = Object.freeze({ minIntervalMs: MIN_INTERVAL_MS,
|
|
12
|
+
maxIntervalMs: MAX_INTERVAL_MS, maxRenewals: MAX_RENEWALS })
|
|
13
|
+
const RENEW_OPERATION = 'budget-auto-renew'
|
|
14
|
+
const RENEWAL_REASON = 'authorized-read-deadline-expired'
|
|
15
|
+
|
|
16
|
+
function renewalTerms(input) {
|
|
17
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
18
|
+
const scope = input.scope
|
|
19
|
+
const policy = input.policy
|
|
20
|
+
if (!scope || typeof scope.goal !== 'string' || !scope.goal.trim()
|
|
21
|
+
|| !Array.isArray(scope.allowedPaths) || scope.allowedPaths.length === 0) {
|
|
22
|
+
fail('AIMLOCK_RENEWAL_SCOPE_INVALID', 'a goal and explicit repository-relative allowedPaths are required')
|
|
23
|
+
}
|
|
24
|
+
const paths = scope.allowedPaths.map((path) => {
|
|
25
|
+
const value = safeRelativePath(path)
|
|
26
|
+
if (/[*?\[\]{}]/.test(value)) fail('AIMLOCK_RENEWAL_SCOPE_INVALID', 'scope paths must be literal files or directories')
|
|
27
|
+
return value
|
|
28
|
+
})
|
|
29
|
+
if (!policy || !Number.isSafeInteger(policy.intervalMs) || policy.intervalMs < MIN_INTERVAL_MS
|
|
30
|
+
|| policy.intervalMs > MAX_INTERVAL_MS || !Number.isSafeInteger(policy.maxRenewals)
|
|
31
|
+
|| policy.maxRenewals < 1 || policy.maxRenewals > MAX_RENEWALS) {
|
|
32
|
+
fail('AIMLOCK_RENEWAL_POLICY_INVALID', 'intervalMs must be 60000..86400000; maxRenewals must be 1..1000')
|
|
33
|
+
}
|
|
34
|
+
const normalizedScope = { goal: scope.goal.trim(), allowedPaths: [...new Set(paths)].sort() }
|
|
35
|
+
return { chainId, scope: normalizedScope, scopeDigest: sha256(JSON.stringify(normalizedScope)),
|
|
36
|
+
policy: { intervalMs: policy.intervalMs, maxRenewals: policy.maxRenewals } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renewalQuestion(terms) {
|
|
40
|
+
return `Allow automatic read-time renewal for chain ${terms.chainId}? Goal: ${terms.scope.goal}. `
|
|
41
|
+
+ `Read scope: ${terms.scope.allowedPaths.join(', ')}. Each renewal: ${terms.policy.intervalMs} ms; `
|
|
42
|
+
+ `maximum: ${terms.policy.maxRenewals}; total additional time: ${terms.policy.intervalMs * terms.policy.maxRenewals} ms. `
|
|
43
|
+
+ 'File, token and write limits remain unchanged. Revocation or task completion stops automatic renewal.'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function requestReadBudgetRenewal(input) {
|
|
47
|
+
const terms = renewalTerms(input)
|
|
48
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
49
|
+
const { state } = await readBudget(root, terms.chainId)
|
|
50
|
+
await assertReadBudgetActive(root, state)
|
|
51
|
+
const requestId = identifier(input.requestId, 'requestId')
|
|
52
|
+
return { schemaVersion: 'confirm.interaction/1.0', requestId, type: 'confirm',
|
|
53
|
+
question: renewalQuestion(terms), options: [{ id: 'approve', label: 'Approve' }, { id: 'decline', label: 'Decline' }],
|
|
54
|
+
default: null, timeout: null, timeoutAction: 'wait', risk: 'low', riskDescription: '',
|
|
55
|
+
rememberable: false, memoryKey: '', callback: { operation: RENEW_OPERATION,
|
|
56
|
+
payload: { chainId: terms.chainId, scopeDigest: terms.scopeDigest, policy: terms.policy } } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renewalReceipt(input, terms) {
|
|
60
|
+
const response = input.confirmation
|
|
61
|
+
const audit = response?.auditEntry
|
|
62
|
+
const callback = response?.callbackRequest
|
|
63
|
+
const payload = callback?.payload
|
|
64
|
+
if (response?.schemaVersion !== 'confirm-protocol.skill.response/1.0' || response.status !== 'succeeded'
|
|
65
|
+
|| audit?.schemaVersion !== 'confirm.audit-entry/1.0' || audit.risk !== 'low'
|
|
66
|
+
|| audit.answer !== 'approve' || audit.remembered !== false || !Number.isFinite(Date.parse(audit.answeredAt))
|
|
67
|
+
|| audit.question !== renewalQuestion(terms) || callback?.operation !== RENEW_OPERATION
|
|
68
|
+
|| payload?.answer !== 'approve' || payload.chainId !== terms.chainId || payload.requestId !== audit.requestId
|
|
69
|
+
|| payload.scopeDigest !== terms.scopeDigest || payload.policy?.intervalMs !== terms.policy.intervalMs
|
|
70
|
+
|| payload.policy?.maxRenewals !== terms.policy.maxRenewals) {
|
|
71
|
+
fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a one-time Confirm Protocol approval bound to the chain, goal, scope and exact renewal policy is required')
|
|
72
|
+
}
|
|
73
|
+
identifier(response.requestId, 'confirmation.requestId')
|
|
74
|
+
return { confirmationId: identifier(audit.auditId, 'auditId'), actorId: identifier(audit.actorId, 'actorId'),
|
|
75
|
+
requestId: identifier(audit.requestId, 'requestId'), authorizedAt: audit.answeredAt }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function authorizeReadBudgetRenewal(input) {
|
|
79
|
+
const terms = renewalTerms(input)
|
|
80
|
+
const receipt = renewalReceipt(input, terms)
|
|
81
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
82
|
+
const budgetPath = managedPath(root, 'runs', terms.chainId, 'read-budget.json')
|
|
83
|
+
return withFileLock(budgetPath, async () => {
|
|
84
|
+
const { state } = await readBudget(root, terms.chainId)
|
|
85
|
+
await assertReadBudgetActive(root, state)
|
|
86
|
+
if (state.autoRenew) {
|
|
87
|
+
const replayed = state.autoRenew.confirmationId === receipt.confirmationId
|
|
88
|
+
fail(replayed ? 'AIMLOCK_CONFIRMATION_REPLAYED' : 'AIMLOCK_RENEWAL_ALREADY_AUTHORIZED',
|
|
89
|
+
'this chain already has an immutable renewal authorization; use the existing policy or an explicit budget extension')
|
|
90
|
+
}
|
|
91
|
+
for (const path of terms.scope.allowedPaths) {
|
|
92
|
+
const target = await resolvedProjectPath(root, path, { allowMissing: true })
|
|
93
|
+
if (relative(root, target.target).split('\\').join('/') !== path) {
|
|
94
|
+
fail('AIMLOCK_RENEWAL_SCOPE_INVALID', 'authorized paths must use their canonical repository location')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const updated = { ...state, autoRenew: { schemaVersion: RENEWAL_SCHEMA,
|
|
98
|
+
...terms, ...receipt, status: 'active', renewalCount: 0, totalRenewedMs: 0, renewals: [] } }
|
|
99
|
+
await atomicJson(budgetPath, updated)
|
|
100
|
+
await appendAudit(root, { event: 'read-budget-auto-renew-authorized', ...terms, ...receipt })
|
|
101
|
+
return budgetView(updated)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function executionCompleted(root, chainId) {
|
|
106
|
+
let source
|
|
107
|
+
try {
|
|
108
|
+
source = await readFile(managedPath(root, 'executions', chainId, 'state.json'), 'utf8')
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code === 'ENOENT') return false
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
const execution = JSON.parse(source)
|
|
114
|
+
if (execution.chainId !== chainId || typeof execution.status !== 'string') {
|
|
115
|
+
fail('AIMLOCK_CHAIN_STATE_INVALID', 'execution lifecycle does not match the read-budget chain')
|
|
116
|
+
}
|
|
117
|
+
return execution.status === 'succeeded'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function assertReadBudgetActive(root, state) {
|
|
121
|
+
if (state.completedAt || await executionCompleted(root, state.chainId)) {
|
|
122
|
+
fail('AIMLOCK_BUDGET_COMPLETED', 'this task is complete; read access and automatic renewal have stopped')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertRenewalScope(state, path, canonicalPath) {
|
|
127
|
+
if (!state.autoRenew) return
|
|
128
|
+
const paths = state.autoRenew.scope.allowedPaths
|
|
129
|
+
const covered = (target) => paths.some((allowed) => target === allowed || target.startsWith(`${allowed}/`))
|
|
130
|
+
if (!covered(path) || !covered(canonicalPath)) {
|
|
131
|
+
fail('AIMLOCK_RENEWAL_SCOPE_MISMATCH', 'source path is outside this task\'s approved read scope')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function renewReadBudgetTime(root, authority, path) {
|
|
136
|
+
const state = authority.state
|
|
137
|
+
const now = Date.now()
|
|
138
|
+
const budget = budgetView(state, now)
|
|
139
|
+
if (budget.remainingDurationMs > 0) return state
|
|
140
|
+
const renewal = state.autoRenew
|
|
141
|
+
if (!renewal || renewal.status !== 'active') {
|
|
142
|
+
fail('AIMLOCK_DECISION_REQUIRED', `read deadline exhausted; automatic renewal ${renewal ? 'was stopped' : 'requires explicit approval via budget-auto-renew-request'}`)
|
|
143
|
+
}
|
|
144
|
+
if (budget.remainingFiles === 0 || budget.remainingTokenEstimate === 0) {
|
|
145
|
+
fail('AIMLOCK_DECISION_REQUIRED', 'file or token budget exhausted; time renewal cannot expand these limits')
|
|
146
|
+
}
|
|
147
|
+
const count = requiredRenewalIntervals(state, now)
|
|
148
|
+
if (count > renewal.policy.maxRenewals - renewal.renewalCount) {
|
|
149
|
+
fail('AIMLOCK_DECISION_REQUIRED', `automatic renewal limit exhausted: ${renewal.renewalCount}/${renewal.policy.maxRenewals} used; ${count} additional intervals required`)
|
|
150
|
+
}
|
|
151
|
+
const at = new Date(now).toISOString()
|
|
152
|
+
const records = Array.from({ length: count }, (_, index) => ({
|
|
153
|
+
count: renewal.renewalCount + index + 1, reason: RENEWAL_REASON, at, path,
|
|
154
|
+
durationMs: renewal.policy.intervalMs,
|
|
155
|
+
maxDurationMs: state.maxDurationMs + (index + 1) * renewal.policy.intervalMs,
|
|
156
|
+
}))
|
|
157
|
+
const addedMs = count * renewal.policy.intervalMs
|
|
158
|
+
const updated = { ...state, maxDurationMs: state.maxDurationMs + addedMs,
|
|
159
|
+
autoRenew: { ...renewal, renewalCount: renewal.renewalCount + count,
|
|
160
|
+
totalRenewedMs: renewal.totalRenewedMs + addedMs, renewals: [...renewal.renewals, ...records] } }
|
|
161
|
+
await atomicJson(authority.path, updated)
|
|
162
|
+
for (const record of records) await appendAudit(root, { event: 'read-budget-auto-renewed',
|
|
163
|
+
chainId: state.chainId, confirmationId: renewal.confirmationId, scopeDigest: renewal.scopeDigest, ...record })
|
|
164
|
+
return updated
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function stopReadBudgetRenewal(input) {
|
|
168
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
169
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
170
|
+
if (!['revoked', 'completed'].includes(input.reason)) {
|
|
171
|
+
fail('AIMLOCK_RENEWAL_STOP_INVALID', 'reason must be revoked or completed')
|
|
172
|
+
}
|
|
173
|
+
return withFileLock(managedPath(root, 'runs', chainId, 'read-budget.json'), async () => {
|
|
174
|
+
const { state, path } = await readBudget(root, chainId)
|
|
175
|
+
if (state.completedAt || (input.reason === 'revoked' && state.autoRenew?.status === 'revoked')) return budgetView(state)
|
|
176
|
+
if (input.reason === 'revoked' && !state.autoRenew) {
|
|
177
|
+
fail('AIMLOCK_RENEWAL_NOT_AUTHORIZED', 'this task has no automatic renewal authorization to revoke')
|
|
178
|
+
}
|
|
179
|
+
const at = new Date().toISOString()
|
|
180
|
+
const updated = { ...state }
|
|
181
|
+
if (input.reason === 'completed') updated.completedAt = at
|
|
182
|
+
if (state.autoRenew) updated.autoRenew = { ...state.autoRenew, status: input.reason, stoppedAt: at }
|
|
183
|
+
await atomicJson(path, updated)
|
|
184
|
+
await appendAudit(root, { event: 'read-budget-auto-renew-stopped', chainId, reason: input.reason, at })
|
|
185
|
+
return budgetView(updated)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function completeReadBudgetIfExists(root, chainId) {
|
|
190
|
+
try { await readBudget(root, chainId) } catch (error) {
|
|
191
|
+
if (error.code === 'ENOENT') return
|
|
192
|
+
throw error
|
|
193
|
+
}
|
|
194
|
+
await stopReadBudgetRenewal({ repositoryRoot: root, chainId, reason: 'completed' })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { RENEWAL_LIMITS, authorizeReadBudgetRenewal, requestReadBudgetRenewal, stopReadBudgetRenewal,
|
|
198
|
+
assertReadBudgetActive, assertRenewalScope, renewReadBudgetTime, completeReadBudgetIfExists }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { RENEWAL_LIMITS } from './aimlock-read-budget-renewal.mjs'
|
|
2
|
+
|
|
3
|
+
const stringSchema = { type: 'string', minLength: 1 }
|
|
4
|
+
const objectSchema = (required, properties) => ({ type: 'object', additionalProperties: false, required, properties })
|
|
5
|
+
const scopeSchema = objectSchema(['goal', 'allowedPaths'], { goal: stringSchema,
|
|
6
|
+
allowedPaths: { type: 'array', minItems: 1, items: stringSchema } })
|
|
7
|
+
const policySchema = objectSchema(['intervalMs', 'maxRenewals'], {
|
|
8
|
+
intervalMs: { type: 'integer', minimum: RENEWAL_LIMITS.minIntervalMs, maximum: RENEWAL_LIMITS.maxIntervalMs },
|
|
9
|
+
maxRenewals: { type: 'integer', minimum: 1, maximum: RENEWAL_LIMITS.maxRenewals },
|
|
10
|
+
})
|
|
11
|
+
const confirmationSchema = objectSchema(['schemaVersion', 'requestId', 'status', 'callbackRequest', 'auditEntry', 'nextStep'], {
|
|
12
|
+
schemaVersion: { const: 'confirm-protocol.skill.response/1.0' }, requestId: stringSchema,
|
|
13
|
+
status: { const: 'succeeded' },
|
|
14
|
+
callbackRequest: objectSchema(['operation', 'payload'], { operation: { const: 'budget-auto-renew' },
|
|
15
|
+
payload: objectSchema(['chainId', 'scopeDigest', 'policy', 'requestId', 'answer'], {
|
|
16
|
+
chainId: stringSchema, scopeDigest: stringSchema, policy: policySchema, requestId: stringSchema, answer: { const: 'approve' },
|
|
17
|
+
}) }),
|
|
18
|
+
auditEntry: objectSchema(['schemaVersion', 'auditId', 'requestId', 'actorId', 'question', 'answer', 'remembered', 'risk', 'answeredAt'], {
|
|
19
|
+
schemaVersion: { const: 'confirm.audit-entry/1.0' }, auditId: stringSchema, requestId: stringSchema,
|
|
20
|
+
actorId: stringSchema, question: stringSchema, answer: { const: 'approve' }, remembered: { const: false },
|
|
21
|
+
risk: { const: 'low' }, answeredAt: { type: 'string', format: 'date-time' },
|
|
22
|
+
}), nextStep: { type: 'object' },
|
|
23
|
+
})
|
|
24
|
+
const terms = { chainId: stringSchema, scope: scopeSchema, policy: policySchema }
|
|
25
|
+
const AUTO_RENEW_OPERATION_SCHEMAS = Object.freeze({
|
|
26
|
+
'budget-auto-renew-request': objectSchema(['chainId', 'scope', 'policy', 'requestId'], { ...terms, requestId: stringSchema }),
|
|
27
|
+
'budget-auto-renew': objectSchema(['chainId', 'scope', 'policy', 'confirmation'], { ...terms, confirmation: confirmationSchema }),
|
|
28
|
+
'budget-auto-renew-stop': objectSchema(['chainId', 'reason'], { chainId: stringSchema, reason: { enum: ['revoked', 'completed'] } }),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export { AUTO_RENEW_OPERATION_SCHEMAS }
|