totemlayer 0.1.4 → 0.1.6
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 +24 -3
- package/bin/agent-profile.mjs +466 -0
- package/bin/agent-runner.mjs +609 -0
- package/bin/agent-setup.mjs +659 -0
- package/bin/collaboration-listener.mjs +83 -83
- package/bin/docker-test-sandbox.mjs +61 -0
- package/bin/local-model-provider.mjs +29 -0
- package/bin/mcp-install.mjs +240 -1
- package/bin/mcp.mjs +1 -1
- package/bin/mission-agent-service.mjs +441 -0
- package/bin/mission-agent-session.mjs +188 -0
- package/bin/mission-supervisor.mjs +166 -0
- package/bin/mission-worker.mjs +3537 -0
- package/bin/network-runtime.mjs +86 -27
- package/bin/network.mjs +96 -5
- package/bin/platform-client.mjs +1 -0
- package/bin/sdk/action-types.js +1 -0
- package/bin/sdk/actions.js +84 -0
- package/bin/sdk/index.js +75 -0
- package/bin/sdk/request-options.js +36 -0
- package/bin/sdk/transport.js +8 -3
- package/bin/totem.mjs +23 -2
- package/package.json +1 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
acquireMissionAgentSession,
|
|
9
|
+
ensurePrivateRuntimeDirectory,
|
|
10
|
+
missionAgentOrigin,
|
|
11
|
+
readPrivateRuntimeJson,
|
|
12
|
+
} from "./mission-agent-session.mjs";
|
|
13
|
+
import {
|
|
14
|
+
missionModelEndpoint,
|
|
15
|
+
preflightMissionRepository,
|
|
16
|
+
runMissionWorkerCycle,
|
|
17
|
+
} from "./mission-worker.mjs";
|
|
18
|
+
import { repositoryIdFromRemote } from "./repository.mjs";
|
|
19
|
+
|
|
20
|
+
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
|
21
|
+
const STAGES = new Set(["proposal", "engineering", "decision", "code", "review", "handoff"]);
|
|
22
|
+
const REPOSITORY_STAGES = new Set(["engineering", "code", "review"]);
|
|
23
|
+
const TERMINAL = new Set(["completed", "canceled", "failed"]);
|
|
24
|
+
const RECOVERABLE = new Set([
|
|
25
|
+
"ready",
|
|
26
|
+
"result_settling",
|
|
27
|
+
"local_test_failure_settling",
|
|
28
|
+
"claiming",
|
|
29
|
+
]);
|
|
30
|
+
const CHECKPOINT_BYTES = 2 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
function transient(error) {
|
|
33
|
+
return (
|
|
34
|
+
["NETWORK_ERROR", "TIMEOUT", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT"].includes(error?.code) ||
|
|
35
|
+
[408, 425, 429, 500, 502, 503, 504].includes(error?.status) ||
|
|
36
|
+
(error instanceof TypeError && /fetch|network/i.test(error.message))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
function cursor(value) {
|
|
40
|
+
return (
|
|
41
|
+
value === null || (typeof value === "string" && value.length > 0 && value.length <= 16_384)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
function assertPage(page) {
|
|
45
|
+
if (!page || !Array.isArray(page.data) || !cursor(page.nextCursor))
|
|
46
|
+
throw new Error("The Missions API returned an invalid discovery page.");
|
|
47
|
+
return page;
|
|
48
|
+
}
|
|
49
|
+
async function saveCheckpoint(path, state) {
|
|
50
|
+
const value = JSON.stringify(state);
|
|
51
|
+
if (Buffer.byteLength(value) > CHECKPOINT_BYTES)
|
|
52
|
+
throw new Error("The saved discovery checkpoint is full; inspect it before continuing.");
|
|
53
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
54
|
+
try {
|
|
55
|
+
await writeFile(temporary, value, { mode: 0o600, flag: "wx" });
|
|
56
|
+
await rename(temporary, path);
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(temporary, { force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function loadCheckpoint(path, origin, agentId) {
|
|
62
|
+
const state = await readPrivateRuntimeJson(path, CHECKPOINT_BYTES).catch((error) => {
|
|
63
|
+
if (error.code === "ENOENT") return null;
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
if (!state)
|
|
67
|
+
return {
|
|
68
|
+
version: 1,
|
|
69
|
+
origin,
|
|
70
|
+
agentId,
|
|
71
|
+
missionCursor: null,
|
|
72
|
+
pendingMissions: [],
|
|
73
|
+
workCursors: {},
|
|
74
|
+
recoveryAfter: null,
|
|
75
|
+
preferRecovery: true,
|
|
76
|
+
};
|
|
77
|
+
if (
|
|
78
|
+
state.version !== 1 ||
|
|
79
|
+
state.origin !== origin ||
|
|
80
|
+
state.agentId !== agentId ||
|
|
81
|
+
!cursor(state.missionCursor) ||
|
|
82
|
+
!Array.isArray(state.pendingMissions) ||
|
|
83
|
+
!state.pendingMissions.every((id) => UUID.test(id)) ||
|
|
84
|
+
!state.workCursors ||
|
|
85
|
+
typeof state.workCursors !== "object" ||
|
|
86
|
+
Array.isArray(state.workCursors) ||
|
|
87
|
+
!Object.entries(state.workCursors).every(([id, value]) => UUID.test(id) && cursor(value)) ||
|
|
88
|
+
!(state.recoveryAfter === null || UUID.test(state.recoveryAfter)) ||
|
|
89
|
+
typeof state.preferRecovery !== "boolean"
|
|
90
|
+
)
|
|
91
|
+
throw new Error("The saved Missions discovery checkpoint needs inspection.");
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
async function recoveryMissions(directory) {
|
|
95
|
+
const found = [];
|
|
96
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
97
|
+
if (!entry.isDirectory() || !UUID.test(entry.name)) continue;
|
|
98
|
+
const missionDirectory = join(directory, entry.name);
|
|
99
|
+
await ensurePrivateRuntimeDirectory(missionDirectory);
|
|
100
|
+
for (const file of await readdir(missionDirectory, { withFileTypes: true })) {
|
|
101
|
+
if (!file.isFile() || !file.name.endsWith(".json") || !UUID.test(file.name.slice(0, -5)))
|
|
102
|
+
continue;
|
|
103
|
+
const record = await readPrivateRuntimeJson(
|
|
104
|
+
join(missionDirectory, file.name),
|
|
105
|
+
CHECKPOINT_BYTES,
|
|
106
|
+
);
|
|
107
|
+
if (
|
|
108
|
+
RECOVERABLE.has(record.state) ||
|
|
109
|
+
(record.state === "completion_blocked" && !record.blockerRecorded)
|
|
110
|
+
) {
|
|
111
|
+
found.push(entry.name);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return found.sort();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One authenticated local agent, serial work, durable discovery, explicit repositories. */
|
|
120
|
+
export async function runMissionAgentService(
|
|
121
|
+
{
|
|
122
|
+
client,
|
|
123
|
+
expectedAgentId,
|
|
124
|
+
baseUrl,
|
|
125
|
+
repositoryBindings = [],
|
|
126
|
+
provider = "qwen-local",
|
|
127
|
+
model,
|
|
128
|
+
qwenUrl,
|
|
129
|
+
nimUrl,
|
|
130
|
+
ollamaUrl,
|
|
131
|
+
reasoning = false,
|
|
132
|
+
stateDirectory = join(homedir(), ".totem", "missions"),
|
|
133
|
+
pollMs = 5000,
|
|
134
|
+
signal,
|
|
135
|
+
emit = () => {},
|
|
136
|
+
},
|
|
137
|
+
dependencies = {},
|
|
138
|
+
) {
|
|
139
|
+
const origin = missionAgentOrigin(baseUrl);
|
|
140
|
+
missionModelEndpoint(provider, { qwenUrl, ollamaUrl, nimUrl });
|
|
141
|
+
if (!Number.isInteger(pollMs) || pollMs < 10 || pollMs > 60_000)
|
|
142
|
+
throw new Error("Choose a Missions polling interval between 10 and 60000 milliseconds.");
|
|
143
|
+
if (!Array.isArray(repositoryBindings))
|
|
144
|
+
throw new Error("Provide explicit local repository bindings.");
|
|
145
|
+
const bindings = new Map();
|
|
146
|
+
for (const binding of repositoryBindings) {
|
|
147
|
+
if (
|
|
148
|
+
!binding ||
|
|
149
|
+
typeof binding.repositoryUrl !== "string" ||
|
|
150
|
+
repositoryIdFromRemote(`https://${binding.repositoryUrl}`) !== binding.repositoryUrl ||
|
|
151
|
+
typeof binding.root !== "string" ||
|
|
152
|
+
!isAbsolute(binding.root)
|
|
153
|
+
)
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Repository bindings require a canonical repository ID and an absolute local root.",
|
|
156
|
+
);
|
|
157
|
+
if (bindings.has(binding.repositoryUrl))
|
|
158
|
+
throw new Error("Configure each repository binding only once.");
|
|
159
|
+
bindings.set(binding.repositoryUrl, resolve(binding.root));
|
|
160
|
+
}
|
|
161
|
+
const authenticate = async () => {
|
|
162
|
+
const agent = await client.agent({ signal });
|
|
163
|
+
if (
|
|
164
|
+
typeof agent?.id !== "string" ||
|
|
165
|
+
!/^[a-zA-Z0-9-]{1,200}$/.test(agent.id) ||
|
|
166
|
+
(expectedAgentId && agent.id !== expectedAgentId)
|
|
167
|
+
)
|
|
168
|
+
throw Object.assign(new Error("The credential no longer identifies the configured agent."), {
|
|
169
|
+
code: "AGENT_IDENTITY_MISMATCH",
|
|
170
|
+
});
|
|
171
|
+
return agent;
|
|
172
|
+
};
|
|
173
|
+
let agent;
|
|
174
|
+
let failures = 0;
|
|
175
|
+
let session;
|
|
176
|
+
try {
|
|
177
|
+
while (!agent && !signal?.aborted) {
|
|
178
|
+
try {
|
|
179
|
+
agent = await authenticate();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (signal?.aborted || !transient(error)) throw error;
|
|
182
|
+
emit({ type: "waiting", code: "service_unavailable" });
|
|
183
|
+
await sleep(
|
|
184
|
+
Math.min(30_000, Math.max(1000, pollMs) * 2 ** Math.min(failures++, 5)),
|
|
185
|
+
undefined,
|
|
186
|
+
{ signal },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!agent || signal?.aborted) {
|
|
191
|
+
emit({ type: "stopped" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
session = await acquireMissionAgentSession(
|
|
195
|
+
{ baseUrl: origin, agentId: agent.id, signal },
|
|
196
|
+
dependencies.session,
|
|
197
|
+
);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!signal?.aborted) throw error;
|
|
200
|
+
emit({ type: "stopped" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let lastEvent;
|
|
204
|
+
const report = (event) => {
|
|
205
|
+
const encoded = JSON.stringify(event);
|
|
206
|
+
if (encoded !== lastEvent) {
|
|
207
|
+
lastEvent = encoded;
|
|
208
|
+
emit(event);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const workerStateDirectory = join(resolve(stateDirectory), "mission-agents", session.key);
|
|
212
|
+
const directory = join(workerStateDirectory, agent.id);
|
|
213
|
+
try {
|
|
214
|
+
await ensurePrivateRuntimeDirectory(resolve(stateDirectory));
|
|
215
|
+
await ensurePrivateRuntimeDirectory(join(resolve(stateDirectory), "mission-agents"));
|
|
216
|
+
await ensurePrivateRuntimeDirectory(workerStateDirectory);
|
|
217
|
+
await ensurePrivateRuntimeDirectory(directory);
|
|
218
|
+
const checkpointPath = join(directory, `discovery-${session.key}.json`);
|
|
219
|
+
const state = await loadCheckpoint(checkpointPath, origin, agent.id);
|
|
220
|
+
const persist = () => saveCheckpoint(checkpointPath, state);
|
|
221
|
+
const revalidate = async (error, missionId) => {
|
|
222
|
+
if (signal?.aborted) throw error;
|
|
223
|
+
if (error.status === 401 || error.code === "AGENT_IDENTITY_MISMATCH") throw error;
|
|
224
|
+
if (error.status === 403) {
|
|
225
|
+
const current = await authenticate();
|
|
226
|
+
if (current.id !== agent.id)
|
|
227
|
+
throw Object.assign(new Error("Agent identity changed."), {
|
|
228
|
+
code: "AGENT_IDENTITY_MISMATCH",
|
|
229
|
+
});
|
|
230
|
+
report({
|
|
231
|
+
type: "blocked",
|
|
232
|
+
...(missionId ? { missionId } : {}),
|
|
233
|
+
code: "permission_denied",
|
|
234
|
+
});
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if ([404, 409].includes(error.status) && missionId) {
|
|
238
|
+
report({ type: "waiting", missionId, code: "assignment_unavailable" });
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
const bindingFor = async (mission, kind, recovery = false) => {
|
|
244
|
+
if (!mission.repository && !REPOSITORY_STAGES.has(kind)) return null;
|
|
245
|
+
const root = mission.repository && bindings.get(mission.repository.url);
|
|
246
|
+
if (!root) {
|
|
247
|
+
report({ type: "waiting", missionId: mission.id, code: "repository_unbound" });
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const canonicalRoot = await realpath(root);
|
|
252
|
+
await preflightMissionRepository(mission, canonicalRoot);
|
|
253
|
+
return canonicalRoot;
|
|
254
|
+
} catch {
|
|
255
|
+
report({
|
|
256
|
+
type: "blocked",
|
|
257
|
+
missionId: mission.id,
|
|
258
|
+
code: "repository_configuration_invalid",
|
|
259
|
+
});
|
|
260
|
+
// Saved completion settlement does not read a repository or repeat an effect.
|
|
261
|
+
return recovery ? null : undefined;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const runCycle = async (missionId, repositoryRoot, workItem, recoveryOnly = false) => {
|
|
265
|
+
await (dependencies.runCycle ?? runMissionWorkerCycle)({
|
|
266
|
+
client,
|
|
267
|
+
baseUrl: origin,
|
|
268
|
+
expectedAgentId: agent.id,
|
|
269
|
+
_session: session,
|
|
270
|
+
missionId,
|
|
271
|
+
repositoryRoot,
|
|
272
|
+
stateDirectory: workerStateDirectory,
|
|
273
|
+
provider,
|
|
274
|
+
model,
|
|
275
|
+
qwenUrl,
|
|
276
|
+
nimUrl,
|
|
277
|
+
ollamaUrl,
|
|
278
|
+
reasoning,
|
|
279
|
+
signal,
|
|
280
|
+
candidateWorkItem: workItem,
|
|
281
|
+
recoveryOnly,
|
|
282
|
+
emit(event) {
|
|
283
|
+
const type =
|
|
284
|
+
event.type === "work_started"
|
|
285
|
+
? "working"
|
|
286
|
+
: ["work_completed", "work_recovered"].includes(event.type)
|
|
287
|
+
? "completed"
|
|
288
|
+
: ["needs_attention", "work_failed", "recovery_waiting"].includes(event.type)
|
|
289
|
+
? "blocked"
|
|
290
|
+
: "waiting";
|
|
291
|
+
report({
|
|
292
|
+
type,
|
|
293
|
+
missionId,
|
|
294
|
+
...(UUID.test(event.workItemId ?? "") ? { workItemId: event.workItemId } : {}),
|
|
295
|
+
code: event.type,
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
const recoverOne = async () => {
|
|
301
|
+
const ids = await recoveryMissions(directory);
|
|
302
|
+
if (!ids.length) return false;
|
|
303
|
+
const missionId =
|
|
304
|
+
ids.find((id) => !state.recoveryAfter || id > state.recoveryAfter) ?? ids[0];
|
|
305
|
+
state.recoveryAfter = missionId;
|
|
306
|
+
await persist();
|
|
307
|
+
try {
|
|
308
|
+
const mission = await client.missions.get(missionId, { signal });
|
|
309
|
+
const root = await bindingFor(mission, null, true);
|
|
310
|
+
// Missing bindings prevent claim replay; saved completions still settle safely.
|
|
311
|
+
await runCycle(missionId, root ?? null, undefined, true);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (!(await revalidate(error, missionId))) throw error;
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
};
|
|
317
|
+
const discoverOne = async () => {
|
|
318
|
+
let loadedPage = false;
|
|
319
|
+
for (let inspected = 0; inspected < 25; inspected += 1) {
|
|
320
|
+
if (!state.pendingMissions.length) {
|
|
321
|
+
if (loadedPage && state.missionCursor === null) return false;
|
|
322
|
+
let page;
|
|
323
|
+
try {
|
|
324
|
+
page = assertPage(
|
|
325
|
+
await client.missions.listPage(
|
|
326
|
+
{ cursor: state.missionCursor ?? undefined, limit: 25 },
|
|
327
|
+
{ signal },
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error.status === 422 && error.code === "INVALID_CURSOR") {
|
|
332
|
+
state.missionCursor = null;
|
|
333
|
+
await persist();
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
loadedPage = true;
|
|
339
|
+
if (page.data.some((mission) => !UUID.test(mission?.id)))
|
|
340
|
+
throw new Error("The Missions API returned an invalid mission identity.");
|
|
341
|
+
state.pendingMissions.push(...page.data.map((mission) => mission.id));
|
|
342
|
+
state.missionCursor = page.nextCursor;
|
|
343
|
+
await persist();
|
|
344
|
+
if (!state.pendingMissions.length) continue;
|
|
345
|
+
}
|
|
346
|
+
const missionId = state.pendingMissions.shift();
|
|
347
|
+
await persist();
|
|
348
|
+
let enteredCycle = false;
|
|
349
|
+
try {
|
|
350
|
+
const mission = await client.missions.get(missionId, { signal });
|
|
351
|
+
if (mission.id !== missionId)
|
|
352
|
+
throw new Error("The Missions API returned the wrong mission.");
|
|
353
|
+
if (mission.status !== "active") {
|
|
354
|
+
if (TERMINAL.has(mission.status)) delete state.workCursors[missionId];
|
|
355
|
+
report({ type: "waiting", missionId, code: "mission_inactive" });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const page = assertPage(
|
|
359
|
+
await client.missions.workItemsPage(
|
|
360
|
+
missionId,
|
|
361
|
+
{ cursor: state.workCursors[missionId] ?? undefined, limit: 1 },
|
|
362
|
+
{ signal },
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
state.workCursors[missionId] = page.nextCursor;
|
|
366
|
+
await persist();
|
|
367
|
+
if (!page.data.length) continue;
|
|
368
|
+
if (page.data.length !== 1)
|
|
369
|
+
throw new Error("The Missions API exceeded the one-item discovery limit.");
|
|
370
|
+
const workItem = page.data[0];
|
|
371
|
+
if (!UUID.test(workItem?.id) || workItem.missionId !== missionId)
|
|
372
|
+
throw new Error("The Missions API returned an invalid work identity.");
|
|
373
|
+
if (!STAGES.has(workItem.metadata?.kind)) {
|
|
374
|
+
report({
|
|
375
|
+
type: "waiting",
|
|
376
|
+
missionId,
|
|
377
|
+
workItemId: workItem.id,
|
|
378
|
+
code: "unsupported_work",
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (workItem.assignedAgentId && workItem.assignedAgentId !== agent.id) continue;
|
|
383
|
+
const root = await bindingFor(mission, workItem.metadata.kind);
|
|
384
|
+
if (root === undefined) continue;
|
|
385
|
+
enteredCycle = true;
|
|
386
|
+
await runCycle(missionId, root, workItem);
|
|
387
|
+
return true;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error.status === 422 && error.code === "INVALID_CURSOR") {
|
|
390
|
+
delete state.workCursors[missionId];
|
|
391
|
+
await persist();
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (!(await revalidate(error, missionId))) throw error;
|
|
395
|
+
if (enteredCycle) return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
};
|
|
400
|
+
report({ type: "connected", agentId: agent.id });
|
|
401
|
+
failures = 0;
|
|
402
|
+
while (!signal?.aborted) {
|
|
403
|
+
try {
|
|
404
|
+
await client.runtime.heartbeat({ label: "missions:local-agent" }, { signal });
|
|
405
|
+
if (signal?.aborted) break;
|
|
406
|
+
let handled = false;
|
|
407
|
+
const recoveryFirst = state.preferRecovery;
|
|
408
|
+
state.preferRecovery = !state.preferRecovery;
|
|
409
|
+
await persist();
|
|
410
|
+
if (recoveryFirst) handled = await recoverOne();
|
|
411
|
+
if (!handled) handled = await discoverOne();
|
|
412
|
+
if (!handled && !recoveryFirst) handled = await recoverOne();
|
|
413
|
+
failures = 0;
|
|
414
|
+
if (!handled) report({ type: "waiting", code: "no_runnable_work" });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (signal?.aborted) break;
|
|
417
|
+
if (await revalidate(error)) {
|
|
418
|
+
/* Verified identity; a scoped permission can change later. */
|
|
419
|
+
} else if (!transient(error)) throw error;
|
|
420
|
+
else {
|
|
421
|
+
failures += 1;
|
|
422
|
+
report({ type: "waiting", code: "service_unavailable" });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
await sleep(
|
|
427
|
+
failures
|
|
428
|
+
? Math.min(30_000, Math.max(1000, pollMs) * 2 ** Math.min(failures - 1, 5))
|
|
429
|
+
: pollMs,
|
|
430
|
+
undefined,
|
|
431
|
+
{ signal },
|
|
432
|
+
);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (!signal?.aborted) throw error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} finally {
|
|
438
|
+
await session.release();
|
|
439
|
+
report({ type: "stopped" });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, rm } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
|
|
8
|
+
const sessions = new WeakSet();
|
|
9
|
+
|
|
10
|
+
export function missionAgentOrigin(baseUrl = "https://totemlayer.com") {
|
|
11
|
+
const url = new URL(baseUrl);
|
|
12
|
+
if (
|
|
13
|
+
url.username ||
|
|
14
|
+
url.password ||
|
|
15
|
+
url.search ||
|
|
16
|
+
url.hash ||
|
|
17
|
+
!["https:", "http:"].includes(url.protocol) ||
|
|
18
|
+
(url.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
|
|
19
|
+
)
|
|
20
|
+
throw new Error("The agent service requires a credential-free HTTPS API URL or loopback HTTP.");
|
|
21
|
+
return url.origin;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function missionAgentSessionKey(baseUrl, agentId) {
|
|
25
|
+
if (typeof agentId !== "string" || !agentId || agentId.length > 200)
|
|
26
|
+
throw new Error("The agent session needs a verified agent identity.");
|
|
27
|
+
return createHash("sha256")
|
|
28
|
+
.update(JSON.stringify([missionAgentOrigin(baseUrl), agentId]))
|
|
29
|
+
.digest("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function alive(pid) {
|
|
33
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return true;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === "ESRCH") return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ownedPrivate(info, directory = false) {
|
|
44
|
+
return (
|
|
45
|
+
(directory ? info.isDirectory() : info.isFile()) &&
|
|
46
|
+
!info.isSymbolicLink() &&
|
|
47
|
+
(process.platform === "win32" || ((info.mode & 0o077) === 0 && info.uid === process.getuid()))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensurePrivateRuntimeDirectory(path) {
|
|
52
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
53
|
+
const info = await lstat(path);
|
|
54
|
+
if (!ownedPrivate(info, true))
|
|
55
|
+
throw new Error(
|
|
56
|
+
"The agent runtime directory must be private, owned by this user, and not a symbolic link.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readPrivateRuntimeJson(path, maxBytes = 4096) {
|
|
61
|
+
const before = await lstat(path);
|
|
62
|
+
if (!ownedPrivate(before) || before.size > maxBytes)
|
|
63
|
+
throw new Error("The saved agent runtime record needs inspection.");
|
|
64
|
+
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
65
|
+
try {
|
|
66
|
+
const info = await file.stat();
|
|
67
|
+
if (
|
|
68
|
+
!ownedPrivate(info) ||
|
|
69
|
+
info.size > maxBytes ||
|
|
70
|
+
info.ino !== before.ino ||
|
|
71
|
+
info.dev !== before.dev
|
|
72
|
+
)
|
|
73
|
+
throw new Error("The saved agent runtime record changed during inspection.");
|
|
74
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
75
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
76
|
+
if (bytesRead > maxBytes) throw new Error("The saved agent runtime record is too large.");
|
|
77
|
+
return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
78
|
+
} finally {
|
|
79
|
+
await file.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function privateRecord(path, origin, agentId) {
|
|
84
|
+
const record = await readPrivateRuntimeJson(path);
|
|
85
|
+
if (
|
|
86
|
+
record.version !== 1 ||
|
|
87
|
+
record.origin !== origin ||
|
|
88
|
+
record.agentId !== agentId ||
|
|
89
|
+
!Number.isSafeInteger(record.pid) ||
|
|
90
|
+
record.pid < 1 ||
|
|
91
|
+
typeof record.nonce !== "string" ||
|
|
92
|
+
!/^[a-f0-9-]{36}$/i.test(record.nonce)
|
|
93
|
+
)
|
|
94
|
+
throw new Error("The saved agent lock is incomplete or does not match this agent and API.");
|
|
95
|
+
return record;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function acquireMissionAgentSession(
|
|
99
|
+
{ baseUrl, agentId, signal },
|
|
100
|
+
{ lockDirectory = join(homedir(), ".totem", "agent-runtime-locks") } = {},
|
|
101
|
+
) {
|
|
102
|
+
const origin = missionAgentOrigin(baseUrl);
|
|
103
|
+
const key = missionAgentSessionKey(origin, agentId);
|
|
104
|
+
if (lockDirectory === join(homedir(), ".totem", "agent-runtime-locks"))
|
|
105
|
+
await ensurePrivateRuntimeDirectory(join(homedir(), ".totem"));
|
|
106
|
+
await ensurePrivateRuntimeDirectory(lockDirectory);
|
|
107
|
+
const path = join(lockDirectory, `${key}.lock`);
|
|
108
|
+
const nonce = randomUUID();
|
|
109
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
110
|
+
signal?.throwIfAborted();
|
|
111
|
+
try {
|
|
112
|
+
const file = await open(path, "wx", 0o600);
|
|
113
|
+
try {
|
|
114
|
+
await file.writeFile(
|
|
115
|
+
JSON.stringify({ version: 1, pid: process.pid, nonce, origin, agentId }),
|
|
116
|
+
);
|
|
117
|
+
} finally {
|
|
118
|
+
await file.close();
|
|
119
|
+
}
|
|
120
|
+
const session = { origin, agentId, key };
|
|
121
|
+
sessions.add(session);
|
|
122
|
+
let released = false;
|
|
123
|
+
session.release = async () => {
|
|
124
|
+
if (released) return;
|
|
125
|
+
released = true;
|
|
126
|
+
sessions.delete(session);
|
|
127
|
+
const current = await privateRecord(path, origin, agentId).catch(() => null);
|
|
128
|
+
if (current?.nonce === nonce && current.pid === process.pid)
|
|
129
|
+
await rm(path, { force: true });
|
|
130
|
+
};
|
|
131
|
+
return session;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code !== "EEXIST") throw error;
|
|
134
|
+
}
|
|
135
|
+
const record = await privateRecord(path, origin, agentId).catch((error) => {
|
|
136
|
+
if (error.code === "ENOENT") return null;
|
|
137
|
+
throw error;
|
|
138
|
+
});
|
|
139
|
+
if (!record) {
|
|
140
|
+
await sleep(25, undefined, { signal });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (alive(record.pid))
|
|
144
|
+
throw Object.assign(
|
|
145
|
+
new Error(
|
|
146
|
+
"This agent is already running against this Totem API. Stop its existing runtime before starting another profile.",
|
|
147
|
+
),
|
|
148
|
+
{ code: "AGENT_ALREADY_RUNNING" },
|
|
149
|
+
);
|
|
150
|
+
// Serialize dead-owner cleanup separately, then re-read the main owner.
|
|
151
|
+
// A stale cleanup guard is kept for inspection rather than risking removal
|
|
152
|
+
// of another live process's lock.
|
|
153
|
+
const cleanupPath = `${path}.cleanup`;
|
|
154
|
+
let cleanup;
|
|
155
|
+
try {
|
|
156
|
+
cleanup = await open(cleanupPath, "wx", 0o600);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error.code !== "EEXIST") throw error;
|
|
159
|
+
await sleep(25, undefined, { signal });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const latest = await privateRecord(path, origin, agentId).catch((error) => {
|
|
164
|
+
if (error.code === "ENOENT") return null;
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
if (latest && !alive(latest.pid)) await rm(path);
|
|
168
|
+
} finally {
|
|
169
|
+
await cleanup.close();
|
|
170
|
+
await rm(cleanupPath, { force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw Object.assign(
|
|
174
|
+
new Error(
|
|
175
|
+
"The agent session lock is busy or incomplete; inspect the existing runtime before retrying.",
|
|
176
|
+
),
|
|
177
|
+
{ code: "AGENT_SESSION_LOCK_BUSY" },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function assertMissionAgentSession(session, baseUrl, agentId) {
|
|
182
|
+
if (
|
|
183
|
+
!sessions.has(session) ||
|
|
184
|
+
session.origin !== missionAgentOrigin(baseUrl) ||
|
|
185
|
+
session.agentId !== agentId
|
|
186
|
+
)
|
|
187
|
+
throw new Error("The worker does not own this verified agent session.");
|
|
188
|
+
}
|