@agentoryhq/node 0.1.1 → 0.1.3
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/dist/align.d.ts +4 -0
- package/dist/align.test.js +69 -0
- package/dist/cli.js +0 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.js +166 -3
- package/dist/daemon.js +11 -1
- package/dist/discover.test.js +27 -0
- package/dist/github-task.d.ts +62 -0
- package/dist/github-task.js +132 -0
- package/dist/github-task.test.js +166 -0
- package/dist/mock-runtime.test.d.ts +1 -0
- package/dist/mock-runtime.test.js +62 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +60 -0
- package/dist/transcript.d.ts +9 -1
- package/dist/transcript.js +21 -1
- package/dist/transcript.test.js +36 -1
- package/package.json +7 -5
- package/dist/daemon.e2e.test.js +0 -123
- /package/dist/{daemon.e2e.test.d.ts → github-task.test.d.ts} +0 -0
package/dist/align.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ export type ApplyAlignResult = {
|
|
|
12
12
|
mcpPath: string;
|
|
13
13
|
skillsPath: string;
|
|
14
14
|
snapshotPath?: string;
|
|
15
|
+
/** 规则写盘目录(rules-desired-config,cursor-only;未写规则时缺省) */
|
|
16
|
+
rulesDir?: string;
|
|
17
|
+
/** 被覆盖规则文件的快照目录(rules-<ts>/;无覆盖时缺省) */
|
|
18
|
+
rulesSnapshotDir?: string;
|
|
15
19
|
};
|
|
16
20
|
/** Host facade — routes to the matching RuntimeAdapter.applyAlign. */
|
|
17
21
|
export declare function applyAlignPatch(opts: ApplyAlignOpts): ApplyAlignResult;
|
package/dist/align.test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -61,3 +62,71 @@ test("applyAlignPatch routes claude-code to .mcp.json not .cursor/", () => {
|
|
|
61
62
|
assert.equal(cursorMcp.mcpServers.keep?.command, "cursor-only");
|
|
62
63
|
assert.ok(fs.existsSync(path.join(root, ".claude", "agentory-skills.json")));
|
|
63
64
|
});
|
|
65
|
+
// ── rules-desired-config 切片Ⅱ:cursor 规则写盘 + claude-code 诚实边界 ──
|
|
66
|
+
test("applyAlignPatch cursor 规则:写 .cursor/rules/<名>.mdc(body 原样) + 覆盖前快照 + 越权名防御跳过", () => {
|
|
67
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-aln-rules-"));
|
|
68
|
+
fs.mkdirSync(path.join(root, ".cursor", "rules"), { recursive: true });
|
|
69
|
+
const existing = path.join(root, ".cursor", "rules", "code-style.mdc");
|
|
70
|
+
fs.writeFileSync(existing, "stale body");
|
|
71
|
+
const r = applyAlignPatch({
|
|
72
|
+
cursorRoot: root,
|
|
73
|
+
patch: {
|
|
74
|
+
mcp: [],
|
|
75
|
+
skills: [],
|
|
76
|
+
rules: [
|
|
77
|
+
{ name: "code-style", body: "always X" },
|
|
78
|
+
{ name: "new-rule", body: "# 标题\n正文" },
|
|
79
|
+
{ name: "../evil", body: "x" },
|
|
80
|
+
{ name: "a/b", body: "x" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
confirmed: true,
|
|
84
|
+
});
|
|
85
|
+
assert.equal(r.ok, true);
|
|
86
|
+
// hash 契约:文件字节 = body 原样(不追加结尾换行) → sha256 与 API ruleBodyHash 同值
|
|
87
|
+
const bytes = fs.readFileSync(existing);
|
|
88
|
+
assert.equal(bytes.toString("utf8"), "always X");
|
|
89
|
+
assert.equal(createHash("sha256").update(bytes).digest("hex"), createHash("sha256").update("always X", "utf8").digest("hex"));
|
|
90
|
+
assert.equal(fs.readFileSync(path.join(root, ".cursor", "rules", "new-rule.mdc"), "utf8"), "# 标题\n正文");
|
|
91
|
+
// 越权名防御性跳过(第二闸):rules 目录之外零文件
|
|
92
|
+
assert.equal(fs.existsSync(path.join(root, ".cursor", "evil.mdc")), false);
|
|
93
|
+
assert.equal(fs.existsSync(path.join(root, "evil.mdc")), false);
|
|
94
|
+
assert.equal(fs.existsSync(path.join(root, ".cursor", "rules", "a")), false);
|
|
95
|
+
assert.equal(r.rulesDir, path.join(root, ".cursor", "rules"));
|
|
96
|
+
// 覆盖前快照既存文件
|
|
97
|
+
assert.ok(r.rulesSnapshotDir);
|
|
98
|
+
assert.equal(fs.readFileSync(path.join(r.rulesSnapshotDir, "code-style.mdc"), "utf8"), "stale body");
|
|
99
|
+
});
|
|
100
|
+
test("applyAlignPatch cursor 规则:无既存文件 = 零快照目录;空 patch.rules 不动 rules 目录", () => {
|
|
101
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-aln-rules2-"));
|
|
102
|
+
fs.mkdirSync(path.join(root, ".cursor"), { recursive: true });
|
|
103
|
+
const r1 = applyAlignPatch({
|
|
104
|
+
cursorRoot: root,
|
|
105
|
+
patch: { mcp: [], skills: [], rules: [{ name: "fresh", body: "b" }] },
|
|
106
|
+
confirmed: true,
|
|
107
|
+
});
|
|
108
|
+
assert.equal(r1.ok, true);
|
|
109
|
+
assert.equal(r1.rulesSnapshotDir, undefined);
|
|
110
|
+
assert.ok(fs.existsSync(path.join(root, ".cursor", "rules", "fresh.mdc")));
|
|
111
|
+
const r2 = applyAlignPatch({
|
|
112
|
+
cursorRoot: root,
|
|
113
|
+
patch: { mcp: [], skills: [] }, // 老形状 patch 无 rules 字段
|
|
114
|
+
confirmed: true,
|
|
115
|
+
});
|
|
116
|
+
assert.equal(r2.ok, true);
|
|
117
|
+
assert.equal(r2.rulesDir, undefined);
|
|
118
|
+
assert.equal(fs.readFileSync(path.join(root, ".cursor", "rules", "fresh.mdc"), "utf8"), "b");
|
|
119
|
+
});
|
|
120
|
+
test("applyAlignPatch claude-code 忽略 patch.rules(一期 cursor-only 诚实边界)", () => {
|
|
121
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-aln-cc-rules-"));
|
|
122
|
+
fs.mkdirSync(path.join(root, ".claude"), { recursive: true });
|
|
123
|
+
const r = applyAlignPatch({
|
|
124
|
+
cursorRoot: root,
|
|
125
|
+
runtime: "claude-code",
|
|
126
|
+
patch: { mcp: [], skills: [], rules: [{ name: "r", body: "b" }] },
|
|
127
|
+
confirmed: true,
|
|
128
|
+
});
|
|
129
|
+
assert.equal(r.ok, true);
|
|
130
|
+
assert.equal(fs.existsSync(path.join(root, ".claude", "rules")), false);
|
|
131
|
+
assert.equal(fs.existsSync(path.join(root, ".cursor", "rules", "r.mdc")), false);
|
|
132
|
+
});
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/client.d.ts
CHANGED
|
@@ -16,5 +16,20 @@ export declare function reportAssetIndex(): Promise<number>;
|
|
|
16
16
|
* Called from the daemon index channel and `agentory-node transcript` CLI.
|
|
17
17
|
*/
|
|
18
18
|
export declare function fulfillPendingTranscripts(): Promise<number>;
|
|
19
|
+
/**
|
|
20
|
+
* 消息通道(ADR-005 拉模型第六通道 · agent-messaging 切片 4):
|
|
21
|
+
* 拉取即锁定的 pending 消息 → adapter.invoke 逐条串行执行(一期单发单执行)→ 回流结果。
|
|
22
|
+
* 红线:不注入任何权限放宽 flag(invoke 实现内钉死);纯 outbound 零反连;
|
|
23
|
+
* 消息正文/输出不落节点日志(只回计数与状态,transcript AC-9 同纪律)。
|
|
24
|
+
*/
|
|
25
|
+
export declare function fulfillPendingMessages(): Promise<number>;
|
|
26
|
+
/**
|
|
27
|
+
* GitHub 任务通道(github-tasks 切片 3 · ADR-007 拉模型第七通道):
|
|
28
|
+
* 拉取即锁定的 claimed 任务 → start(claimed→running)→ executeGithubTask
|
|
29
|
+
* (remote 校验 AC-4 / headless 执行 / commit+push 任务分支 AC-8)→ 回流结果。
|
|
30
|
+
* 红线:不注入任何权限放宽 flag(adapter.invoke 内钉死);纯 outbound 零反连;
|
|
31
|
+
* issue 正文/输出不落节点日志(只回计数与状态,messaging AC-9 同纪律)。
|
|
32
|
+
*/
|
|
33
|
+
export declare function fulfillPendingGithubTasks(): Promise<number>;
|
|
19
34
|
/** Pull confirmed plans and write Cursor files (AC-3). */
|
|
20
35
|
export declare function applyPendingAligns(): Promise<number>;
|
package/dist/client.js
CHANGED
|
@@ -5,7 +5,9 @@ import { controlPlaneBaseUrl } from "@agentoryhq/shared";
|
|
|
5
5
|
import { applyAlignPatch } from "./align.js";
|
|
6
6
|
import { discoverAssetIndex } from "./asset-index.js";
|
|
7
7
|
import { discoverAgentsWithHealth } from "./discover.js";
|
|
8
|
-
import {
|
|
8
|
+
import { adapterForRuntime } from "./registry.js";
|
|
9
|
+
import { captureTranscriptForSourceKey } from "./transcript.js";
|
|
10
|
+
import { executeGithubTask } from "./github-task.js";
|
|
9
11
|
function statePath() {
|
|
10
12
|
return process.env.AGENTORY_NODE_STATE ?? path.join(os.homedir(), ".agentory", "node.json");
|
|
11
13
|
}
|
|
@@ -124,8 +126,9 @@ export async function fulfillPendingTranscripts() {
|
|
|
124
126
|
const snapshots = [];
|
|
125
127
|
const misses = [];
|
|
126
128
|
for (const req of pending) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
// 跨 adapter 路由(claude-session-index AC-5):cursor/claude key 各自
|
|
130
|
+
// 找到归属 adapter 捕获;未知前缀无人认领 → miss 上报(既有语义)。
|
|
131
|
+
const snap = captureTranscriptForSourceKey(req.sourceKey);
|
|
129
132
|
if (snap)
|
|
130
133
|
snapshots.push(snap);
|
|
131
134
|
else
|
|
@@ -147,6 +150,166 @@ export async function fulfillPendingTranscripts() {
|
|
|
147
150
|
}, null, 2));
|
|
148
151
|
return 0;
|
|
149
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* 消息通道(ADR-005 拉模型第六通道 · agent-messaging 切片 4):
|
|
155
|
+
* 拉取即锁定的 pending 消息 → adapter.invoke 逐条串行执行(一期单发单执行)→ 回流结果。
|
|
156
|
+
* 红线:不注入任何权限放宽 flag(invoke 实现内钉死);纯 outbound 零反连;
|
|
157
|
+
* 消息正文/输出不落节点日志(只回计数与状态,transcript AC-9 同纪律)。
|
|
158
|
+
*/
|
|
159
|
+
export async function fulfillPendingMessages() {
|
|
160
|
+
const st = loadState();
|
|
161
|
+
if (!st) {
|
|
162
|
+
console.error("not registered; run: agentory-node register --token <root>");
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
const { status, body } = await api("GET", `/v1/nodes/${st.nodeId}/messages/pending`, {
|
|
166
|
+
token: st.nodeToken,
|
|
167
|
+
});
|
|
168
|
+
if (status !== 200) {
|
|
169
|
+
console.error(status, JSON.stringify(body));
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
const pending = body.messages ?? [];
|
|
173
|
+
if (!pending.length) {
|
|
174
|
+
console.log(JSON.stringify({ executed: 0, reported: 0, outcomes: {} }, null, 2));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const cwd = process.cwd();
|
|
178
|
+
const outcomes = {};
|
|
179
|
+
let reported = 0;
|
|
180
|
+
let reportFailed = 0;
|
|
181
|
+
for (const msg of pending) {
|
|
182
|
+
let result;
|
|
183
|
+
const adapter = adapterForRuntime(msg.runtime);
|
|
184
|
+
if (!adapter?.invoke) {
|
|
185
|
+
// 控制面已按诚实集拦 409;走到这里 = 节点侧无该 runtime adapter(版本偏差),
|
|
186
|
+
// 诚实回流 failed 而不是静默丢弃。
|
|
187
|
+
result = {
|
|
188
|
+
outcome: "failed",
|
|
189
|
+
output: "",
|
|
190
|
+
truncated: false,
|
|
191
|
+
exitCode: null,
|
|
192
|
+
durationMs: 0,
|
|
193
|
+
errorText: `no invoke adapter for runtime ${msg.runtime}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
try {
|
|
198
|
+
result = await adapter.invoke({ homeDir: os.homedir(), cwd }, {
|
|
199
|
+
prompt: msg.body,
|
|
200
|
+
timeoutMs: msg.timeoutMs,
|
|
201
|
+
maxOutputBytes: msg.maxOutputBytes,
|
|
202
|
+
cwd,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
result = {
|
|
207
|
+
outcome: "failed",
|
|
208
|
+
output: "",
|
|
209
|
+
truncated: false,
|
|
210
|
+
exitCode: null,
|
|
211
|
+
durationMs: 0,
|
|
212
|
+
errorText: err instanceof Error ? err.message : String(err),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
outcomes[result.outcome] = (outcomes[result.outcome] ?? 0) + 1;
|
|
217
|
+
const report = await api("POST", `/v1/nodes/${st.nodeId}/messages/report`, {
|
|
218
|
+
token: st.nodeToken,
|
|
219
|
+
body: {
|
|
220
|
+
messageId: msg.id,
|
|
221
|
+
outcome: result.outcome,
|
|
222
|
+
output: result.output,
|
|
223
|
+
truncated: result.truncated,
|
|
224
|
+
exitCode: result.exitCode,
|
|
225
|
+
durationMs: result.durationMs,
|
|
226
|
+
runtimeSessionId: result.runtimeSessionId,
|
|
227
|
+
errorText: result.errorText,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
// 业务失败(failed/timeout)是正常回流结果,不算通道失败;只有回流本身报错才非 0。
|
|
231
|
+
if (report.status === 200)
|
|
232
|
+
reported += 1;
|
|
233
|
+
else
|
|
234
|
+
reportFailed += 1;
|
|
235
|
+
}
|
|
236
|
+
console.log(JSON.stringify({ executed: pending.length, reported, reportFailed, outcomes }, null, 2));
|
|
237
|
+
return reportFailed ? 1 : 0;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* GitHub 任务通道(github-tasks 切片 3 · ADR-007 拉模型第七通道):
|
|
241
|
+
* 拉取即锁定的 claimed 任务 → start(claimed→running)→ executeGithubTask
|
|
242
|
+
* (remote 校验 AC-4 / headless 执行 / commit+push 任务分支 AC-8)→ 回流结果。
|
|
243
|
+
* 红线:不注入任何权限放宽 flag(adapter.invoke 内钉死);纯 outbound 零反连;
|
|
244
|
+
* issue 正文/输出不落节点日志(只回计数与状态,messaging AC-9 同纪律)。
|
|
245
|
+
*/
|
|
246
|
+
export async function fulfillPendingGithubTasks() {
|
|
247
|
+
const st = loadState();
|
|
248
|
+
if (!st) {
|
|
249
|
+
console.error("not registered; run: agentory-node register --token <root>");
|
|
250
|
+
return 2;
|
|
251
|
+
}
|
|
252
|
+
const { status, body } = await api("GET", `/v1/nodes/${st.nodeId}/github-tasks/pending`, {
|
|
253
|
+
token: st.nodeToken,
|
|
254
|
+
});
|
|
255
|
+
if (status !== 200) {
|
|
256
|
+
console.error(status, JSON.stringify(body));
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
const pending = body.tasks ?? [];
|
|
260
|
+
if (!pending.length) {
|
|
261
|
+
console.log(JSON.stringify({ executed: 0, reported: 0, outcomes: {} }, null, 2));
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
const adapter = adapterForRuntime("claude-code");
|
|
265
|
+
// 一期单仓库:仓库工作目录 env 指定(部署面文档化,切片 6);缺省 = daemon 进程 cwd(messaging 同口径)。
|
|
266
|
+
const repoDir = process.env.AGENTORY_GITHUB_REPO_DIR?.trim() || process.cwd();
|
|
267
|
+
const outcomes = {};
|
|
268
|
+
let reported = 0;
|
|
269
|
+
let reportFailed = 0;
|
|
270
|
+
for (const task of pending) {
|
|
271
|
+
const startRes = await api("POST", `/v1/nodes/${st.nodeId}/github-tasks/start`, {
|
|
272
|
+
token: st.nodeToken,
|
|
273
|
+
body: { taskId: task.id },
|
|
274
|
+
});
|
|
275
|
+
if (startRes.status !== 200) {
|
|
276
|
+
// claimed 锁定被并发取走/版本偏差:诚实计数,不执行不上报。
|
|
277
|
+
reportFailed += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
let report;
|
|
281
|
+
if (!adapter?.invoke) {
|
|
282
|
+
// 节点侧无 claude-code adapter(版本偏差):诚实回流 failed 而不是静默丢弃。
|
|
283
|
+
report = {
|
|
284
|
+
outcome: "failed",
|
|
285
|
+
output: "",
|
|
286
|
+
truncated: false,
|
|
287
|
+
exitCode: null,
|
|
288
|
+
durationMs: 0,
|
|
289
|
+
errorText: "no invoke adapter for runtime claude-code",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
const invoke = adapter.invoke;
|
|
294
|
+
report = await executeGithubTask(task, {
|
|
295
|
+
repoDir,
|
|
296
|
+
invoke: (req) => invoke({ homeDir: os.homedir(), cwd: req.cwd }, req),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
outcomes[report.outcome] = (outcomes[report.outcome] ?? 0) + 1;
|
|
300
|
+
const res = await api("POST", `/v1/nodes/${st.nodeId}/github-tasks/report`, {
|
|
301
|
+
token: st.nodeToken,
|
|
302
|
+
body: { taskId: task.id, ...report },
|
|
303
|
+
});
|
|
304
|
+
// 业务失败(failed/timeout)是正常回流结果;只有回流本身报错才非 0(messaging 同口径)。
|
|
305
|
+
if (res.status === 200)
|
|
306
|
+
reported += 1;
|
|
307
|
+
else
|
|
308
|
+
reportFailed += 1;
|
|
309
|
+
}
|
|
310
|
+
console.log(JSON.stringify({ executed: pending.length, reported, reportFailed, outcomes }, null, 2));
|
|
311
|
+
return reportFailed ? 1 : 0;
|
|
312
|
+
}
|
|
150
313
|
/** Pull confirmed plans and write Cursor files (AC-3). */
|
|
151
314
|
export async function applyPendingAligns() {
|
|
152
315
|
const st = loadState();
|
package/dist/daemon.js
CHANGED
|
@@ -3,7 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { controlPlaneBaseUrl } from "@agentoryhq/shared";
|
|
6
|
-
import { applyPendingAligns, fulfillPendingTranscripts, heartbeat, loadState, reportAssetIndex, reportDiscovery } from "./client.js";
|
|
6
|
+
import { applyPendingAligns, fulfillPendingGithubTasks, fulfillPendingMessages, fulfillPendingTranscripts, heartbeat, loadState, reportAssetIndex, reportDiscovery } from "./client.js";
|
|
7
7
|
import { discoverAssetIndex } from "./asset-index.js";
|
|
8
8
|
const MIN_INTERVAL_SEC = 5;
|
|
9
9
|
const DEFAULTS = {
|
|
@@ -118,6 +118,14 @@ export async function runFullRound() {
|
|
|
118
118
|
if (c !== 0)
|
|
119
119
|
code = c;
|
|
120
120
|
});
|
|
121
|
+
await runChannel("message", fulfillPendingMessages, (c) => {
|
|
122
|
+
if (c !== 0)
|
|
123
|
+
code = c;
|
|
124
|
+
});
|
|
125
|
+
await runChannel("github-task", fulfillPendingGithubTasks, (c) => {
|
|
126
|
+
if (c !== 0)
|
|
127
|
+
code = c;
|
|
128
|
+
});
|
|
121
129
|
await runChannel("discover", reportDiscovery, (c) => {
|
|
122
130
|
if (c !== 0)
|
|
123
131
|
code = c;
|
|
@@ -198,6 +206,8 @@ export async function runDaemon(opts = {}) {
|
|
|
198
206
|
await runChannel("heartbeat", heartbeat);
|
|
199
207
|
await runChannel("align", applyPendingAligns);
|
|
200
208
|
await runChannel("transcript", fulfillPendingTranscripts);
|
|
209
|
+
await runChannel("message", fulfillPendingMessages);
|
|
210
|
+
await runChannel("github-task", fulfillPendingGithubTasks);
|
|
201
211
|
nextDue.heartbeat += intervals.heartbeatSec * 1000;
|
|
202
212
|
}
|
|
203
213
|
if (now >= nextDue.index) {
|
package/dist/discover.test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -77,3 +78,29 @@ test("fixture overrides filesystem", () => {
|
|
|
77
78
|
assert.equal(agents.length, 1);
|
|
78
79
|
assert.equal(agents[0]?.mcp.length, 0);
|
|
79
80
|
});
|
|
81
|
+
// rules-desired-config 切片Ⅱ:规则发现只报名字 + sha256(文件字节),正文不出节点。
|
|
82
|
+
test("discover cursor rules:名字+hash 上报 · cwd 优先 · 非 .mdc 忽略 · claude 恒空", () => {
|
|
83
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-disc-rules-"));
|
|
84
|
+
const home = path.join(root, "home");
|
|
85
|
+
const cwd = path.join(root, "proj");
|
|
86
|
+
fs.mkdirSync(path.join(cwd, ".cursor", "rules"), { recursive: true });
|
|
87
|
+
fs.mkdirSync(path.join(home, ".cursor", "rules"), { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(cwd, ".cursor", "rules", "code-style.mdc"), "cwd body");
|
|
89
|
+
fs.writeFileSync(path.join(home, ".cursor", "rules", "code-style.mdc"), "home body"); // 同名:cwd 优先
|
|
90
|
+
fs.writeFileSync(path.join(home, ".cursor", "rules", "global.mdc"), "g");
|
|
91
|
+
fs.writeFileSync(path.join(cwd, ".cursor", "rules", "notes.md"), "ignored"); // 非 .mdc 不进规则维
|
|
92
|
+
const agents = discoverAgents({ homeDir: home, cwd });
|
|
93
|
+
const cursor = agents.find((a) => a.runtime === "cursor");
|
|
94
|
+
assert.ok(cursor);
|
|
95
|
+
const byName = new Map((cursor.rules ?? []).map((r) => [r.name, r]));
|
|
96
|
+
assert.equal(byName.size, 2);
|
|
97
|
+
assert.equal(byName.get("code-style")?.contentHash, createHash("sha256").update("cwd body", "utf8").digest("hex"));
|
|
98
|
+
assert.equal(byName.get("global")?.contentHash, createHash("sha256").update("g", "utf8").digest("hex"));
|
|
99
|
+
assert.equal(byName.has("notes"), false);
|
|
100
|
+
// claude-code 诚实边界:恒报空 rules
|
|
101
|
+
fs.mkdirSync(path.join(cwd, ".claude"), { recursive: true });
|
|
102
|
+
const agents2 = discoverAgents({ homeDir: home, cwd });
|
|
103
|
+
const claude = agents2.find((a) => a.runtime === "claude-code");
|
|
104
|
+
assert.ok(claude);
|
|
105
|
+
assert.deepEqual(claude.rules, []);
|
|
106
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { InvokeResult } from "@agentoryhq/runtime-contract";
|
|
2
|
+
/** 控制面 pending 载荷(PendingGithubTask 同构;client.ts HTTP 面内联解析,messaging 判例)。 */
|
|
3
|
+
export type GithubTaskPayload = {
|
|
4
|
+
id: string;
|
|
5
|
+
repoOwner: string;
|
|
6
|
+
repoName: string;
|
|
7
|
+
defaultBranch: string;
|
|
8
|
+
issueNumber: number;
|
|
9
|
+
issueTitle: string;
|
|
10
|
+
issueBody: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
maxOutputBytes: number;
|
|
14
|
+
};
|
|
15
|
+
/** 上报形状(控制面 GithubTaskReport 同构:outcome=done 时 pushedBranch 必有,AC-5)。 */
|
|
16
|
+
export type GithubTaskRunReport = {
|
|
17
|
+
outcome: "done" | "failed" | "timeout";
|
|
18
|
+
output: string;
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
exitCode: number | null;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
errorText?: string;
|
|
23
|
+
pushedBranch?: string;
|
|
24
|
+
};
|
|
25
|
+
/** 令牌样式串脱敏(AC-12):GitHub PAT 前缀族 + URL 内嵌凭据形态,一律 [redacted]。 */
|
|
26
|
+
export declare function maskSecrets(text: string): string;
|
|
27
|
+
/** remote URL → owner/repo(https / ssh:// / git@ 三形态;非 github.com → undefined = 校验失败)。 */
|
|
28
|
+
export declare function parseGithubRepo(remoteUrl: string): {
|
|
29
|
+
owner: string;
|
|
30
|
+
repo: string;
|
|
31
|
+
} | undefined;
|
|
32
|
+
export type GitRunner = (args: string[], cwd: string) => Promise<{
|
|
33
|
+
code: number;
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
}>;
|
|
37
|
+
/** 默认 git runner:execFile(零 shell 注入面);输出封顶防爆、超时兜底防挂死。 */
|
|
38
|
+
export declare const runGit: GitRunner;
|
|
39
|
+
/** 任务 prompt(AC-4:输入 = issue 标题+正文入队快照);git 收尾由宿主负责,agent 只改工作区。 */
|
|
40
|
+
export declare function githubTaskPrompt(task: GithubTaskPayload): string;
|
|
41
|
+
export type ExecuteDeps = {
|
|
42
|
+
/** 仓库工作目录(实例项目目录,AC-4;client.ts 解析 env/cwd 后注入)。 */
|
|
43
|
+
repoDir: string;
|
|
44
|
+
/** 测试可注入假 git;缺省 = 真 execFile runner。 */
|
|
45
|
+
git?: GitRunner;
|
|
46
|
+
/** 执行注入(client.ts 接 claude-code adapter.invoke;单元测试接假实现)。 */
|
|
47
|
+
invoke: (req: {
|
|
48
|
+
prompt: string;
|
|
49
|
+
timeoutMs: number;
|
|
50
|
+
maxOutputBytes: number;
|
|
51
|
+
cwd: string;
|
|
52
|
+
}) => Promise<InvokeResult>;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 执行单任务(纯函数面):
|
|
56
|
+
* ① git 工作区校验 → ② origin remote 与绑定仓库一致(不一致 → failed 零执行,AC-4)
|
|
57
|
+
* → ③ fetch 默认分支 + checkout -B 任务分支(入队定型,PR-only AC-8)
|
|
58
|
+
* → ④ invoke(claude-code headless 默认权限;超时 kill/截断承 messaging 判例)
|
|
59
|
+
* → ⑤ done 且有改动 → commit + push 任务分支 → done + pushedBranch(AC-5);
|
|
60
|
+
* failed/timeout 原样回流(不 commit 不 push,AC-6);无改动 → failed no_changes(诚实,不开空 PR)。
|
|
61
|
+
*/
|
|
62
|
+
export declare function executeGithubTask(task: GithubTaskPayload, deps: ExecuteDeps): Promise<GithubTaskRunReport>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// GitHub 任务执行器(github-tasks 切片 3 · ADR-007 拉模型第七通道)。
|
|
2
|
+
// 红线:纯出站零反连(只调控制面 API 与本地 git);errorText 全脱敏(git 错误文本可能
|
|
3
|
+
// 含 remote 凭据——AC-12 令牌零明文);invoke 走 claude-code adapter 默认权限(ADR-005
|
|
4
|
+
// 红线,adapter 内钉死不注入放宽 flag);PR-only:push 目标恒为入队定型分支 task.branch
|
|
5
|
+
// (agentory/task-<id>),任何路径不直推默认分支(AC-8)。
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
const ERROR_TEXT_CHARS = 200;
|
|
8
|
+
const GIT_OUTPUT_CAP = 16_384;
|
|
9
|
+
/** 令牌样式串脱敏(AC-12):GitHub PAT 前缀族 + URL 内嵌凭据形态,一律 [redacted]。 */
|
|
10
|
+
export function maskSecrets(text) {
|
|
11
|
+
return text
|
|
12
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, "[redacted]")
|
|
13
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]{16,}\b/g, "[redacted]")
|
|
14
|
+
.replace(/\/\/[^/\s@]*:[^/\s@]*@/g, "//[redacted]@");
|
|
15
|
+
}
|
|
16
|
+
/** remote URL → owner/repo(https / ssh:// / git@ 三形态;非 github.com → undefined = 校验失败)。 */
|
|
17
|
+
export function parseGithubRepo(remoteUrl) {
|
|
18
|
+
const url = remoteUrl.trim();
|
|
19
|
+
const patterns = [
|
|
20
|
+
/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
21
|
+
/^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
22
|
+
/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/,
|
|
23
|
+
];
|
|
24
|
+
for (const p of patterns) {
|
|
25
|
+
const m = p.exec(url);
|
|
26
|
+
if (m)
|
|
27
|
+
return { owner: m[1], repo: m[2] };
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** 失败原因文本:先脱敏、压空白、再截短(控制面 failureReason 200 字符同口径)。 */
|
|
32
|
+
function errText(prefix, detail) {
|
|
33
|
+
return `${prefix}: ${maskSecrets(detail).replace(/\s+/g, " ").trim()}`.slice(0, ERROR_TEXT_CHARS);
|
|
34
|
+
}
|
|
35
|
+
/** 默认 git runner:execFile(零 shell 注入面);输出封顶防爆、超时兜底防挂死。 */
|
|
36
|
+
export const runGit = (args, cwd) => new Promise((resolve) => {
|
|
37
|
+
execFile("git", args, { cwd, maxBuffer: GIT_OUTPUT_CAP * 2, timeout: 120_000 }, (err, stdout, stderr) => {
|
|
38
|
+
const e = err;
|
|
39
|
+
const code = typeof e?.code === "number" ? e.code : e ? 1 : 0;
|
|
40
|
+
resolve({
|
|
41
|
+
code,
|
|
42
|
+
stdout: String(stdout ?? "").slice(0, GIT_OUTPUT_CAP),
|
|
43
|
+
stderr: String(stderr ?? "").slice(0, GIT_OUTPUT_CAP),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
/** 任务 prompt(AC-4:输入 = issue 标题+正文入队快照);git 收尾由宿主负责,agent 只改工作区。 */
|
|
48
|
+
export function githubTaskPrompt(task) {
|
|
49
|
+
return [
|
|
50
|
+
`处理 GitHub issue #${task.issueNumber}:${task.issueTitle}`,
|
|
51
|
+
"",
|
|
52
|
+
task.issueBody,
|
|
53
|
+
"",
|
|
54
|
+
`直接在当前工作区(分支 ${task.branch})完成此 issue;把改动留在工作区即可,git add/commit/push 由宿主负责。`,
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 执行单任务(纯函数面):
|
|
59
|
+
* ① git 工作区校验 → ② origin remote 与绑定仓库一致(不一致 → failed 零执行,AC-4)
|
|
60
|
+
* → ③ fetch 默认分支 + checkout -B 任务分支(入队定型,PR-only AC-8)
|
|
61
|
+
* → ④ invoke(claude-code headless 默认权限;超时 kill/截断承 messaging 判例)
|
|
62
|
+
* → ⑤ done 且有改动 → commit + push 任务分支 → done + pushedBranch(AC-5);
|
|
63
|
+
* failed/timeout 原样回流(不 commit 不 push,AC-6);无改动 → failed no_changes(诚实,不开空 PR)。
|
|
64
|
+
*/
|
|
65
|
+
export async function executeGithubTask(task, deps) {
|
|
66
|
+
const git = deps.git ?? runGit;
|
|
67
|
+
const startedAt = Date.now();
|
|
68
|
+
const fail = (errorText) => ({
|
|
69
|
+
outcome: "failed",
|
|
70
|
+
output: "",
|
|
71
|
+
truncated: false,
|
|
72
|
+
exitCode: null,
|
|
73
|
+
durationMs: Date.now() - startedAt,
|
|
74
|
+
errorText,
|
|
75
|
+
});
|
|
76
|
+
const topLevel = await git(["rev-parse", "--show-toplevel"], deps.repoDir);
|
|
77
|
+
if (topLevel.code !== 0) {
|
|
78
|
+
return fail(errText("not_a_git_repo", topLevel.stderr || "rev-parse failed"));
|
|
79
|
+
}
|
|
80
|
+
// 读配置原值而非 `remote get-url`:后者会应用 url.insteadOf 传输层重写(代理/凭据
|
|
81
|
+
// 替换场景返回重写后 URL),身份校验必须看仓库声明的 origin(AC-4)。
|
|
82
|
+
const remote = await git(["config", "--get", "remote.origin.url"], deps.repoDir);
|
|
83
|
+
if (remote.code !== 0)
|
|
84
|
+
return fail(errText("no_origin_remote", remote.stderr));
|
|
85
|
+
const parsed = parseGithubRepo(remote.stdout);
|
|
86
|
+
if (!parsed || parsed.owner !== task.repoOwner || parsed.repo !== task.repoName) {
|
|
87
|
+
// 不回显 remote URL(可能含凭据)——只说与哪个绑定仓库不一致。
|
|
88
|
+
return fail(`repo_mismatch: origin does not match binding ${task.repoOwner}/${task.repoName}`);
|
|
89
|
+
}
|
|
90
|
+
const fetched = await git(["fetch", "origin", task.defaultBranch], deps.repoDir);
|
|
91
|
+
if (fetched.code !== 0)
|
|
92
|
+
return fail(errText("fetch_failed", fetched.stderr));
|
|
93
|
+
const checkout = await git(["checkout", "-B", task.branch, `origin/${task.defaultBranch}`], deps.repoDir);
|
|
94
|
+
if (checkout.code !== 0)
|
|
95
|
+
return fail(errText("checkout_failed", checkout.stderr));
|
|
96
|
+
const result = await deps.invoke({
|
|
97
|
+
prompt: githubTaskPrompt(task),
|
|
98
|
+
timeoutMs: task.timeoutMs,
|
|
99
|
+
maxOutputBytes: task.maxOutputBytes,
|
|
100
|
+
cwd: deps.repoDir,
|
|
101
|
+
});
|
|
102
|
+
if (result.outcome !== "done") {
|
|
103
|
+
return {
|
|
104
|
+
outcome: result.outcome,
|
|
105
|
+
output: result.output,
|
|
106
|
+
truncated: result.truncated,
|
|
107
|
+
exitCode: result.exitCode,
|
|
108
|
+
durationMs: result.durationMs,
|
|
109
|
+
...(result.errorText ? { errorText: errText("execution", result.errorText) } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
await git(["add", "-A"], deps.repoDir);
|
|
113
|
+
const status = await git(["status", "--porcelain"], deps.repoDir);
|
|
114
|
+
if (status.code !== 0)
|
|
115
|
+
return fail(errText("status_failed", status.stderr));
|
|
116
|
+
if (!status.stdout.trim())
|
|
117
|
+
return fail("no_changes: agent produced no workspace changes");
|
|
118
|
+
const commit = await git(["commit", "-m", `agentory: ${task.issueTitle} (#${task.issueNumber})`], deps.repoDir);
|
|
119
|
+
if (commit.code !== 0)
|
|
120
|
+
return fail(errText("commit_failed", commit.stderr));
|
|
121
|
+
const push = await git(["push", "-u", "origin", task.branch], deps.repoDir);
|
|
122
|
+
if (push.code !== 0)
|
|
123
|
+
return fail(errText("push_failed", push.stderr));
|
|
124
|
+
return {
|
|
125
|
+
outcome: "done",
|
|
126
|
+
output: result.output,
|
|
127
|
+
truncated: result.truncated,
|
|
128
|
+
exitCode: result.exitCode,
|
|
129
|
+
durationMs: result.durationMs,
|
|
130
|
+
pushedBranch: task.branch,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// github-task 执行器单测(github-tasks 切片 3):假 git + 假 invoke 注入面。
|
|
2
|
+
// 覆盖:remote URL 解析矩阵 · 脱敏(AC-12)· remote 不一致 failed 零执行(AC-4)·
|
|
3
|
+
// happy path 全序列(fetch→checkout -B 任务分支→invoke→add/status/commit/push→done+pushedBranch,
|
|
4
|
+
// AC-5/8)· invoke failed/timeout 原样回流不 push(AC-6)· 无改动 no_changes 诚实失败 ·
|
|
5
|
+
// git 步骤失败脱敏回流 · prompt = issue 标题+正文快照(AC-4 输入面)。
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { executeGithubTask, githubTaskPrompt, maskSecrets, parseGithubRepo, } from "./github-task.js";
|
|
9
|
+
const TASK = {
|
|
10
|
+
id: "ght_1",
|
|
11
|
+
repoOwner: "tunsuy",
|
|
12
|
+
repoName: "agentory",
|
|
13
|
+
defaultBranch: "main",
|
|
14
|
+
issueNumber: 901,
|
|
15
|
+
issueTitle: "修复登录闪烁",
|
|
16
|
+
issueBody: "复现步骤 BODY",
|
|
17
|
+
branch: "agentory/task-ght_1",
|
|
18
|
+
timeoutMs: 60_000,
|
|
19
|
+
maxOutputBytes: 1024,
|
|
20
|
+
};
|
|
21
|
+
/** 假 git:按 args 前缀匹配计划表;全调用留痕供序列断言。 */
|
|
22
|
+
function fakeGit(plan, calls) {
|
|
23
|
+
return async (args) => {
|
|
24
|
+
const key = args.join(" ");
|
|
25
|
+
calls.push(args);
|
|
26
|
+
for (const [prefix, out] of plan) {
|
|
27
|
+
if (key.startsWith(prefix)) {
|
|
28
|
+
return { code: out.code ?? 0, stdout: out.stdout ?? "", stderr: out.stderr ?? "" };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const OK_REMOTE = [
|
|
35
|
+
"config --get remote.origin.url",
|
|
36
|
+
{ stdout: "https://github.com/tunsuy/agentory.git\n" },
|
|
37
|
+
];
|
|
38
|
+
function fakeInvoke(result, seen) {
|
|
39
|
+
return async (req) => {
|
|
40
|
+
seen.push({ prompt: req.prompt, cwd: req.cwd });
|
|
41
|
+
return {
|
|
42
|
+
outcome: "done",
|
|
43
|
+
output: "mock ok",
|
|
44
|
+
truncated: false,
|
|
45
|
+
exitCode: 0,
|
|
46
|
+
durationMs: 5,
|
|
47
|
+
...result,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
test("parseGithubRepo:https/ssh/git@ 三形态 + .git 后缀 + 非 github 拒绝", () => {
|
|
52
|
+
assert.deepEqual(parseGithubRepo("https://github.com/tunsuy/agentory.git"), { owner: "tunsuy", repo: "agentory" });
|
|
53
|
+
assert.deepEqual(parseGithubRepo("https://github.com/tunsuy/agentory"), { owner: "tunsuy", repo: "agentory" });
|
|
54
|
+
assert.deepEqual(parseGithubRepo("ssh://git@github.com/tunsuy/agentory.git"), { owner: "tunsuy", repo: "agentory" });
|
|
55
|
+
assert.deepEqual(parseGithubRepo("git@github.com:tunsuy/agentory.git"), { owner: "tunsuy", repo: "agentory" });
|
|
56
|
+
assert.equal(parseGithubRepo("https://gitlab.com/tunsuy/agentory.git"), undefined, "非 github.com 拒绝");
|
|
57
|
+
assert.equal(parseGithubRepo("https://github.com.evil.com/tunsuy/agentory.git"), undefined, "域名后缀伪装拒绝");
|
|
58
|
+
assert.equal(parseGithubRepo("/srv/git/origin.git"), undefined, "本地路径拒绝");
|
|
59
|
+
assert.equal(parseGithubRepo(""), undefined);
|
|
60
|
+
});
|
|
61
|
+
test("maskSecrets:PAT 前缀族 + URL 内嵌凭据(AC-12)", () => {
|
|
62
|
+
assert.ok(!maskSecrets("token ghp_abcdefghijklmnop0123 leaked").includes("ghp_"));
|
|
63
|
+
assert.ok(maskSecrets("ghp_abcdefghijklmnop0123").includes("[redacted]"));
|
|
64
|
+
assert.ok(!maskSecrets("github_pat_11ABCDEFGH_abcdefghij0123").includes("github_pat_11"));
|
|
65
|
+
const masked = maskSecrets("https://x-access-token:ghs_secretvalue123456@github.com/o/r");
|
|
66
|
+
assert.ok(!masked.includes("ghs_secretvalue123456"), `URL 凭据应脱敏:${masked}`);
|
|
67
|
+
});
|
|
68
|
+
test("githubTaskPrompt:issue 标题+正文快照+任务分支(AC-4 输入面)", () => {
|
|
69
|
+
const p = githubTaskPrompt(TASK);
|
|
70
|
+
assert.ok(p.includes("#901"));
|
|
71
|
+
assert.ok(p.includes("修复登录闪烁"));
|
|
72
|
+
assert.ok(p.includes("复现步骤 BODY"));
|
|
73
|
+
assert.ok(p.includes(TASK.branch));
|
|
74
|
+
});
|
|
75
|
+
test("happy path:fetch→checkout -B→invoke→add/status/commit/push→done+pushedBranch(AC-5/8)", async () => {
|
|
76
|
+
const calls = [];
|
|
77
|
+
const seen = [];
|
|
78
|
+
const git = fakeGit([OK_REMOTE, ["status --porcelain", { stdout: "M fix.txt\n" }]], calls);
|
|
79
|
+
const report = await executeGithubTask(TASK, { repoDir: "/repo", git, invoke: fakeInvoke({}, seen) });
|
|
80
|
+
assert.equal(report.outcome, "done");
|
|
81
|
+
assert.equal(report.pushedBranch, TASK.branch, "push 目标 = 入队定型任务分支(PR-only AC-8)");
|
|
82
|
+
assert.equal(report.exitCode, 0);
|
|
83
|
+
const keys = calls.map((a) => a.join(" "));
|
|
84
|
+
const idx = (prefix) => keys.findIndex((k) => k.startsWith(prefix));
|
|
85
|
+
assert.ok(idx("fetch origin main") >= 0, "先 fetch 默认分支");
|
|
86
|
+
assert.ok(keys.includes(`checkout -B ${TASK.branch} origin/main`), "基于 origin/main 建任务分支");
|
|
87
|
+
assert.ok(idx("add -A") > idx("checkout"), "invoke 后 add");
|
|
88
|
+
assert.ok(idx("commit -m") > idx("add -A"));
|
|
89
|
+
assert.ok(keys.includes(`push -u origin ${TASK.branch}`), "push 任务分支");
|
|
90
|
+
assert.ok(!keys.some((k) => k.startsWith("push") && k.includes("main")), "绝不直推默认分支(AC-8)");
|
|
91
|
+
assert.equal(seen.length, 1);
|
|
92
|
+
assert.ok(seen[0].prompt.includes("复现步骤 BODY"), "prompt 携带 issue 快照");
|
|
93
|
+
assert.equal(seen[0].cwd, "/repo");
|
|
94
|
+
});
|
|
95
|
+
test("remote 不一致 → failed repo_mismatch,invoke/fetch 零调用(AC-4 零执行)", async () => {
|
|
96
|
+
const calls = [];
|
|
97
|
+
const seen = [];
|
|
98
|
+
const git = fakeGit([["config --get remote.origin.url", { stdout: "https://github.com/evil/other-repo.git\n" }]], calls);
|
|
99
|
+
const report = await executeGithubTask(TASK, { repoDir: "/repo", git, invoke: fakeInvoke({}, seen) });
|
|
100
|
+
assert.equal(report.outcome, "failed");
|
|
101
|
+
assert.match(report.errorText ?? "", /^repo_mismatch/);
|
|
102
|
+
assert.ok((report.errorText ?? "").includes("tunsuy/agentory"), "原因指明绑定仓库");
|
|
103
|
+
assert.equal(seen.length, 0, "零执行");
|
|
104
|
+
const keys = calls.map((a) => a.join(" "));
|
|
105
|
+
assert.ok(!keys.some((k) => k.startsWith("fetch")), "不一致时不 fetch");
|
|
106
|
+
assert.ok(!keys.some((k) => k.startsWith("checkout")), "不一致时不动工作区");
|
|
107
|
+
assert.ok(!report.errorText?.includes("evil/other-repo"), "不回显 remote URL(可能含凭据)");
|
|
108
|
+
});
|
|
109
|
+
test("非 git 目录 / 无 origin → failed 诚实回流", async () => {
|
|
110
|
+
const calls = [];
|
|
111
|
+
const git = fakeGit([["rev-parse --show-toplevel", { code: 128, stderr: "not a git repository" }]], calls);
|
|
112
|
+
const report = await executeGithubTask(TASK, {
|
|
113
|
+
repoDir: "/nope",
|
|
114
|
+
git,
|
|
115
|
+
invoke: fakeInvoke({}, []),
|
|
116
|
+
});
|
|
117
|
+
assert.equal(report.outcome, "failed");
|
|
118
|
+
assert.match(report.errorText ?? "", /^not_a_git_repo/);
|
|
119
|
+
});
|
|
120
|
+
test("invoke failed/timeout 原样回流:不 commit 不 push(AC-6)", async () => {
|
|
121
|
+
for (const outcome of ["failed", "timeout"]) {
|
|
122
|
+
const calls = [];
|
|
123
|
+
const git = fakeGit([OK_REMOTE], calls);
|
|
124
|
+
const report = await executeGithubTask(TASK, {
|
|
125
|
+
repoDir: "/repo",
|
|
126
|
+
git,
|
|
127
|
+
invoke: fakeInvoke({ outcome, output: "partial", truncated: false, exitCode: outcome === "failed" ? 7 : null, durationMs: 12, errorText: "boom" }, []),
|
|
128
|
+
});
|
|
129
|
+
assert.equal(report.outcome, outcome);
|
|
130
|
+
assert.equal(report.exitCode, outcome === "failed" ? 7 : null);
|
|
131
|
+
assert.match(report.errorText ?? "", /^execution: boom/);
|
|
132
|
+
const keys = calls.map((a) => a.join(" "));
|
|
133
|
+
assert.ok(!keys.some((k) => k.startsWith("push")), `${outcome} 不 push`);
|
|
134
|
+
assert.ok(!keys.some((k) => k.startsWith("commit")), `${outcome} 不 commit`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test("无改动 → failed no_changes(诚实,不开空 PR)", async () => {
|
|
138
|
+
const calls = [];
|
|
139
|
+
const git = fakeGit([OK_REMOTE, ["status --porcelain", { stdout: "" }]], calls);
|
|
140
|
+
const report = await executeGithubTask(TASK, { repoDir: "/repo", git, invoke: fakeInvoke({}, []) });
|
|
141
|
+
assert.equal(report.outcome, "failed");
|
|
142
|
+
assert.match(report.errorText ?? "", /^no_changes/);
|
|
143
|
+
const keys = calls.map((a) => a.join(" "));
|
|
144
|
+
assert.ok(!keys.some((k) => k.startsWith("push")));
|
|
145
|
+
});
|
|
146
|
+
test("fetch/push 失败 → 脱敏 errorText 回流(AC-12)", async () => {
|
|
147
|
+
const fetchCalls = [];
|
|
148
|
+
const fetchFail = await executeGithubTask(TASK, {
|
|
149
|
+
repoDir: "/repo",
|
|
150
|
+
git: fakeGit([OK_REMOTE, ["fetch origin", { code: 1, stderr: "fatal: unable to access 'https://x-access-token:ghs_secretvalue123456@github.com/…'" }]], fetchCalls),
|
|
151
|
+
invoke: fakeInvoke({}, []),
|
|
152
|
+
});
|
|
153
|
+
assert.equal(fetchFail.outcome, "failed");
|
|
154
|
+
assert.match(fetchFail.errorText ?? "", /^fetch_failed/);
|
|
155
|
+
assert.ok(!(fetchFail.errorText ?? "").includes("ghs_secretvalue123456"), "凭据脱敏");
|
|
156
|
+
assert.ok((fetchFail.errorText ?? "").length <= 200, "截短同控制面口径");
|
|
157
|
+
const pushCalls = [];
|
|
158
|
+
const pushFail = await executeGithubTask(TASK, {
|
|
159
|
+
repoDir: "/repo",
|
|
160
|
+
git: fakeGit([OK_REMOTE, ["status --porcelain", { stdout: "M a\n" }], ["push -u origin", { code: 1, stderr: "remote: Invalid PAT ghp_abcdefghijklmnop0123" }]], pushCalls),
|
|
161
|
+
invoke: fakeInvoke({}, []),
|
|
162
|
+
});
|
|
163
|
+
assert.equal(pushFail.outcome, "failed");
|
|
164
|
+
assert.match(pushFail.errorText ?? "", /^push_failed/);
|
|
165
|
+
assert.ok(!(pushFail.errorText ?? "").includes("ghp_"), "PAT 脱敏");
|
|
166
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { runHeadless } from "@agentoryhq/adapter-claude-code";
|
|
5
|
+
/**
|
|
6
|
+
* Mock 执行 runtime(scripts/mock-claude.mjs)确定性回放断言。
|
|
7
|
+
* 走 runHeadless 真 spawn 路径 = smoke 全链的同一机制(超时/截断/退出码全真)。
|
|
8
|
+
*/
|
|
9
|
+
const MOCK_BIN = fileURLToPath(new URL("../../../scripts/mock-claude.mjs", import.meta.url));
|
|
10
|
+
function run(prompt, overrides = {}) {
|
|
11
|
+
return runHeadless({
|
|
12
|
+
command: process.execPath,
|
|
13
|
+
args: [MOCK_BIN, "-p", prompt, "--session-id", "11111111-2222-3333-4444-555555555555"],
|
|
14
|
+
cwd: process.cwd(),
|
|
15
|
+
timeoutMs: 10_000,
|
|
16
|
+
maxOutputBytes: 64 * 1024,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
test("mock: 默认回放 mock ok: <prompt>", async () => {
|
|
21
|
+
const res = await run("总结最近一周构建失败原因");
|
|
22
|
+
assert.equal(res.outcome, "done");
|
|
23
|
+
assert.equal(res.exitCode, 0);
|
|
24
|
+
assert.match(res.output, /mock ok: 总结最近一周构建失败原因/);
|
|
25
|
+
});
|
|
26
|
+
test("mock: mock:text 指令覆盖输出", async () => {
|
|
27
|
+
const res = await run("mock:text=P95-分析完成");
|
|
28
|
+
assert.equal(res.outcome, "done");
|
|
29
|
+
assert.match(res.output, /P95-分析完成/);
|
|
30
|
+
});
|
|
31
|
+
test("mock: outcome=failed + exit=7 → failed 退出码 7", async () => {
|
|
32
|
+
const res = await run("mock:outcome=failed mock:exit=7");
|
|
33
|
+
assert.equal(res.outcome, "failed");
|
|
34
|
+
assert.equal(res.exitCode, 7);
|
|
35
|
+
assert.match(res.errorText ?? "", /退出码 7/);
|
|
36
|
+
});
|
|
37
|
+
test("mock: bytes=5000 撑爆 maxOutputBytes=50 → 真截断", async () => {
|
|
38
|
+
const res = await run("mock:bytes=5000", { maxOutputBytes: 50 });
|
|
39
|
+
assert.equal(res.outcome, "done");
|
|
40
|
+
assert.equal(res.truncated, true);
|
|
41
|
+
assert.equal(Buffer.byteLength(res.output, "utf8"), 50);
|
|
42
|
+
});
|
|
43
|
+
test("mock: delay=300 → durationMs ≥ 250(可注入延迟)", async () => {
|
|
44
|
+
const res = await run("mock:delay=300");
|
|
45
|
+
assert.equal(res.outcome, "done");
|
|
46
|
+
assert.ok(res.durationMs >= 250, `durationMs=${res.durationMs}`);
|
|
47
|
+
});
|
|
48
|
+
test("mock: hang=1 → runner 真超时 kill + 部分输出回流(AC-7 全真路径)", async () => {
|
|
49
|
+
const started = Date.now();
|
|
50
|
+
const res = await run("mock:hang=1", { timeoutMs: 600 });
|
|
51
|
+
assert.equal(res.outcome, "timeout");
|
|
52
|
+
assert.match(res.output, /mock partial before hang/);
|
|
53
|
+
assert.match(res.errorText ?? "", /执行超时/);
|
|
54
|
+
assert.ok(Date.now() - started < 8_000, "超时即收口");
|
|
55
|
+
});
|
|
56
|
+
test("mock: 确定性——同输入两次结果一致(CI 可跑判据)", async () => {
|
|
57
|
+
const a = await run("mock:text=det");
|
|
58
|
+
const b = await run("mock:text=det");
|
|
59
|
+
assert.equal(a.output, b.output);
|
|
60
|
+
assert.equal(a.outcome, b.outcome);
|
|
61
|
+
assert.equal(a.exitCode, b.exitCode);
|
|
62
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { ADAPTER_API_VERSION, hasCapability } from "@agentoryhq/runtime-contract";
|
|
4
|
+
import { adapterForRuntime, builtinAdapters } from "./registry.js";
|
|
5
|
+
/**
|
|
6
|
+
* capability ↔ 方法一致性(契约注释「Optional methods must match declared
|
|
7
|
+
* capabilities」的机械闸)。声明了 capability 就必须有对应方法;实现了可选
|
|
8
|
+
* 方法就必须声明对应 capability。
|
|
9
|
+
*/
|
|
10
|
+
const CAPABILITY_METHODS = {
|
|
11
|
+
detect: ["detect"],
|
|
12
|
+
discoverConfig: ["discoverConfig"],
|
|
13
|
+
applyAlign: ["applyAlign"],
|
|
14
|
+
indexSessions: ["indexSessions"],
|
|
15
|
+
readTranscript: ["transcriptFileForSourceKey", "captureTranscriptSnapshot"],
|
|
16
|
+
/** 控制面侧导出(handoff snapshot),无 adapter 方法。 */
|
|
17
|
+
handoffExport: [],
|
|
18
|
+
invoke: ["invoke"],
|
|
19
|
+
};
|
|
20
|
+
const METHOD_CAPABILITY = {
|
|
21
|
+
discoverConfig: "discoverConfig",
|
|
22
|
+
applyAlign: "applyAlign",
|
|
23
|
+
indexSessions: "indexSessions",
|
|
24
|
+
transcriptFileForSourceKey: "readTranscript",
|
|
25
|
+
captureTranscriptSnapshot: "readTranscript",
|
|
26
|
+
invoke: "invoke",
|
|
27
|
+
};
|
|
28
|
+
test("builtin adapters: manifest capabilities match implemented methods", () => {
|
|
29
|
+
assert.ok(builtinAdapters.length >= 2);
|
|
30
|
+
for (const adapter of builtinAdapters) {
|
|
31
|
+
const { runtime, adapterApi, capabilities } = adapter.manifest;
|
|
32
|
+
assert.equal(adapterApi, ADAPTER_API_VERSION, `${runtime}: adapter_api 保持 1(additive 契约)`);
|
|
33
|
+
assert.ok(capabilities.includes("detect"), `${runtime}: detect 必声明`);
|
|
34
|
+
for (const cap of capabilities) {
|
|
35
|
+
for (const method of CAPABILITY_METHODS[cap]) {
|
|
36
|
+
assert.equal(typeof adapter[method], "function", `${runtime}: 声明 capability "${cap}" 但缺方法 ${method}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const [method, cap] of Object.entries(METHOD_CAPABILITY)) {
|
|
40
|
+
if (typeof adapter[method] === "function") {
|
|
41
|
+
assert.ok(hasCapability(adapter.manifest, cap), `${runtime}: 实现了 ${method} 但未声明 capability "${cap}"`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
test("invoke capability 诚实边界(AC-4 契约面):claude-code 声明并实现;cursor 不声明、不实现", () => {
|
|
47
|
+
const claude = adapterForRuntime("claude-code");
|
|
48
|
+
assert.ok(claude);
|
|
49
|
+
assert.ok(hasCapability(claude.manifest, "invoke"), "claude-code 有 headless 执行面,须声明 invoke");
|
|
50
|
+
assert.equal(typeof claude.invoke, "function");
|
|
51
|
+
const cursor = adapterForRuntime("cursor");
|
|
52
|
+
assert.ok(cursor);
|
|
53
|
+
assert.ok(!hasCapability(cursor.manifest, "invoke"), "cursor 无 headless 执行面,不得声明 invoke");
|
|
54
|
+
assert.equal(cursor.invoke, undefined);
|
|
55
|
+
});
|
|
56
|
+
test("adapterForRuntime resolves builtins; unknown runtime returns undefined", () => {
|
|
57
|
+
assert.equal(adapterForRuntime("claude-code")?.manifest.displayName, "Claude Code");
|
|
58
|
+
assert.equal(adapterForRuntime("cursor")?.manifest.displayName, "Cursor");
|
|
59
|
+
assert.equal(adapterForRuntime("nope-runtime"), undefined);
|
|
60
|
+
});
|
package/dist/transcript.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
import type { TranscriptSnapshot } from "@agentoryhq/shared";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* Transcript routing (claude-session-index §3-4): resolve each pending
|
|
4
|
+
* sourceKey across builtin adapters — the adapter whose
|
|
5
|
+
* `transcriptFileForSourceKey` recognizes the key owns it, and its own
|
|
6
|
+
* `captureTranscriptSnapshot` captures it (cursor keys stay on the cursor
|
|
7
|
+
* adapter, claude-session: keys route to claude-code). Unknown prefixes
|
|
8
|
+
* resolve to undefined = miss, same semantics as before. Same iteration
|
|
9
|
+
* shape as discoverAssetIndex.
|
|
3
10
|
*/
|
|
11
|
+
export declare function captureTranscriptForSourceKey(sourceKey: string, homeDir?: string): TranscriptSnapshot | undefined;
|
|
4
12
|
export { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptSnapshot, maskSecrets, transcriptFileForSourceKey, } from "@agentoryhq/adapter-cursor";
|
|
5
13
|
export type TranscriptFetchOpts = {
|
|
6
14
|
homeDir?: string;
|
package/dist/transcript.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { builtinAdapters } from "./registry.js";
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
4
|
+
* Transcript routing (claude-session-index §3-4): resolve each pending
|
|
5
|
+
* sourceKey across builtin adapters — the adapter whose
|
|
6
|
+
* `transcriptFileForSourceKey` recognizes the key owns it, and its own
|
|
7
|
+
* `captureTranscriptSnapshot` captures it (cursor keys stay on the cursor
|
|
8
|
+
* adapter, claude-session: keys route to claude-code). Unknown prefixes
|
|
9
|
+
* resolve to undefined = miss, same semantics as before. Same iteration
|
|
10
|
+
* shape as discoverAssetIndex.
|
|
3
11
|
*/
|
|
12
|
+
export function captureTranscriptForSourceKey(sourceKey, homeDir) {
|
|
13
|
+
const home = homeDir ?? os.homedir();
|
|
14
|
+
for (const adapter of builtinAdapters) {
|
|
15
|
+
if (!adapter.transcriptFileForSourceKey || !adapter.captureTranscriptSnapshot)
|
|
16
|
+
continue;
|
|
17
|
+
const file = adapter.transcriptFileForSourceKey(sourceKey, home);
|
|
18
|
+
if (file === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
return adapter.captureTranscriptSnapshot(file);
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
4
24
|
export { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptSnapshot, maskSecrets, transcriptFileForSourceKey, } from "@agentoryhq/adapter-cursor";
|
package/dist/transcript.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
-
import { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptSnapshot, maskSecrets, transcriptFileForSourceKey, } from "./transcript.js";
|
|
6
|
+
import { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptForSourceKey, captureTranscriptSnapshot, maskSecrets, transcriptFileForSourceKey, } from "./transcript.js";
|
|
7
7
|
function makeHome() {
|
|
8
8
|
return fs.mkdtempSync(path.join(os.tmpdir(), "agentory-tr-"));
|
|
9
9
|
}
|
|
@@ -103,6 +103,41 @@ test("captureTranscriptSnapshot empty / missing files (AC-1 空态)", () => {
|
|
|
103
103
|
fs.rmSync(home, { recursive: true, force: true });
|
|
104
104
|
}
|
|
105
105
|
});
|
|
106
|
+
test("captureTranscriptForSourceKey routes cursor + claude keys to their adapters; unknown → undefined (AC-5)", () => {
|
|
107
|
+
const home = makeHome();
|
|
108
|
+
try {
|
|
109
|
+
// cursor session jsonl under ~/.cursor/projects/
|
|
110
|
+
const cursorFile = writeJsonl(home, "p/agent-transcripts/id1/id1.jsonl", [
|
|
111
|
+
{ role: "user", message: "cursor question", ts: "2026-10-30T10:00:00.000Z" },
|
|
112
|
+
{ role: "assistant", message: { content: [{ type: "text", text: "cursor answer" }] }, ts: "2026-10-30T10:00:05.000Z" },
|
|
113
|
+
]);
|
|
114
|
+
// claude session jsonl under ~/.claude/projects/
|
|
115
|
+
const claudeFile = path.join(home, ".claude", "projects", "Users-demo-work-app", "11111111-2222-3333-4444-555555555555.jsonl");
|
|
116
|
+
fs.mkdirSync(path.dirname(claudeFile), { recursive: true });
|
|
117
|
+
fs.writeFileSync(claudeFile, [
|
|
118
|
+
JSON.stringify({ type: "user", origin: { kind: "human" }, message: { role: "user", content: "claude question" }, timestamp: "2026-10-30T10:00:00.000Z" }),
|
|
119
|
+
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "claude answer" }] }, timestamp: "2026-10-30T10:00:05.000Z" }),
|
|
120
|
+
].join("\n"));
|
|
121
|
+
// cursor key routes to the cursor adapter
|
|
122
|
+
const cursorSnap = captureTranscriptForSourceKey(`cursor-transcript:${cursorFile}`, home);
|
|
123
|
+
assert.ok(cursorSnap);
|
|
124
|
+
assert.equal(cursorSnap.sourceKey, `cursor-transcript:${cursorFile}`);
|
|
125
|
+
assert.equal(cursorSnap.messages.length, 2);
|
|
126
|
+
assert.equal(cursorSnap.messages[0].text, "cursor question");
|
|
127
|
+
// claude key routes to the claude-code adapter
|
|
128
|
+
const claudeSnap = captureTranscriptForSourceKey(`claude-session:${claudeFile}`, home);
|
|
129
|
+
assert.ok(claudeSnap);
|
|
130
|
+
assert.equal(claudeSnap.sourceKey, `claude-session:${claudeFile}`);
|
|
131
|
+
assert.equal(claudeSnap.messages.length, 2);
|
|
132
|
+
assert.equal(claudeSnap.messages[0].text, "claude question");
|
|
133
|
+
// unknown prefix / missing file → undefined (miss, 既有语义)
|
|
134
|
+
assert.equal(captureTranscriptForSourceKey("other-runtime:whatever", home), undefined);
|
|
135
|
+
assert.equal(captureTranscriptForSourceKey(`claude-session:${path.join(home, ".claude", "projects", "slug", "gone.jsonl")}`, home), undefined);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
106
141
|
test("transcriptFileForSourceKey enforces ~/.cursor/projects containment (invariants §4)", () => {
|
|
107
142
|
const home = makeHome();
|
|
108
143
|
try {
|
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentoryhq/node",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Agentory Node CLI
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Agentory Node CLI \u2014 register, discover, align, daemon",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"agentory-node": "./dist/cli.js"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
11
14
|
"scripts": {
|
|
12
15
|
"build": "tsc -p tsconfig.json",
|
|
13
16
|
"start": "node dist/cli.js"
|
|
@@ -20,8 +23,7 @@
|
|
|
20
23
|
},
|
|
21
24
|
"repository": {
|
|
22
25
|
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/tunsuy/agentory-node.git"
|
|
24
|
-
"directory": "apps/node"
|
|
26
|
+
"url": "git+https://github.com/tunsuy/agentory-node.git"
|
|
25
27
|
},
|
|
26
28
|
"publishConfig": {
|
|
27
29
|
"access": "public"
|
package/dist/daemon.e2e.test.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { once } from "node:events";
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
/**
|
|
8
|
-
* AC-1 / AC-3 integration: real API (dist server, MemoryStore) + daemon --once
|
|
9
|
-
* through the real client path, fixture-driven asset index. Self-contained:
|
|
10
|
-
* boots its own API on an ephemeral port with a temp state dir.
|
|
11
|
-
*/
|
|
12
|
-
test("AC-1 + AC-3: daemon --once full round reports to server; second run skips unchanged index", async () => {
|
|
13
|
-
const { createApiServer } = await import("../../api/dist/server.js");
|
|
14
|
-
const { MemoryStore } = await import("../../api/dist/store.js");
|
|
15
|
-
const store = new MemoryStore();
|
|
16
|
-
const server = createApiServer(store);
|
|
17
|
-
server.listen(0, "127.0.0.1");
|
|
18
|
-
await once(server, "listening");
|
|
19
|
-
const port = server.address().port;
|
|
20
|
-
const base = `http://127.0.0.1:${port}`;
|
|
21
|
-
const bs = await fetch(`${base}/v1/bootstrap`, {
|
|
22
|
-
method: "POST",
|
|
23
|
-
headers: { "content-type": "application/json" },
|
|
24
|
-
body: JSON.stringify({ email: "daemon-e2e@studio.dev" }),
|
|
25
|
-
});
|
|
26
|
-
const bsBody = (await bs.json());
|
|
27
|
-
const workspaceId = bsBody.workspace.id;
|
|
28
|
-
const reg = await fetch(`${base}/v1/nodes/register`, {
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: { authorization: `Bearer ${bsBody.nodeRootToken}`, "content-type": "application/json" },
|
|
31
|
-
body: JSON.stringify({ name: "daemon-e2e", hostname: "e2e.local" }),
|
|
32
|
-
});
|
|
33
|
-
const regBody = (await reg.json());
|
|
34
|
-
const nodeId = regBody.node.id;
|
|
35
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentory-daemon-e2e-"));
|
|
36
|
-
const fixture = path.join(dir, "asset-index.json");
|
|
37
|
-
fs.writeFileSync(fixture, JSON.stringify({
|
|
38
|
-
sessions: [
|
|
39
|
-
{ sourceKey: "s1", displayName: "daemon-e2e-session", projectPath: "/p", updatedAt: "2026-09-24T10:00:00Z" },
|
|
40
|
-
],
|
|
41
|
-
memories: [],
|
|
42
|
-
}));
|
|
43
|
-
const prev = {
|
|
44
|
-
state: process.env.AGENTORY_NODE_STATE,
|
|
45
|
-
fixtureEnv: process.env.AGENTORY_ASSET_INDEX_FIXTURE,
|
|
46
|
-
cursorRoot: process.env.AGENTORY_CURSOR_ROOT,
|
|
47
|
-
base: process.env.CONTROL_PLANE_BASE_URL,
|
|
48
|
-
};
|
|
49
|
-
process.env.AGENTORY_NODE_STATE = path.join(dir, "node.json");
|
|
50
|
-
// daemon 的去重 state 文件落在 state 同目录(daemon-state.json)
|
|
51
|
-
process.env.AGENTORY_ASSET_INDEX_FIXTURE = fixture;
|
|
52
|
-
process.env.AGENTORY_CURSOR_ROOT = dir;
|
|
53
|
-
process.env.CONTROL_PLANE_BASE_URL = base;
|
|
54
|
-
try {
|
|
55
|
-
const { runDaemon } = await import("./daemon.js");
|
|
56
|
-
fs.writeFileSync(path.join(dir, "node.json"), JSON.stringify({ workspaceId, nodeId, nodeToken: regBody.nodeToken, controlPlaneBaseUrl: base }));
|
|
57
|
-
const capture = () => {
|
|
58
|
-
const lines = [];
|
|
59
|
-
const orig = console.log;
|
|
60
|
-
console.log = (...args) => {
|
|
61
|
-
lines.push(args.map(String).join(" "));
|
|
62
|
-
};
|
|
63
|
-
return {
|
|
64
|
-
lines,
|
|
65
|
-
restore: () => {
|
|
66
|
-
console.log = orig;
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
};
|
|
70
|
-
// Round 1: heartbeat + align + asset-index + discover all ok.
|
|
71
|
-
const c1 = capture();
|
|
72
|
-
let code1;
|
|
73
|
-
try {
|
|
74
|
-
code1 = await runDaemon({ once: true });
|
|
75
|
-
}
|
|
76
|
-
finally {
|
|
77
|
-
c1.restore();
|
|
78
|
-
}
|
|
79
|
-
assert.equal(code1, 0, "first --once should succeed");
|
|
80
|
-
assert.ok(c1.lines.some((l) => l.includes('"channel":"heartbeat"') && l.includes('"ok"')), "heartbeat ok");
|
|
81
|
-
assert.ok(c1.lines.some((l) => l.includes('"channel":"asset-index"') && l.includes('"ok"')), "asset-index posted");
|
|
82
|
-
// Server side: session index now contains the fixture session for this node.
|
|
83
|
-
const idx = await fetch(`${base}/v1/workspaces/${workspaceId}/session-index?q=daemon-e2e-session`, {
|
|
84
|
-
headers: { authorization: `Bearer ${bsBody.sessionToken}` },
|
|
85
|
-
});
|
|
86
|
-
const idxBody = (await idx.json());
|
|
87
|
-
assert.equal(idx.status, 200);
|
|
88
|
-
assert.ok(idxBody.sessions.some((s) => s.displayName === "daemon-e2e-session"), "fixture session visible in session-index");
|
|
89
|
-
// Round 2: index unchanged → skip; heartbeat still fires.
|
|
90
|
-
const c2 = capture();
|
|
91
|
-
let code2;
|
|
92
|
-
try {
|
|
93
|
-
code2 = await runDaemon({ once: true });
|
|
94
|
-
}
|
|
95
|
-
finally {
|
|
96
|
-
c2.restore();
|
|
97
|
-
}
|
|
98
|
-
assert.equal(code2, 0, "second --once should succeed (heartbeat goes, index skips)");
|
|
99
|
-
assert.ok(c2.lines.some((l) => l.includes("unchanged, skip")), "asset-index unchanged, skip");
|
|
100
|
-
assert.ok(c2.lines.some((l) => l.includes('"channel":"heartbeat"') && l.includes('"ok"')), "heartbeat still ok");
|
|
101
|
-
}
|
|
102
|
-
finally {
|
|
103
|
-
if (prev.state === undefined)
|
|
104
|
-
delete process.env.AGENTORY_NODE_STATE;
|
|
105
|
-
else
|
|
106
|
-
process.env.AGENTORY_NODE_STATE = prev.state;
|
|
107
|
-
if (prev.fixtureEnv === undefined)
|
|
108
|
-
delete process.env.AGENTORY_ASSET_INDEX_FIXTURE;
|
|
109
|
-
else
|
|
110
|
-
process.env.AGENTORY_ASSET_INDEX_FIXTURE = prev.fixtureEnv;
|
|
111
|
-
if (prev.cursorRoot === undefined)
|
|
112
|
-
delete process.env.AGENTORY_CURSOR_ROOT;
|
|
113
|
-
else
|
|
114
|
-
process.env.AGENTORY_CURSOR_ROOT = prev.cursorRoot;
|
|
115
|
-
if (prev.base === undefined)
|
|
116
|
-
delete process.env.CONTROL_PLANE_BASE_URL;
|
|
117
|
-
else
|
|
118
|
-
process.env.CONTROL_PLANE_BASE_URL = prev.base;
|
|
119
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
-
server.close();
|
|
121
|
-
await once(server, "close");
|
|
122
|
-
}
|
|
123
|
-
});
|
|
File without changes
|