evolcore 0.0.19 → 0.0.20
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/CHANGELOG.md +21 -1
- package/README.md +2 -0
- package/bin/install-codex-managed-hooks.mjs +61 -0
- package/dist/agents/claude-runner.js +4 -3
- package/dist/agents/codex-runner.js +3 -2
- package/dist/agents/ecagent-runner.js +3 -2
- package/dist/aun/aid/agentmd.js +7 -0
- package/dist/aun/msg/group.js +14 -3
- package/dist/aun/msg/p2p.js +21 -11
- package/dist/aun/outbox.js +144 -19
- package/dist/channels/aun.js +621 -211
- package/dist/cli/daemon-commands.js +12 -6
- package/dist/cli/index.js +1 -0
- package/dist/cli/init.js +55 -15
- package/dist/cli/restart-monitor.js +3 -3
- package/dist/config/aun-gateway-config.js +2 -0
- package/dist/config/config-manager.js +92 -8
- package/dist/config/config-operation-service.js +1 -2
- package/dist/config/gateway-config.js +9 -7
- package/dist/config/lifecycle.js +16 -5
- package/dist/config-store.js +13 -6
- package/dist/core/auth/authorization-audit.js +5 -2
- package/dist/core/bootstrap-messages.js +2 -2
- package/dist/core/bootstrap-service.js +21 -36
- package/dist/core/channel-loader.js +0 -2
- package/dist/core/evolagent.js +5 -4
- package/dist/core/message/response-engine.js +9 -4
- package/dist/core/permission/ec-command-parser.js +56 -3
- package/dist/core/permission/sandbox-runtime.js +46 -12
- package/dist/core/permission/tool-policy.js +98 -41
- package/dist/core/relation/peer-identity.js +18 -0
- package/dist/eck/kit-renderer.js +17 -8
- package/dist/index.js +30 -19
- package/dist/utils/stats.js +52 -18
- package/dist/utils/welcome.js +2 -2
- package/kits/rules/01-overview.md +1 -1
- package/kits/rules/03-identity.md +1 -1
- package/kits/rules/05-venue.md +1 -1
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.10.json +2 -1
- package/kits/schemas/agent-config.schema.11.json +408 -0
- package/kits/schemas/daemon.schema.5.json +136 -0
- package/kits/schemas/defaults.schema.5.json +107 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
本文件记录 EvolCore 的重要变更。EvolClaw 版本线的历史记录已归档至
|
|
4
4
|
[`docs/_archive/CHANGELOG-evolclaw.md`](docs/_archive/CHANGELOG-evolclaw.md)。
|
|
5
5
|
|
|
6
|
+
## 0.0.20 (2026-08-28)
|
|
7
|
+
|
|
8
|
+
### AUN 网关与投递可靠性
|
|
9
|
+
|
|
10
|
+
- 接入 SDK 网关发现结果,校验消息 ID、投递状态和显式路由,区分永久失败与可重试失败。
|
|
11
|
+
- Outbox 保留永久失败诊断,并以路由条件更新避免并发投递覆盖修正后的目标。
|
|
12
|
+
|
|
13
|
+
### Bootstrap 与配置生命周期
|
|
14
|
+
|
|
15
|
+
- 收紧 Bootstrap 消息路由和 Agent 生命周期门禁,补充配置兼容校验及 `ec init` 的 ecagent 配置交互。
|
|
16
|
+
- 增加 Agent、Daemon 和默认配置 schema 版本,权限审计记录命中的策略路径。
|
|
17
|
+
|
|
18
|
+
### 观察者模式
|
|
19
|
+
|
|
20
|
+
- 观察者模式默认开启,显式设置 `false` 才关闭并持久化覆盖。
|
|
21
|
+
|
|
22
|
+
### 跨平台运行与安装
|
|
23
|
+
|
|
24
|
+
- 统一运行时根目录解析,延长 Windows Daemon IPC 优雅关闭等待,并完善 Codex 受管目录安装准备。
|
|
25
|
+
|
|
6
26
|
## 0.0.19 (2026-08-27)
|
|
7
27
|
|
|
8
28
|
### PowerShell EC 命令安全
|
|
@@ -18,7 +38,7 @@
|
|
|
18
38
|
### 跨平台安装与诊断
|
|
19
39
|
|
|
20
40
|
- Windows 数据迁移固定调用系统 `bsdtar`,避免 Git Bash GNU tar 误解析 Windows 绝对路径。
|
|
21
|
-
- Linux
|
|
41
|
+
- Linux 公开安装器与 npm 全局安装/升级均幂等创建真实 `/etc/codex` 目录;root 历史实例可在启动任务时自愈缺失目录,同名文件和符号链接仍被拒绝。
|
|
22
42
|
- 统一数据根解析,仅使用 `EVOLCORE_HOME` 或 `~/.evolcore`,避免因启动目录不同误用数据目录。
|
|
23
43
|
- `ec start` 已运行提示新增 Daemon 持续运行时长,并支持 IPC uptime 回退探测。
|
|
24
44
|
|
package/README.md
CHANGED
|
@@ -103,6 +103,8 @@ curl -fsSL https://download.evolai.cn/install.sh | bash
|
|
|
103
103
|
npm install -g evolcore
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Linux 全局安装会在 `postinstall` 阶段准备 Codex 受管运行所需的真实目录 `/etc/codex`。如果当前 npm 全局目录属于普通用户且该系统目录尚不存在,请先执行 `sudo install -d -m 0755 /etc/codex`,再重新安装。
|
|
107
|
+
|
|
106
108
|
> **Windows 用户**:首次运行前可能需要执行 `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`
|
|
107
109
|
|
|
108
110
|
**从源码安装**:
|
|
@@ -18,6 +18,59 @@ function fail(message) {
|
|
|
18
18
|
process.exit(1);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function isGlobalInstall() {
|
|
22
|
+
return ['1', 'true'].includes(String(process.env.npm_config_global || '').toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureRealDirectory(directory) {
|
|
26
|
+
let stat;
|
|
27
|
+
try {
|
|
28
|
+
stat = fs.lstatSync(directory);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== 'ENOENT') {
|
|
31
|
+
fail(`could not inspect the Codex managed requirements mountpoint ${directory}: ${error?.message || String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (stat) {
|
|
36
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
37
|
+
fail(`Codex managed requirements mountpoint is unsafe: ${directory} must be a real directory.`);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(directory, { mode: 0o755 });
|
|
44
|
+
fs.chmodSync(directory, 0o755);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === 'EEXIST') {
|
|
47
|
+
const raced = fs.lstatSync(directory);
|
|
48
|
+
if (raced.isDirectory() && !raced.isSymbolicLink()) return;
|
|
49
|
+
}
|
|
50
|
+
fail(
|
|
51
|
+
`Linux global installation requires the real directory ${directory}, but it could not be created: `
|
|
52
|
+
+ `${error?.message || String(error)}. `
|
|
53
|
+
+ `Create it first with: sudo install -d -m 0755 ${directory}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const created = fs.lstatSync(directory);
|
|
58
|
+
if (!created.isDirectory() || created.isSymbolicLink()) {
|
|
59
|
+
fail(`Codex managed requirements mountpoint provisioning failed: ${directory} must be a real directory.`);
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`[evolcore] Prepared Codex managed requirements mountpoint at ${directory}.\n`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runLinuxPostinstall() {
|
|
65
|
+
const explicitMountpoint = argument('--mountpoint');
|
|
66
|
+
if (process.platform !== 'linux') return;
|
|
67
|
+
// Local source installs do not launch the Linux Codex guard and must not
|
|
68
|
+
// mutate machine-wide configuration. An explicit path is accepted only for
|
|
69
|
+
// isolated lifecycle behavior tests.
|
|
70
|
+
if (!isGlobalInstall() && !explicitMountpoint) return;
|
|
71
|
+
ensureRealDirectory(explicitMountpoint || '/etc/codex');
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
function tomlString(value) {
|
|
22
75
|
return JSON.stringify(value);
|
|
23
76
|
}
|
|
@@ -139,6 +192,14 @@ try {
|
|
|
139
192
|
}
|
|
140
193
|
|
|
141
194
|
function main() {
|
|
195
|
+
if (process.env.npm_lifecycle_event === 'postinstall') {
|
|
196
|
+
runLinuxPostinstall();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (process.platform === 'linux' && argument('--mountpoint')) {
|
|
200
|
+
runLinuxPostinstall();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
142
203
|
if (process.platform !== 'win32' && !argument('--requirements')) {
|
|
143
204
|
fail('Codex managed hook provisioning is only supported on Windows.');
|
|
144
205
|
}
|
|
@@ -2041,7 +2041,7 @@ export class AgentRunner {
|
|
|
2041
2041
|
...(updatedInput ? { updatedInput } : {}),
|
|
2042
2042
|
},
|
|
2043
2043
|
});
|
|
2044
|
-
const recordPreflightDenial = async (toolName, toolInput, preflight, requestId, policyCode) => {
|
|
2044
|
+
const recordPreflightDenial = async (toolName, toolInput, preflight, requestId, policyCode, matchedPath) => {
|
|
2045
2045
|
const code = policyCode ?? (preflight?.behavior === 'deny' ? preflight.policyCode : undefined);
|
|
2046
2046
|
if (!code)
|
|
2047
2047
|
return;
|
|
@@ -2061,6 +2061,7 @@ export class AgentRunner {
|
|
|
2061
2061
|
selfAid: ctx?.selfAid,
|
|
2062
2062
|
requestId: typeof requestId === 'string' ? requestId : undefined,
|
|
2063
2063
|
taskId: ctx?.taskId,
|
|
2064
|
+
matchedPath: matchedPath ?? (preflight?.behavior === 'deny' ? preflight.matchedPath : undefined),
|
|
2064
2065
|
});
|
|
2065
2066
|
try {
|
|
2066
2067
|
await ctx?.recordExecutionAnomaly?.({
|
|
@@ -2186,7 +2187,7 @@ export class AgentRunner {
|
|
|
2186
2187
|
};
|
|
2187
2188
|
const roResult = checkReadonly(toolName, toolInput, projectPath, readonlyContext);
|
|
2188
2189
|
if (roResult.behavior === 'deny') {
|
|
2189
|
-
await recordPreflightDenial(toolName, toolInput, undefined, input.tool_use_id ?? input.toolUseID, roResult.policyCode ?? 'readonly_mode');
|
|
2190
|
+
await recordPreflightDenial(toolName, toolInput, undefined, input.tool_use_id ?? input.toolUseID, roResult.policyCode ?? 'readonly_mode', roResult.matchedPath);
|
|
2190
2191
|
return hookDecision('deny', roResult.message, updatedInput);
|
|
2191
2192
|
}
|
|
2192
2193
|
return hookDecision('allow', undefined, updatedInput);
|
|
@@ -2346,7 +2347,7 @@ export class AgentRunner {
|
|
|
2346
2347
|
};
|
|
2347
2348
|
const roResult = checkReadonly(toolName, input, projectPath, readonlyContext);
|
|
2348
2349
|
if (roResult.behavior === 'deny') {
|
|
2349
|
-
await recordPreflightDenial(toolName, input, undefined, options.toolUseID, roResult.policyCode ?? 'readonly_mode');
|
|
2350
|
+
await recordPreflightDenial(toolName, input, undefined, options.toolUseID, roResult.policyCode ?? 'readonly_mode', roResult.matchedPath);
|
|
2350
2351
|
return { behavior: 'deny', message: roResult.message, decisionClassification: 'user_reject' };
|
|
2351
2352
|
}
|
|
2352
2353
|
return { behavior: 'allow', updatedInput: input, decisionClassification: 'user_permanent' };
|
|
@@ -2356,7 +2356,7 @@ export class CodexRunner {
|
|
|
2356
2356
|
// per-session 权限模式(runQuery 写入);缺省回落实例级 currentMode(兼容无 runQuery 上下文的调用)
|
|
2357
2357
|
const rawMode = this.chatModes.get(sessionKey) ?? this.currentMode;
|
|
2358
2358
|
const mode = normalizePermissionMode(rawMode).mode;
|
|
2359
|
-
const recordBlockedOperation = async (policyCode, operationInput = toolInput, denialReason = 'policy denied') => {
|
|
2359
|
+
const recordBlockedOperation = async (policyCode, operationInput = toolInput, denialReason = 'policy denied', matchedPath) => {
|
|
2360
2360
|
const summary = summarizeToolInputForAudit(toolName, operationInput).slice(0, 512);
|
|
2361
2361
|
auditToolPreflightDenial({
|
|
2362
2362
|
toolName,
|
|
@@ -2372,6 +2372,7 @@ export class CodexRunner {
|
|
|
2372
2372
|
selfAid: permissionContext?.selfAid,
|
|
2373
2373
|
requestId: typeof requestId === 'string' || typeof requestId === 'number' ? String(requestId) : undefined,
|
|
2374
2374
|
taskId: permissionContext?.taskId,
|
|
2375
|
+
matchedPath,
|
|
2375
2376
|
});
|
|
2376
2377
|
try {
|
|
2377
2378
|
await permissionContext?.recordExecutionAnomaly?.({
|
|
@@ -2413,7 +2414,7 @@ export class CodexRunner {
|
|
|
2413
2414
|
logger.warn(`[CodexRunner] tool preflight denied: session=${sessionKey} tool=${toolName} ` +
|
|
2414
2415
|
`policy=${preflight.policyCode ?? 'unknown'} reason=${preflight.message ?? 'policy denied'}`);
|
|
2415
2416
|
if (preflight.policyCode) {
|
|
2416
|
-
await recordBlockedOperation(preflight.policyCode, toolInput, preflight.message ?? 'tool preflight denied');
|
|
2417
|
+
await recordBlockedOperation(preflight.policyCode, toolInput, preflight.message ?? 'tool preflight denied', preflight.matchedPath);
|
|
2417
2418
|
}
|
|
2418
2419
|
return 'deny';
|
|
2419
2420
|
}
|
|
@@ -987,7 +987,7 @@ export class EcagentRunner {
|
|
|
987
987
|
}
|
|
988
988
|
async authorizeTool(sessionId, projectPath, mode, toolName, input) {
|
|
989
989
|
const permissionContext = this.permissionContexts.get(sessionId);
|
|
990
|
-
const recordBlockedOperation = async (policyCode, operationInput = input) => {
|
|
990
|
+
const recordBlockedOperation = async (policyCode, operationInput = input, matchedPath) => {
|
|
991
991
|
const summary = summarizeToolInputForAudit(toolName, operationInput).slice(0, 512);
|
|
992
992
|
auditToolPreflightDenial({
|
|
993
993
|
toolName,
|
|
@@ -1002,6 +1002,7 @@ export class EcagentRunner {
|
|
|
1002
1002
|
role: permissionContext?.role,
|
|
1003
1003
|
selfAid: permissionContext?.selfAid,
|
|
1004
1004
|
taskId: permissionContext?.taskId,
|
|
1005
|
+
matchedPath,
|
|
1005
1006
|
});
|
|
1006
1007
|
try {
|
|
1007
1008
|
await permissionContext?.recordExecutionAnomaly?.({
|
|
@@ -1039,7 +1040,7 @@ export class EcagentRunner {
|
|
|
1039
1040
|
});
|
|
1040
1041
|
if (preflight.behavior === 'deny') {
|
|
1041
1042
|
if (preflight.policyCode) {
|
|
1042
|
-
await recordBlockedOperation(preflight.policyCode, input);
|
|
1043
|
+
await recordBlockedOperation(preflight.policyCode, input, preflight.matchedPath);
|
|
1043
1044
|
}
|
|
1044
1045
|
return { block: true, reason: preflight.message };
|
|
1045
1046
|
}
|
package/dist/aun/aid/agentmd.js
CHANGED
|
@@ -266,6 +266,13 @@ export async function agentmdSync(aid, opts) {
|
|
|
266
266
|
const ownStore = !opts?.store;
|
|
267
267
|
const localPath = agentMdPath(aid);
|
|
268
268
|
try {
|
|
269
|
+
// AIDStore owns the agent.md consistency API in fastaun 0.5.x. Keep this
|
|
270
|
+
// runtime guard because callers and test doubles can still accidentally
|
|
271
|
+
// pass an AUNClient (which deliberately does not expose these methods).
|
|
272
|
+
const candidate = store;
|
|
273
|
+
if (typeof candidate.checkAgentMd !== 'function' || typeof candidate.downloadAgentMd !== 'function') {
|
|
274
|
+
throw new TypeError('invalid AIDStore: checkAgentMd/downloadAgentMd unavailable');
|
|
275
|
+
}
|
|
269
276
|
const check = await store.checkAgentMd(aid, 30);
|
|
270
277
|
// In sync (cache fresh) — return local file content with cached verification status.
|
|
271
278
|
if (check.ok && !check.data.needs_update && check.data.local_found) {
|
package/dist/aun/msg/group.js
CHANGED
|
@@ -211,12 +211,23 @@ export async function groupSend(args) {
|
|
|
211
211
|
const encrypt = resolveAunMessageEncrypt(args.encrypt);
|
|
212
212
|
const sendParams = { group_id: args.groupId, payload, encrypt };
|
|
213
213
|
const result = await conn.call('group.send', sendParams);
|
|
214
|
-
const messageId = typeof result?.message?.message_id === 'string'
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
const messageId = typeof result?.message?.message_id === 'string' && result.message.message_id.trim()
|
|
215
|
+
? result.message.message_id
|
|
216
|
+
: typeof result?.message_id === 'string' && result.message_id.trim()
|
|
217
|
+
? result.message_id
|
|
218
|
+
: undefined;
|
|
219
|
+
if (!messageId) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: 'group.send returned no message_id',
|
|
223
|
+
code: 'MISSING_MESSAGE_ID',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
appendGroupSendOutboundLog(args, messageId, payload, runtimeContext, encrypt);
|
|
217
227
|
return {
|
|
218
228
|
ok: true,
|
|
219
229
|
group_id: result?.group_id ?? args.groupId,
|
|
230
|
+
message_id: messageId,
|
|
220
231
|
message: result?.message,
|
|
221
232
|
event: result?.event,
|
|
222
233
|
};
|
package/dist/aun/msg/p2p.js
CHANGED
|
@@ -289,24 +289,34 @@ export async function msgSend(args) {
|
|
|
289
289
|
const encrypt = resolveAunMessageEncrypt(args.encrypt);
|
|
290
290
|
const sendParams = { to: args.to, payload, encrypt };
|
|
291
291
|
const result = await conn.call('message.send', sendParams);
|
|
292
|
+
const messageId = typeof result?.message_id === 'string' && result.message_id.trim()
|
|
293
|
+
? result.message_id
|
|
294
|
+
: typeof result?.message?.message_id === 'string' && result.message.message_id.trim()
|
|
295
|
+
? result.message.message_id
|
|
296
|
+
: undefined;
|
|
297
|
+
if (!messageId) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: 'message.send returned no message_id',
|
|
301
|
+
code: 'MISSING_MESSAGE_ID',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
292
304
|
// 5. 写出方向 jsonl(与 daemon 一致格式,标记 source)
|
|
293
305
|
// source 标记:
|
|
294
306
|
// - 'cli': 用户手动调用 ec msg send
|
|
295
307
|
// - 'msg': agent 在会话中调用 ec msg send
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
catch { }
|
|
308
|
+
try {
|
|
309
|
+
await appendMsgSendOutboundLog(args, { ok: true, message_id: messageId }, {
|
|
310
|
+
payload,
|
|
311
|
+
runtimeContext,
|
|
312
|
+
chatmode,
|
|
313
|
+
encrypt,
|
|
314
|
+
});
|
|
306
315
|
}
|
|
316
|
+
catch { }
|
|
307
317
|
return {
|
|
308
318
|
ok: true,
|
|
309
|
-
message_id:
|
|
319
|
+
message_id: messageId,
|
|
310
320
|
seq: result?.seq,
|
|
311
321
|
timestamp: result?.timestamp,
|
|
312
322
|
status: result?.status,
|
package/dist/aun/outbox.js
CHANGED
|
@@ -3,6 +3,7 @@ import { agentOutboxPath } from '../paths.js';
|
|
|
3
3
|
import { atomicRead, atomicWrite } from '../utils/atomic-write.js';
|
|
4
4
|
import { isDeliveryTarget, isDeliveryTargetForChannel, sameDeliveryTarget } from '../core/message/message-utils.js';
|
|
5
5
|
const MAX_ENTRIES_PER_AID = 20;
|
|
6
|
+
const MAX_TERMINAL_ENTRIES_PER_AID = 100;
|
|
6
7
|
const DEFAULT_TTL = 300_000; // 5 minutes
|
|
7
8
|
function outboxFile(aid) {
|
|
8
9
|
return agentOutboxPath(aid);
|
|
@@ -13,6 +14,11 @@ function generateId() {
|
|
|
13
14
|
return `out-${ts}-${rand}`;
|
|
14
15
|
}
|
|
15
16
|
function isExpired(entry) {
|
|
17
|
+
// Terminal failures are retained as durable diagnostics. They are excluded
|
|
18
|
+
// from `load()`/`hasPending()` and must not disappear merely because the
|
|
19
|
+
// original delivery TTL elapsed.
|
|
20
|
+
if (entry.terminal)
|
|
21
|
+
return false;
|
|
16
22
|
if (entry.critical)
|
|
17
23
|
return false;
|
|
18
24
|
return Date.now() - entry.ts > entry.ttl;
|
|
@@ -41,11 +47,22 @@ function readEntries(aid) {
|
|
|
41
47
|
}
|
|
42
48
|
function writeEntries(aid, entries) {
|
|
43
49
|
const file = outboxFile(aid);
|
|
44
|
-
const
|
|
45
|
-
|
|
50
|
+
const boundedEntries = trimTerminalEntries(entries);
|
|
51
|
+
const content = boundedEntries.length > 0
|
|
52
|
+
? boundedEntries.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
46
53
|
: '';
|
|
47
54
|
atomicWrite(file, content);
|
|
48
55
|
}
|
|
56
|
+
function trimTerminalEntries(entries) {
|
|
57
|
+
const terminalEntries = entries.filter(entry => !!entry.terminal);
|
|
58
|
+
if (terminalEntries.length <= MAX_TERMINAL_ENTRIES_PER_AID)
|
|
59
|
+
return entries;
|
|
60
|
+
const retainedTerminalIds = new Set(terminalEntries
|
|
61
|
+
.sort((left, right) => (left.terminal?.at ?? left.ts) - (right.terminal?.at ?? right.ts))
|
|
62
|
+
.slice(-MAX_TERMINAL_ENTRIES_PER_AID)
|
|
63
|
+
.map(entry => entry.id));
|
|
64
|
+
return entries.filter(entry => !entry.terminal || retainedTerminalIds.has(entry.id));
|
|
65
|
+
}
|
|
49
66
|
export function enqueue(aid, opts) {
|
|
50
67
|
const delivery = opts.delivery;
|
|
51
68
|
if (!isDeliveryForChannel(delivery, opts.channelId)) {
|
|
@@ -54,6 +71,9 @@ export function enqueue(aid, opts) {
|
|
|
54
71
|
}
|
|
55
72
|
let existingEntries = readEntries(aid);
|
|
56
73
|
if (opts.dedupeKey) {
|
|
74
|
+
// Terminal results are decisions for this logical operation. Reusing the
|
|
75
|
+
// same route must not silently turn a permanent rejection into a retry;
|
|
76
|
+
// an explicit route correction is the only case that replaces it.
|
|
57
77
|
const existing = existingEntries.find(entry => entry.dedupeKey === opts.dedupeKey && !isExpired(entry));
|
|
58
78
|
if (existing
|
|
59
79
|
&& existing.channelId === opts.channelId
|
|
@@ -86,10 +106,12 @@ export function enqueue(aid, opts) {
|
|
|
86
106
|
ttl: opts.ttl ?? DEFAULT_TTL,
|
|
87
107
|
postSend: opts.postSend,
|
|
88
108
|
};
|
|
89
|
-
// Enforce
|
|
109
|
+
// Enforce the pending-entry cap without allowing historical terminal
|
|
110
|
+
// diagnostics to evict live messages. Terminal records are bounded
|
|
111
|
+
// separately so a permanently failing target cannot grow the file forever.
|
|
90
112
|
let entries = existingEntries;
|
|
91
|
-
if (entries.filter(candidate => !candidate.critical).length >= MAX_ENTRIES_PER_AID) {
|
|
92
|
-
const dropIndex = entries.findIndex(candidate => !candidate.critical);
|
|
113
|
+
if (entries.filter(candidate => !candidate.critical && !candidate.terminal).length >= MAX_ENTRIES_PER_AID) {
|
|
114
|
+
const dropIndex = entries.findIndex(candidate => !candidate.critical && !candidate.terminal);
|
|
93
115
|
if (dropIndex >= 0)
|
|
94
116
|
entries.splice(dropIndex, 1);
|
|
95
117
|
entries = [...entries, entry];
|
|
@@ -104,6 +126,16 @@ export function remove(aid, id) {
|
|
|
104
126
|
const entries = readEntries(aid).filter(e => e.id !== id);
|
|
105
127
|
writeEntries(aid, entries);
|
|
106
128
|
}
|
|
129
|
+
/** Remove an entry only if it still points at the route that was submitted. */
|
|
130
|
+
export function removeIfRouteMatches(aid, submitted) {
|
|
131
|
+
const entries = readEntries(aid);
|
|
132
|
+
const index = entries.findIndex(entry => entry.id === submitted.id);
|
|
133
|
+
if (index < 0 || !hasSamePersistedRoute(entries[index], submitted))
|
|
134
|
+
return false;
|
|
135
|
+
entries.splice(index, 1);
|
|
136
|
+
writeEntries(aid, entries);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
107
139
|
/** Replace an existing entry without consuming another outbox slot. */
|
|
108
140
|
export function replace(aid, entry) {
|
|
109
141
|
const entries = readEntries(aid);
|
|
@@ -114,6 +146,30 @@ export function replace(aid, entry) {
|
|
|
114
146
|
writeEntries(aid, entries);
|
|
115
147
|
return true;
|
|
116
148
|
}
|
|
149
|
+
/** Replace an entry only while its submitted route is still current. */
|
|
150
|
+
export function replaceIfRouteMatches(aid, submitted, replacement) {
|
|
151
|
+
const entries = readEntries(aid);
|
|
152
|
+
const index = entries.findIndex(candidate => candidate.id === submitted.id);
|
|
153
|
+
if (index < 0)
|
|
154
|
+
return 'missing';
|
|
155
|
+
if (!hasSamePersistedRoute(entries[index], submitted))
|
|
156
|
+
return 'route-changed';
|
|
157
|
+
entries[index] = replacement;
|
|
158
|
+
writeEntries(aid, entries);
|
|
159
|
+
return 'replaced';
|
|
160
|
+
}
|
|
161
|
+
/** Update only the delivery receipt while preserving concurrent entry changes. */
|
|
162
|
+
export function updateDeliveryReceiptIfRouteMatches(aid, submitted, receipt) {
|
|
163
|
+
const entries = readEntries(aid);
|
|
164
|
+
const index = entries.findIndex(candidate => candidate.id === submitted.id);
|
|
165
|
+
if (index < 0)
|
|
166
|
+
return 'missing';
|
|
167
|
+
if (!hasSamePersistedRoute(entries[index], submitted))
|
|
168
|
+
return 'route-changed';
|
|
169
|
+
entries[index].deliveryReceipt = receipt;
|
|
170
|
+
writeEntries(aid, entries);
|
|
171
|
+
return 'replaced';
|
|
172
|
+
}
|
|
117
173
|
/** Remove durable interaction cards that were cancelled before delivery. */
|
|
118
174
|
export function removeInteractionCards(aid, requestId) {
|
|
119
175
|
const entries = readEntries(aid);
|
|
@@ -125,10 +181,13 @@ export function removeInteractionCards(aid, requestId) {
|
|
|
125
181
|
return removed;
|
|
126
182
|
}
|
|
127
183
|
export function load(aid) {
|
|
128
|
-
return readEntries(aid).filter(e => !isExpired(e));
|
|
184
|
+
return readEntries(aid).filter(e => !e.terminal && !isExpired(e));
|
|
129
185
|
}
|
|
130
|
-
export function findByDedupeKey(aid, dedupeKey) {
|
|
131
|
-
|
|
186
|
+
export function findByDedupeKey(aid, dedupeKey, opts = {}) {
|
|
187
|
+
const entries = opts.includeTerminal
|
|
188
|
+
? readEntries(aid).filter(entry => !isExpired(entry))
|
|
189
|
+
: load(aid);
|
|
190
|
+
return entries.find(entry => entry.dedupeKey === dedupeKey);
|
|
132
191
|
}
|
|
133
192
|
export function cleanup(aid) {
|
|
134
193
|
const all = readEntries(aid);
|
|
@@ -138,6 +197,19 @@ export function cleanup(aid) {
|
|
|
138
197
|
writeEntries(aid, valid);
|
|
139
198
|
return removed;
|
|
140
199
|
}
|
|
200
|
+
/** Mark a durable entry terminal while keeping its reason for diagnostics. */
|
|
201
|
+
export function markTerminal(aid, id, failure, submitted) {
|
|
202
|
+
const entries = readEntries(aid);
|
|
203
|
+
const entry = entries.find(candidate => candidate.id === id);
|
|
204
|
+
if (!entry || (submitted && !hasSamePersistedRoute(entry, submitted)))
|
|
205
|
+
return false;
|
|
206
|
+
entry.lastError = failure.error ?? 'permanent send failure';
|
|
207
|
+
if (failure.code !== undefined)
|
|
208
|
+
entry.lastErrorCode = failure.code;
|
|
209
|
+
entry.terminal = { at: Date.now(), error: entry.lastError, ...(failure.code !== undefined ? { code: failure.code } : {}) };
|
|
210
|
+
writeEntries(aid, entries);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
141
213
|
// Each caller gets its own pass over the current disk state. This prevents
|
|
142
214
|
// duplicate sends while allowing a later drain trigger to pick up entries
|
|
143
215
|
// enqueued during an active pass.
|
|
@@ -145,14 +217,25 @@ const activeDrainTails = new Map();
|
|
|
145
217
|
function hasRouteForChannel(entry) {
|
|
146
218
|
return isDeliveryForChannel(entry.delivery, entry.channelId);
|
|
147
219
|
}
|
|
220
|
+
function hasSamePersistedRoute(left, right) {
|
|
221
|
+
if (left.channelId !== right.channelId)
|
|
222
|
+
return false;
|
|
223
|
+
if (left.delivery === undefined && right.delivery === undefined)
|
|
224
|
+
return true;
|
|
225
|
+
return isDeliveryTarget(left.delivery)
|
|
226
|
+
&& isDeliveryTarget(right.delivery)
|
|
227
|
+
&& sameDeliveryTarget(left.delivery, right.delivery);
|
|
228
|
+
}
|
|
148
229
|
async function drainOnce(aid, sender) {
|
|
149
|
-
const entries = readEntries(aid);
|
|
230
|
+
const entries = readEntries(aid).filter(entry => !entry.terminal);
|
|
150
231
|
if (entries.length === 0)
|
|
151
232
|
return { sent: 0, expired: 0, failed: 0 };
|
|
152
233
|
const drainedIds = new Set(entries.map(e => e.id));
|
|
234
|
+
const drainedById = new Map(entries.map(entry => [entry.id, entry]));
|
|
153
235
|
let sent = 0;
|
|
154
236
|
let expired = 0;
|
|
155
237
|
let failed = 0;
|
|
238
|
+
let permanent = 0;
|
|
156
239
|
const remaining = [];
|
|
157
240
|
for (const entry of entries) {
|
|
158
241
|
if (isExpired(entry)) {
|
|
@@ -161,20 +244,48 @@ async function drainOnce(aid, sender) {
|
|
|
161
244
|
}
|
|
162
245
|
if (!hasRouteForChannel(entry)) {
|
|
163
246
|
// A route is security-sensitive metadata. An entry whose groupId does
|
|
164
|
-
// not match its address cannot be repaired from channelId
|
|
165
|
-
// instead of retrying the same malformed
|
|
166
|
-
|
|
247
|
+
// not match its address cannot be repaired from channelId. Keep a
|
|
248
|
+
// terminal diagnostic record instead of retrying the same malformed
|
|
249
|
+
// send forever or silently deleting evidence of the failure.
|
|
250
|
+
permanent++;
|
|
251
|
+
entry.lastError = 'outbox entry has no valid delivery route';
|
|
252
|
+
entry.lastErrorCode = 'AUN_OUTBOUND_ROUTE_REQUIRED';
|
|
253
|
+
entry.terminal = {
|
|
254
|
+
at: Date.now(),
|
|
255
|
+
error: entry.lastError,
|
|
256
|
+
code: entry.lastErrorCode,
|
|
257
|
+
};
|
|
258
|
+
remaining.push(entry);
|
|
167
259
|
continue;
|
|
168
260
|
}
|
|
169
261
|
try {
|
|
170
262
|
entry.attempts = (entry.attempts ?? 0) + 1;
|
|
171
|
-
const
|
|
172
|
-
|
|
263
|
+
const raw = await sender(entry);
|
|
264
|
+
const result = typeof raw === 'boolean'
|
|
265
|
+
? { status: raw ? 'sent' : 'retry' }
|
|
266
|
+
: raw;
|
|
267
|
+
if (result.status === 'sent') {
|
|
173
268
|
sent++;
|
|
174
269
|
}
|
|
270
|
+
else if (result.status === 'permanent') {
|
|
271
|
+
permanent++;
|
|
272
|
+
entry.lastError = result.error ?? 'permanent send failure';
|
|
273
|
+
if (result.code !== undefined)
|
|
274
|
+
entry.lastErrorCode = result.code;
|
|
275
|
+
entry.terminal = {
|
|
276
|
+
at: Date.now(),
|
|
277
|
+
error: entry.lastError,
|
|
278
|
+
...(result.code !== undefined ? { code: result.code } : {}),
|
|
279
|
+
};
|
|
280
|
+
// Keep a terminal record for diagnostics, but exclude it from all
|
|
281
|
+
// pending-send views so the timer cannot submit it again.
|
|
282
|
+
remaining.push(entry);
|
|
283
|
+
}
|
|
175
284
|
else {
|
|
176
285
|
failed++;
|
|
177
|
-
entry.lastError = 'sender returned false';
|
|
286
|
+
entry.lastError = result.error ?? 'sender returned false';
|
|
287
|
+
if (result.code !== undefined)
|
|
288
|
+
entry.lastErrorCode = result.code;
|
|
178
289
|
remaining.push(entry);
|
|
179
290
|
}
|
|
180
291
|
}
|
|
@@ -190,17 +301,31 @@ async function drainOnce(aid, sender) {
|
|
|
190
301
|
if (!drainedIds.has(entry.id))
|
|
191
302
|
return [entry];
|
|
192
303
|
const failedEntry = failedById.get(entry.id);
|
|
193
|
-
if (!failedEntry)
|
|
194
|
-
|
|
304
|
+
if (!failedEntry) {
|
|
305
|
+
const submittedEntry = drainedById.get(entry.id);
|
|
306
|
+
return submittedEntry && !hasSamePersistedRoute(entry, submittedEntry)
|
|
307
|
+
? [entry]
|
|
308
|
+
: [];
|
|
309
|
+
}
|
|
310
|
+
// A failure belongs to the route that was actually submitted. If another
|
|
311
|
+
// writer corrected that route while the RPC was in flight, preserve the
|
|
312
|
+
// corrected entry as pending instead of terminating it with a stale
|
|
313
|
+
// target's permanent error.
|
|
314
|
+
if (!hasSamePersistedRoute(entry, failedEntry))
|
|
315
|
+
return [entry];
|
|
316
|
+
if (entry.terminal && !failedEntry.terminal)
|
|
317
|
+
return [entry];
|
|
195
318
|
return [{
|
|
196
319
|
...failedEntry,
|
|
197
320
|
...entry,
|
|
198
321
|
attempts: failedEntry.attempts,
|
|
199
322
|
lastError: failedEntry.lastError,
|
|
323
|
+
lastErrorCode: failedEntry.lastErrorCode,
|
|
324
|
+
...(failedEntry.terminal ? { terminal: failedEntry.terminal } : {}),
|
|
200
325
|
}];
|
|
201
326
|
});
|
|
202
327
|
writeEntries(aid, retained);
|
|
203
|
-
return { sent, expired, failed };
|
|
328
|
+
return { sent, expired, failed, ...(permanent > 0 ? { permanent } : {}) };
|
|
204
329
|
}
|
|
205
330
|
export async function drain(aid, sender) {
|
|
206
331
|
const previous = activeDrainTails.get(aid);
|
|
@@ -220,7 +345,7 @@ export async function drain(aid, sender) {
|
|
|
220
345
|
}
|
|
221
346
|
}
|
|
222
347
|
export function hasPending(aid) {
|
|
223
|
-
return readEntries(aid).some(entry => !isExpired(entry));
|
|
348
|
+
return readEntries(aid).some(entry => !entry.terminal && !isExpired(entry));
|
|
224
349
|
}
|
|
225
350
|
/**
|
|
226
351
|
* 当前 outbox 中待发送条目数。用于诊断发送管线堵塞:depth 持续增长说明
|