@agentoryhq/node 0.1.1
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 +5 -0
- package/dist/align.d.ts +28 -0
- package/dist/align.js +32 -0
- package/dist/align.test.d.ts +1 -0
- package/dist/align.test.js +63 -0
- package/dist/asset-index.d.ts +15 -0
- package/dist/asset-index.js +60 -0
- package/dist/asset-index.test.d.ts +1 -0
- package/dist/asset-index.test.js +87 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +89 -0
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +7 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.js +198 -0
- package/dist/daemon.d.ts +32 -0
- package/dist/daemon.e2e.test.d.ts +1 -0
- package/dist/daemon.e2e.test.js +123 -0
- package/dist/daemon.js +223 -0
- package/dist/daemon.test.d.ts +1 -0
- package/dist/daemon.test.js +132 -0
- package/dist/discover.d.ts +14 -0
- package/dist/discover.js +57 -0
- package/dist/discover.test.d.ts +1 -0
- package/dist/discover.test.js +79 -0
- package/dist/probe.d.ts +18 -0
- package/dist/probe.js +86 -0
- package/dist/probe.test.d.ts +1 -0
- package/dist/probe.test.js +37 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +7 -0
- package/dist/transcript.d.ts +7 -0
- package/dist/transcript.js +4 -0
- package/dist/transcript.test.d.ts +1 -0
- package/dist/transcript.test.js +121 -0
- package/package.json +32 -0
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
});
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { controlPlaneBaseUrl } from "@agentoryhq/shared";
|
|
6
|
+
import { applyPendingAligns, fulfillPendingTranscripts, heartbeat, loadState, reportAssetIndex, reportDiscovery } from "./client.js";
|
|
7
|
+
import { discoverAssetIndex } from "./asset-index.js";
|
|
8
|
+
const MIN_INTERVAL_SEC = 5;
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
heartbeatSec: 60,
|
|
11
|
+
indexSec: 600,
|
|
12
|
+
discoverSec: 3600,
|
|
13
|
+
};
|
|
14
|
+
function logLine(stream, channel, status, detail) {
|
|
15
|
+
stream(JSON.stringify({ ts: new Date().toISOString(), channel, status, ...detail }));
|
|
16
|
+
}
|
|
17
|
+
/** Env override with hard clamp to MIN_INTERVAL_SEC (AC-6). */
|
|
18
|
+
export function parseIntervalSec(envName, fallback) {
|
|
19
|
+
const raw = process.env[envName];
|
|
20
|
+
if (raw === undefined)
|
|
21
|
+
return fallback;
|
|
22
|
+
const parsed = Number(raw);
|
|
23
|
+
if (Number.isFinite(parsed) && parsed >= MIN_INTERVAL_SEC)
|
|
24
|
+
return Math.floor(parsed);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed !== MIN_INTERVAL_SEC) {
|
|
26
|
+
// 只对真被钳制的值打日志(含 0/负数/非数字;不重复日志 0 会被钳到 5)
|
|
27
|
+
logLine(console.error, "daemon", "clamped", { message: `${envName}=${raw} clamped to ${MIN_INTERVAL_SEC}sec` });
|
|
28
|
+
}
|
|
29
|
+
return MIN_INTERVAL_SEC;
|
|
30
|
+
}
|
|
31
|
+
export function resolveIntervals() {
|
|
32
|
+
return {
|
|
33
|
+
heartbeatSec: parseIntervalSec("AGENTORY_DAEMON_HEARTBEAT_SEC", DEFAULTS.heartbeatSec),
|
|
34
|
+
indexSec: parseIntervalSec("AGENTORY_DAEMON_INDEX_SEC", DEFAULTS.indexSec),
|
|
35
|
+
discoverSec: parseIntervalSec("AGENTORY_DAEMON_DISCOVER_SEC", DEFAULTS.discoverSec),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** 0–10% of the period of jitter, so a fleet of nodes doesn't fire in unison. */
|
|
39
|
+
export function jitterMs(periodSec, rand = Math.random) {
|
|
40
|
+
return Math.floor(rand() * 0.1 * periodSec * 1000);
|
|
41
|
+
}
|
|
42
|
+
export function assetIndexSha(index) {
|
|
43
|
+
return createHash("sha256").update(JSON.stringify(index)).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
function daemonStateFile() {
|
|
46
|
+
return process.env.AGENTORY_NODE_STATE
|
|
47
|
+
? path.join(path.dirname(process.env.AGENTORY_NODE_STATE), "daemon-state.json")
|
|
48
|
+
: path.join(os.homedir(), ".agentory", "daemon-state.json");
|
|
49
|
+
}
|
|
50
|
+
function loadDaemonState() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(fs.readFileSync(daemonStateFile(), "utf8"));
|
|
53
|
+
if (raw && typeof raw === "object")
|
|
54
|
+
return raw;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* fresh start */
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
function saveDaemonState(state) {
|
|
62
|
+
const file = daemonStateFile();
|
|
63
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
64
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
const daemonState = loadDaemonState();
|
|
67
|
+
/** Report the index only when its content hash changed since last report (AC-3). */
|
|
68
|
+
export async function reportAssetIndexIfChanged() {
|
|
69
|
+
const sha = assetIndexSha(discoverAssetIndex());
|
|
70
|
+
if (daemonState.lastIndexSha === sha) {
|
|
71
|
+
logLine(console.log, "asset-index", "unchanged, skip", { sha: sha.slice(0, 12) });
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const code = await reportAssetIndex();
|
|
75
|
+
if (code === 0) {
|
|
76
|
+
daemonState.lastIndexSha = sha;
|
|
77
|
+
saveDaemonState(daemonState);
|
|
78
|
+
}
|
|
79
|
+
return code;
|
|
80
|
+
}
|
|
81
|
+
let heartbeatFailures = 0;
|
|
82
|
+
async function runChannel(name, fn, onExit) {
|
|
83
|
+
try {
|
|
84
|
+
const code = await fn();
|
|
85
|
+
if (name === "heartbeat") {
|
|
86
|
+
heartbeatFailures = code === 0 ? 0 : heartbeatFailures + 1;
|
|
87
|
+
if (heartbeatFailures >= 3) {
|
|
88
|
+
logLine(console.error, "daemon", "heartbeat failing", { consecutiveFailures: heartbeatFailures });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
logLine(console.log, name, code === 0 ? "ok" : "error", code === 0 ? undefined : { exit: code });
|
|
92
|
+
if (code !== 0 && onExit)
|
|
93
|
+
onExit(code);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// 单轮失败不退进程(AC-4)——结构化错误行,继续下一个通道/下一轮。
|
|
97
|
+
logLine(console.error, name, "error", { error: err instanceof Error ? err.message : String(err), base: controlPlaneBaseUrl() });
|
|
98
|
+
if (onExit)
|
|
99
|
+
onExit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** One full round: all channels (--once 与首轮同语义,AC-1)。失败通道 → 非 0。 */
|
|
103
|
+
export async function runFullRound() {
|
|
104
|
+
let code = 0;
|
|
105
|
+
await runChannel("heartbeat", heartbeat, (c) => {
|
|
106
|
+
if (c !== 0)
|
|
107
|
+
code = c;
|
|
108
|
+
});
|
|
109
|
+
await runChannel("align", applyPendingAligns, (c) => {
|
|
110
|
+
if (c !== 0)
|
|
111
|
+
code = c;
|
|
112
|
+
});
|
|
113
|
+
await runChannel("asset-index", reportAssetIndexIfChanged, (c) => {
|
|
114
|
+
if (c !== 0)
|
|
115
|
+
code = c;
|
|
116
|
+
});
|
|
117
|
+
await runChannel("transcript", fulfillPendingTranscripts, (c) => {
|
|
118
|
+
if (c !== 0)
|
|
119
|
+
code = c;
|
|
120
|
+
});
|
|
121
|
+
await runChannel("discover", reportDiscovery, (c) => {
|
|
122
|
+
if (c !== 0)
|
|
123
|
+
code = c;
|
|
124
|
+
});
|
|
125
|
+
return code;
|
|
126
|
+
}
|
|
127
|
+
export async function runDaemon(opts = {}) {
|
|
128
|
+
const st = loadState();
|
|
129
|
+
if (!st) {
|
|
130
|
+
console.error("not registered; run: agentory-node register --token <root>");
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
const intervals = { ...resolveIntervals(), ...(opts.intervals ?? {}) };
|
|
134
|
+
logLine(console.log, "daemon", "start", {
|
|
135
|
+
nodeId: st.nodeId,
|
|
136
|
+
heartbeatSec: intervals.heartbeatSec,
|
|
137
|
+
indexSec: intervals.indexSec,
|
|
138
|
+
discoverSec: intervals.discoverSec,
|
|
139
|
+
once: !!opts.once,
|
|
140
|
+
});
|
|
141
|
+
if (opts.once) {
|
|
142
|
+
// 第一轮前先探活:控制面不可达时 --once 应失败退出非 0(AC-4),
|
|
143
|
+
// 常驻模式则靠 runChannel 的容错继续重试。
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`${controlPlaneBaseUrl()}/health`);
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
logLine(console.error, "daemon", "unreachable", { base: controlPlaneBaseUrl(), status: res.status });
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
logLine(console.error, "daemon", "unreachable", {
|
|
153
|
+
base: controlPlaneBaseUrl(),
|
|
154
|
+
error: err instanceof Error ? err.message : String(err),
|
|
155
|
+
});
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
const code = await runFullRound();
|
|
159
|
+
logLine(console.log, "daemon", "stop", { reason: "once" });
|
|
160
|
+
return code;
|
|
161
|
+
}
|
|
162
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
163
|
+
const clock = opts.clock ?? (() => Date.now());
|
|
164
|
+
const startedAt = clock();
|
|
165
|
+
let stopping = false;
|
|
166
|
+
const onSignal = (sig) => {
|
|
167
|
+
if (stopping)
|
|
168
|
+
return;
|
|
169
|
+
stopping = true;
|
|
170
|
+
logLine(console.log, "daemon", "stopping", { signal: sig });
|
|
171
|
+
};
|
|
172
|
+
if (opts.registerSignal) {
|
|
173
|
+
opts.registerSignal(onSignal);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
process.on("SIGINT", onSignal);
|
|
177
|
+
process.on("SIGTERM", onSignal);
|
|
178
|
+
}
|
|
179
|
+
// Anti-flap jitter(防整点齐射);进程收到信号时 jumping out。
|
|
180
|
+
try {
|
|
181
|
+
const j = jitterMs(intervals.heartbeatSec);
|
|
182
|
+
if (j > 0) {
|
|
183
|
+
logLine(console.log, "daemon", "jitter", { ms: j });
|
|
184
|
+
await sleep(j);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* sleep aborted(少见;继续) */
|
|
189
|
+
}
|
|
190
|
+
const nextDue = {
|
|
191
|
+
heartbeat: startedAt + intervals.heartbeatSec * 1000,
|
|
192
|
+
index: startedAt + intervals.indexSec * 1000,
|
|
193
|
+
discover: startedAt + intervals.discoverSec * 1000,
|
|
194
|
+
};
|
|
195
|
+
while (!stopping) {
|
|
196
|
+
const now = clock();
|
|
197
|
+
if (now >= nextDue.heartbeat) {
|
|
198
|
+
await runChannel("heartbeat", heartbeat);
|
|
199
|
+
await runChannel("align", applyPendingAligns);
|
|
200
|
+
await runChannel("transcript", fulfillPendingTranscripts);
|
|
201
|
+
nextDue.heartbeat += intervals.heartbeatSec * 1000;
|
|
202
|
+
}
|
|
203
|
+
if (now >= nextDue.index) {
|
|
204
|
+
await runChannel("asset-index", reportAssetIndexIfChanged);
|
|
205
|
+
nextDue.index += intervals.indexSec * 1000;
|
|
206
|
+
}
|
|
207
|
+
if (now >= nextDue.discover) {
|
|
208
|
+
await runChannel("discover", reportDiscovery);
|
|
209
|
+
nextDue.discover += intervals.discoverSec * 1000;
|
|
210
|
+
}
|
|
211
|
+
// 睡到最近一个到期点(信号会被 stopping 吸收,醒来即退)。
|
|
212
|
+
const wakeAt = Math.min(nextDue.heartbeat, nextDue.index, nextDue.discover);
|
|
213
|
+
const ms = Math.max(0, wakeAt - clock());
|
|
214
|
+
try {
|
|
215
|
+
await ms > 0 ? sleep(ms) : sleep(0);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* sleep aborted */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
logLine(console.log, "daemon", "stop", { reason: "signal" });
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
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 { test } from "node:test";
|
|
6
|
+
import { assetIndexSha, jitterMs, parseIntervalSec, resolveIntervals, runDaemon, } from "./daemon.js";
|
|
7
|
+
function tmpStateDir() {
|
|
8
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "agentory-daemon-test-"));
|
|
9
|
+
}
|
|
10
|
+
function writeState(dir) {
|
|
11
|
+
const file = path.join(dir, "node.json");
|
|
12
|
+
fs.writeFileSync(file, JSON.stringify({ nodeId: "nd_test", nodeToken: "tok_test", controlPlaneBaseUrl: "http://127.0.0.1:1" }, null, 2), { mode: 0o600 });
|
|
13
|
+
return file;
|
|
14
|
+
}
|
|
15
|
+
test("AC-6: interval clamps to minimum (0 and non-numeric)", () => {
|
|
16
|
+
process.env.AGENTORY_DAEMON_HEARTBEAT_SEC = "0";
|
|
17
|
+
assert.equal(parseIntervalSec("AGENTORY_DAEMON_HEARTBEAT_SEC", 60), 5);
|
|
18
|
+
delete process.env.AGENTORY_DAEMON_HEARTBEAT_SEC;
|
|
19
|
+
process.env.AGENTORY_DAEMON_INDEX_SEC = "abc";
|
|
20
|
+
assert.equal(parseIntervalSec("AGENTORY_DAEMON_INDEX_SEC", 600), 5);
|
|
21
|
+
delete process.env.AGENTORY_DAEMON_INDEX_SEC;
|
|
22
|
+
// negative also clamps
|
|
23
|
+
process.env.AGENTORY_DAEMON_DISCOVER_SEC = "-3";
|
|
24
|
+
assert.equal(parseIntervalSec("AGENTORY_DAEMON_DISCOVER_SEC", 3600), 5);
|
|
25
|
+
delete process.env.AGENTORY_DAEMON_DISCOVER_SEC;
|
|
26
|
+
// valid values pass through
|
|
27
|
+
assert.equal(parseIntervalSec("AGENTORY_DAEMON_HEARTBEAT_SEC", 60), 60);
|
|
28
|
+
});
|
|
29
|
+
test("AC-6: resolveIntervals reports clamped values", () => {
|
|
30
|
+
const savedHb = process.env.AGENTORY_DAEMON_HEARTBEAT_SEC;
|
|
31
|
+
process.env.AGENTORY_DAEMON_HEARTBEAT_SEC = "0";
|
|
32
|
+
const iv = resolveIntervals();
|
|
33
|
+
assert.equal(iv.heartbeatSec, 5);
|
|
34
|
+
assert.equal(iv.indexSec, 600);
|
|
35
|
+
assert.equal(iv.discoverSec, 3600);
|
|
36
|
+
if (savedHb === undefined)
|
|
37
|
+
delete process.env.AGENTORY_DAEMON_HEARTBEAT_SEC;
|
|
38
|
+
else
|
|
39
|
+
process.env.AGENTORY_DAEMON_HEARTBEAT_SEC = savedHb;
|
|
40
|
+
});
|
|
41
|
+
test("jitter stays within 10% of period (deterministic bounds)", () => {
|
|
42
|
+
for (let i = 0; i < 50; i++) {
|
|
43
|
+
const j = jitterMs(100, () => 0.999);
|
|
44
|
+
assert.ok(j >= 0 && j < 10 * 1000, `jitter out of bounds: ${j}`);
|
|
45
|
+
}
|
|
46
|
+
assert.equal(jitterMs(100, () => 0), 0);
|
|
47
|
+
});
|
|
48
|
+
test("assetIndexSha changes when index changes", () => {
|
|
49
|
+
const a = assetIndexSha({ sessions: [{ id: 1 }], memories: [] });
|
|
50
|
+
const b = assetIndexSha({ sessions: [{ id: 2 }], memories: [] });
|
|
51
|
+
assert.notEqual(a, b);
|
|
52
|
+
assert.equal(a, assetIndexSha({ sessions: [{ id: 1 }], memories: [] }));
|
|
53
|
+
});
|
|
54
|
+
test("AC-4 (part): daemon --once exits non-zero when control plane unreachable, base URL in stderr/log", async () => {
|
|
55
|
+
const dir = tmpStateDir();
|
|
56
|
+
const prev = process.env.AGENTORY_NODE_STATE;
|
|
57
|
+
const prevBase = process.env.CONTROL_PLANE_BASE_URL;
|
|
58
|
+
process.env.AGENTORY_NODE_STATE = writeState(dir);
|
|
59
|
+
process.env.CONTROL_PLANE_BASE_URL = "http://127.0.0.1:1"; // 不可达端口
|
|
60
|
+
try {
|
|
61
|
+
const code = await runDaemon({ once: true });
|
|
62
|
+
assert.notEqual(code, 0);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (prev === undefined)
|
|
66
|
+
delete process.env.AGENTORY_NODE_STATE;
|
|
67
|
+
else
|
|
68
|
+
process.env.AGENTORY_NODE_STATE = prev;
|
|
69
|
+
if (prevBase === undefined)
|
|
70
|
+
delete process.env.CONTROL_PLANE_BASE_URL;
|
|
71
|
+
else
|
|
72
|
+
process.env.CONTROL_PLANE_BASE_URL = prevBase;
|
|
73
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
test("daemon without registration exits 2 with usage hint", async () => {
|
|
77
|
+
const dir = tmpStateDir();
|
|
78
|
+
const prev = process.env.AGENTORY_NODE_STATE;
|
|
79
|
+
process.env.AGENTORY_NODE_STATE = path.join(dir, "missing.json");
|
|
80
|
+
try {
|
|
81
|
+
const code = await runDaemon({ once: true });
|
|
82
|
+
assert.equal(code, 2);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (prev === undefined)
|
|
86
|
+
delete process.env.AGENTORY_NODE_STATE;
|
|
87
|
+
else
|
|
88
|
+
process.env.AGENTORY_NODE_STATE = prev;
|
|
89
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
test("AC-5: SIGNAL mid-run → graceful stop, exit 0", async () => {
|
|
93
|
+
const dir = tmpStateDir();
|
|
94
|
+
const prev = process.env.AGENTORY_NODE_STATE;
|
|
95
|
+
process.env.AGENTORY_NODE_STATE = writeState(dir);
|
|
96
|
+
let fireSignal;
|
|
97
|
+
let sawStoppingLine = false;
|
|
98
|
+
const originalLog = console.log;
|
|
99
|
+
console.log = (...args) => {
|
|
100
|
+
const s = args.map(String).join(" ");
|
|
101
|
+
if (s.includes('"stopping"'))
|
|
102
|
+
sawStoppingLine = true;
|
|
103
|
+
originalLog(...args);
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
let elapsedMs = 0;
|
|
107
|
+
const code = await runDaemon({
|
|
108
|
+
intervals: { heartbeatSec: 5, indexSec: 600, discoverSec: 3600 },
|
|
109
|
+
clock: () => 1_000 + elapsedMs,
|
|
110
|
+
sleep: (ms) => {
|
|
111
|
+
elapsedMs += ms;
|
|
112
|
+
if (elapsedMs >= 5_000 && fireSignal) {
|
|
113
|
+
fireSignal("SIGINT");
|
|
114
|
+
}
|
|
115
|
+
return new Promise((r) => setTimeout(r, 0));
|
|
116
|
+
},
|
|
117
|
+
registerSignal: (onSignal) => {
|
|
118
|
+
fireSignal = onSignal;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
assert.equal(code, 0);
|
|
122
|
+
assert.ok(sawStoppingLine, "expected a stopping log line on signal");
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
console.log = originalLog;
|
|
126
|
+
if (prev === undefined)
|
|
127
|
+
delete process.env.AGENTORY_NODE_STATE;
|
|
128
|
+
else
|
|
129
|
+
process.env.AGENTORY_NODE_STATE = prev;
|
|
130
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DiscoveredAgent } from "@agentoryhq/shared";
|
|
2
|
+
export type DiscoverOpts = {
|
|
3
|
+
/** Override home for tests. */
|
|
4
|
+
homeDir?: string;
|
|
5
|
+
/** Optional JSON fixture path (AGENTORY_DISCOVERY_FIXTURE). */
|
|
6
|
+
fixturePath?: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
/** Skip probe (unit tests of filesystem parse only). */
|
|
9
|
+
skipProbe?: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** Sync filesystem/fixture discovery (no probe). */
|
|
12
|
+
export declare function discoverAgents(opts?: DiscoverOpts): DiscoveredAgent[];
|
|
13
|
+
/** Discovery + binding-level health probe (AC-2). */
|
|
14
|
+
export declare function discoverAgentsWithHealth(opts?: DiscoverOpts): Promise<DiscoveredAgent[]>;
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { probeAll } from "./probe.js";
|
|
4
|
+
import { builtinAdapters } from "./registry.js";
|
|
5
|
+
function readJsonFile(file) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function adapterContext(opts) {
|
|
14
|
+
return {
|
|
15
|
+
homeDir: opts.homeDir ?? os.homedir(),
|
|
16
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function discoverAgentsSync(opts = {}) {
|
|
20
|
+
const fixture = opts.fixturePath ?? process.env.AGENTORY_DISCOVERY_FIXTURE;
|
|
21
|
+
if (fixture) {
|
|
22
|
+
const raw = readJsonFile(fixture);
|
|
23
|
+
if (Array.isArray(raw))
|
|
24
|
+
return raw;
|
|
25
|
+
if (raw && typeof raw === "object" && Array.isArray(raw.agents)) {
|
|
26
|
+
return raw.agents;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const ctx = adapterContext(opts);
|
|
30
|
+
const agents = [];
|
|
31
|
+
for (const adapter of builtinAdapters) {
|
|
32
|
+
if (!adapter.detect(ctx))
|
|
33
|
+
continue;
|
|
34
|
+
if (adapter.discoverConfig) {
|
|
35
|
+
const agent = adapter.discoverConfig(ctx);
|
|
36
|
+
if (agent)
|
|
37
|
+
agents.push(agent);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return agents;
|
|
41
|
+
}
|
|
42
|
+
/** Sync filesystem/fixture discovery (no probe). */
|
|
43
|
+
export function discoverAgents(opts = {}) {
|
|
44
|
+
return discoverAgentsSync(opts);
|
|
45
|
+
}
|
|
46
|
+
/** Discovery + binding-level health probe (AC-2). */
|
|
47
|
+
export async function discoverAgentsWithHealth(opts = {}) {
|
|
48
|
+
const agents = discoverAgentsSync(opts);
|
|
49
|
+
if (opts.skipProbe)
|
|
50
|
+
return agents;
|
|
51
|
+
return Promise.all(agents.map(async (a) => {
|
|
52
|
+
if (!a.mcp.length)
|
|
53
|
+
return a;
|
|
54
|
+
const mcp = await probeAll(a.mcp.map((m) => ({ name: m.name, command: m.command })));
|
|
55
|
+
return { ...a, mcp };
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { test } from "node:test";
|
|
6
|
+
import { discoverAgents, discoverAgentsWithHealth } from "./discover.js";
|
|
7
|
+
test("discover cursor mcp and empty skills; claude supported", () => {
|
|
8
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-disc-"));
|
|
9
|
+
const home = path.join(root, "home");
|
|
10
|
+
const cwd = path.join(root, "proj");
|
|
11
|
+
fs.mkdirSync(path.join(cwd, ".cursor"), { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(cwd, ".cursor", "mcp.json"), JSON.stringify({
|
|
13
|
+
mcpServers: {
|
|
14
|
+
github: { command: "npx", args: ["-y", "server"] },
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
|
|
18
|
+
const agents = discoverAgents({ homeDir: home, cwd });
|
|
19
|
+
const cursor = agents.find((a) => a.runtime === "cursor");
|
|
20
|
+
const claude = agents.find((a) => a.runtime === "claude-code");
|
|
21
|
+
assert.ok(cursor);
|
|
22
|
+
assert.equal(cursor.supported, true);
|
|
23
|
+
assert.equal(cursor.mcp.length, 1);
|
|
24
|
+
assert.equal(cursor.mcp[0]?.name, "github");
|
|
25
|
+
assert.equal(cursor.mcp[0]?.status, "unknown");
|
|
26
|
+
assert.deepEqual(cursor.skills, []);
|
|
27
|
+
assert.ok(claude);
|
|
28
|
+
assert.equal(claude.supported, true);
|
|
29
|
+
});
|
|
30
|
+
test("discoverAgentsWithHealth probes bindings", async () => {
|
|
31
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-disc-h-"));
|
|
32
|
+
const home = path.join(root, "home");
|
|
33
|
+
const cwd = path.join(root, "proj");
|
|
34
|
+
fs.mkdirSync(path.join(cwd, ".cursor"), { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(cwd, ".cursor", "mcp.json"), JSON.stringify({
|
|
36
|
+
mcpServers: {
|
|
37
|
+
broken: { command: "npx", args: ["invalid", "bad-url"] },
|
|
38
|
+
ok: { command: "npx", args: ["-y", "server"] },
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
const agents = await discoverAgentsWithHealth({ homeDir: home, cwd });
|
|
42
|
+
const cursor = agents.find((a) => a.runtime === "cursor");
|
|
43
|
+
assert.ok(cursor);
|
|
44
|
+
const broken = cursor.mcp.find((m) => m.name === "broken");
|
|
45
|
+
const ok = cursor.mcp.find((m) => m.name === "ok");
|
|
46
|
+
assert.equal(broken?.status, "failed");
|
|
47
|
+
assert.ok(broken?.reason);
|
|
48
|
+
assert.equal(ok?.status, "healthy");
|
|
49
|
+
});
|
|
50
|
+
test("discover includes ~/.cursor/skills-cursor names", () => {
|
|
51
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-skills-"));
|
|
52
|
+
const home = path.join(root, "home");
|
|
53
|
+
const cwd = path.join(root, "proj");
|
|
54
|
+
fs.mkdirSync(path.join(cwd, ".cursor"), { recursive: true });
|
|
55
|
+
fs.mkdirSync(path.join(home, ".cursor", "skills-cursor", "native-skill"), { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(home, ".cursor", "skills-cursor", "native-skill", "SKILL.md"), "# native\n");
|
|
57
|
+
const agents = discoverAgents({ homeDir: home, cwd });
|
|
58
|
+
const cursor = agents.find((a) => a.runtime === "cursor");
|
|
59
|
+
assert.ok(cursor);
|
|
60
|
+
assert.ok(cursor.skills.includes("native-skill"));
|
|
61
|
+
});
|
|
62
|
+
test("fixture overrides filesystem", () => {
|
|
63
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-fix-"));
|
|
64
|
+
const fixture = path.join(root, "fix.json");
|
|
65
|
+
fs.writeFileSync(fixture, JSON.stringify({
|
|
66
|
+
agents: [
|
|
67
|
+
{
|
|
68
|
+
runtime: "cursor",
|
|
69
|
+
supported: true,
|
|
70
|
+
displayName: "Cursor · fixture",
|
|
71
|
+
mcp: [],
|
|
72
|
+
skills: [],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}));
|
|
76
|
+
const agents = discoverAgents({ fixturePath: fixture, homeDir: root, cwd: root });
|
|
77
|
+
assert.equal(agents.length, 1);
|
|
78
|
+
assert.equal(agents[0]?.mcp.length, 0);
|
|
79
|
+
});
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LiveMcpBinding, McpHealthStatus } from "@agentoryhq/shared";
|
|
2
|
+
export type ProbeInput = {
|
|
3
|
+
name: string;
|
|
4
|
+
command?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Binding-level health probe (slice 3 / AC-2).
|
|
8
|
+
* Simulation hooks in command string for tests: invalid / ENOENT / timeout-sim / degraded.
|
|
9
|
+
* Optional real HTTP(S) fetch when command looks like a URL and AGENTORY_MCP_HTTP_PROBE=1.
|
|
10
|
+
*/
|
|
11
|
+
export declare function probeMcpBinding(input: ProbeInput, opts?: {
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
fetchImpl?: typeof fetch;
|
|
14
|
+
}): Promise<LiveMcpBinding>;
|
|
15
|
+
export declare function probeAll(bindings: ProbeInput[], opts?: {
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}): Promise<LiveMcpBinding[]>;
|
|
18
|
+
export declare function worstStatus(statuses: McpHealthStatus[]): McpHealthStatus;
|