@inetafrica/open-claudia 2.14.8 → 3.0.0
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/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- package/web.js +159 -45
package/core/lessons.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
const fs = require("fs");
|
|
25
25
|
const path = require("path");
|
|
26
26
|
const crypto = require("crypto");
|
|
27
|
+
const { atomicWriteFileSync } = require("./fsutil");
|
|
27
28
|
|
|
28
29
|
const CONFIG_DIR = require("../config-dir");
|
|
29
30
|
const LESSONS_FILE = process.env.LESSONS_FILE ? path.resolve(process.env.LESSONS_FILE) : path.join(CONFIG_DIR, "lessons.md");
|
|
@@ -93,7 +94,7 @@ function readMeta() {
|
|
|
93
94
|
|
|
94
95
|
function writeMeta(meta) {
|
|
95
96
|
fs.mkdirSync(path.dirname(LESSONS_META_FILE), { recursive: true, mode: 0o700 });
|
|
96
|
-
|
|
97
|
+
atomicWriteFileSync(LESSONS_META_FILE, JSON.stringify(meta, null, 2) + "\n", { mode: 0o600, backup: true });
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
// Self-heal: drop meta for lessons that no longer exist, seed defaults for
|
|
@@ -128,7 +129,7 @@ function writeLessons(lessons) {
|
|
|
128
129
|
const body = lessons.map(lessonLine).join("\n");
|
|
129
130
|
const content = FILE_HEADER + "\n" + body + (body ? "\n" : "");
|
|
130
131
|
fs.mkdirSync(path.dirname(LESSONS_FILE), { recursive: true, mode: 0o700 });
|
|
131
|
-
|
|
132
|
+
atomicWriteFileSync(LESSONS_FILE, content, { mode: 0o600, backup: true });
|
|
132
133
|
return LESSONS_FILE;
|
|
133
134
|
}
|
|
134
135
|
|
package/core/loopback.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Bot ↔ subprocess send channel. Each running bot publishes a tiny HTTP
|
|
2
2
|
// server on 127.0.0.1:<random-port> and writes {port, token} to a
|
|
3
3
|
// per-PID file under CONFIG_DIR/loopback. Subprocesses spawned through
|
|
4
|
-
//
|
|
5
|
-
// OC_CHANNEL_ADAPTER
|
|
4
|
+
// runAgent inherit OC_SEND_URL, OC_SEND_TOKEN, OC_CHANNEL_ID, and
|
|
5
|
+
// OC_CHANNEL_ADAPTER plus immutable provider/project/session/run metadata in
|
|
6
|
+
// their env; the `open-claudia send-file /
|
|
6
7
|
// send-voice / send-photo` CLI subcommands POST a file back through
|
|
7
8
|
// this endpoint so the active channel's adapter can deliver it.
|
|
8
9
|
//
|
|
@@ -169,13 +170,24 @@ async function handleJson(req, res, url, kind) {
|
|
|
169
170
|
adapterType: adapter.type,
|
|
170
171
|
channelId,
|
|
171
172
|
canonicalUserId: payload.canonicalUserId || null,
|
|
173
|
+
userId: payload.userId || null,
|
|
174
|
+
provider: payload.provider || null,
|
|
175
|
+
providerSettings: payload.providerSettings || {},
|
|
172
176
|
project: payload.project || null,
|
|
177
|
+
projectDir: payload.projectDir || null,
|
|
173
178
|
prompt: String(payload.prompt),
|
|
174
179
|
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
175
180
|
source: payload.source || "agent",
|
|
176
181
|
fireAt,
|
|
177
|
-
sessionKey: payload.sessionKey || null,
|
|
178
182
|
sessionId: payload.sessionId || null,
|
|
183
|
+
previousSessionId: payload.previousSessionId || null,
|
|
184
|
+
originRunId: payload.originRunId || null,
|
|
185
|
+
sessionLineage: {
|
|
186
|
+
originRunId: payload.originRunId || null,
|
|
187
|
+
originSessionId: payload.sessionId || null,
|
|
188
|
+
previousSessionId: payload.previousSessionId || null,
|
|
189
|
+
originProject: payload.originProject || payload.project || null,
|
|
190
|
+
},
|
|
179
191
|
});
|
|
180
192
|
return reply(res, 200, { ok: true, job });
|
|
181
193
|
}
|
|
@@ -188,13 +200,24 @@ async function handleJson(req, res, url, kind) {
|
|
|
188
200
|
adapterType: adapter.type,
|
|
189
201
|
channelId,
|
|
190
202
|
canonicalUserId: payload.canonicalUserId || null,
|
|
203
|
+
userId: payload.userId || null,
|
|
204
|
+
provider: payload.provider || null,
|
|
205
|
+
providerSettings: payload.providerSettings || {},
|
|
191
206
|
project: payload.project || null,
|
|
207
|
+
projectDir: payload.projectDir || null,
|
|
192
208
|
prompt: String(payload.prompt),
|
|
193
209
|
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
194
210
|
source: payload.source || "agent",
|
|
195
211
|
schedule: String(payload.schedule),
|
|
196
|
-
sessionKey: payload.sessionKey || null,
|
|
197
212
|
sessionId: payload.sessionId || null,
|
|
213
|
+
previousSessionId: payload.previousSessionId || null,
|
|
214
|
+
originRunId: payload.originRunId || null,
|
|
215
|
+
sessionLineage: {
|
|
216
|
+
originRunId: payload.originRunId || null,
|
|
217
|
+
originSessionId: payload.sessionId || null,
|
|
218
|
+
previousSessionId: payload.previousSessionId || null,
|
|
219
|
+
originProject: payload.originProject || payload.project || null,
|
|
220
|
+
},
|
|
198
221
|
});
|
|
199
222
|
return reply(res, 200, { ok: true, job });
|
|
200
223
|
}
|
|
@@ -208,7 +231,11 @@ async function handleJson(req, res, url, kind) {
|
|
|
208
231
|
|
|
209
232
|
if (kind === "job-list") {
|
|
210
233
|
const list = jobsStore.listForChannel(adapterId, channelId);
|
|
211
|
-
|
|
234
|
+
// Archived legacy jobs often predate channel metadata — show the
|
|
235
|
+
// channel-less ones too rather than hiding them everywhere.
|
|
236
|
+
const archived = (jobsStore.listArchived ? jobsStore.listArchived() : [])
|
|
237
|
+
.filter((job) => !job.channelId || String(job.channelId) === String(channelId));
|
|
238
|
+
return reply(res, 200, { ok: true, jobs: list, archived });
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
if (kind === "task-add") {
|
|
@@ -496,11 +523,20 @@ async function handleJson(req, res, url, kind) {
|
|
|
496
523
|
const rec = approvals.create({
|
|
497
524
|
tool: payload.tool, verb: payload.verb || "", tier: payload.tier || "destructive",
|
|
498
525
|
command: payload.command, channelId, adapter: adapterId,
|
|
526
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
527
|
+
userId: payload.userId || null,
|
|
528
|
+
provider: payload.provider || null,
|
|
529
|
+
providerSettings: payload.providerSettings || {},
|
|
530
|
+
project: payload.project || null,
|
|
531
|
+
projectDir: payload.projectDir || null,
|
|
532
|
+
sessionId: payload.sessionId || null,
|
|
533
|
+
previousSessionId: payload.previousSessionId || null,
|
|
534
|
+
originRunId: payload.originRunId || null,
|
|
499
535
|
});
|
|
500
536
|
const text = [
|
|
501
537
|
`🔐 Destructive tool run wants approval:`,
|
|
502
538
|
``,
|
|
503
|
-
`<pre>${String(
|
|
539
|
+
`<pre>${String(rec.command).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</pre>`,
|
|
504
540
|
``,
|
|
505
541
|
`Tool: ${payload.tool}${payload.verb ? ` · verb: ${payload.verb}` : ""} · tier: ${rec.tier}`,
|
|
506
542
|
`This is the literal command that will execute. Decide within ~90s to run it inline; after that the agent moves on, but your Approve/Deny stays valid for 24h and wakes it back up.`,
|
|
@@ -512,8 +548,9 @@ async function handleJson(req, res, url, kind) {
|
|
|
512
548
|
// <pre> needs Telegram's HTML parse mode to render; other adapters ignore it.
|
|
513
549
|
const sendOpts = { keyboard };
|
|
514
550
|
if (adapter.type === "telegram") sendOpts.parseMode = "HTML";
|
|
515
|
-
const
|
|
516
|
-
|
|
551
|
+
const { normalizeSendResult } = require("./io");
|
|
552
|
+
const sent = normalizeSendResult(await adapter.send(channelId, text, sendOpts));
|
|
553
|
+
if (!sent.ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
|
|
517
554
|
audit.log("tool.approval.requested", { id: rec.id, tool: rec.tool, verb: rec.verb, channelId });
|
|
518
555
|
return reply(res, 200, { ok: true, id: rec.id });
|
|
519
556
|
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const CONFIG_DIR = require("../config-dir");
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_PENDING = 50;
|
|
9
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 30 * 1000;
|
|
10
|
+
const DEFAULT_LOCK_POLL_MS = 25;
|
|
11
|
+
const DEFAULT_STALE_LOCK_MS = 30 * 60 * 1000;
|
|
12
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
|
|
13
|
+
|
|
14
|
+
class MutationQueueError extends Error {
|
|
15
|
+
constructor(code, message, details = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "MutationQueueError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.details = details;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
24
|
+
|
|
25
|
+
function positiveInteger(value, fallback, label) {
|
|
26
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
27
|
+
const number = Number(value);
|
|
28
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new TypeError(`${label} must be a positive integer`);
|
|
29
|
+
return number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeLabel(value) {
|
|
33
|
+
return String(value || "memory-mutation")
|
|
34
|
+
.replace(/[^a-zA-Z0-9_.:-]+/g, "-")
|
|
35
|
+
.slice(0, 80) || "memory-mutation";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeMetadata(metadata) {
|
|
39
|
+
const result = {};
|
|
40
|
+
for (const key of ["provider", "model", "purpose", "runId", "trigger"]) {
|
|
41
|
+
if (metadata?.[key] == null) continue;
|
|
42
|
+
result[key] = String(metadata[key]).slice(0, 160);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ownerIsAlive(lockDir) {
|
|
48
|
+
let owner;
|
|
49
|
+
try { owner = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")); }
|
|
50
|
+
catch (_) { return false; }
|
|
51
|
+
const pid = Number(owner?.pid);
|
|
52
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false;
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return error.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class MemoryMutationQueue {
|
|
62
|
+
constructor(options = {}) {
|
|
63
|
+
this.lockDir = options.lockDir || path.join(CONFIG_DIR, "memory-mutation.lock");
|
|
64
|
+
this.journalFile = options.journalFile || path.join(CONFIG_DIR, "memory-mutation-intents.jsonl");
|
|
65
|
+
this.maxPending = positiveInteger(options.maxPending, DEFAULT_MAX_PENDING, "maxPending");
|
|
66
|
+
this.lockTimeoutMs = positiveInteger(options.lockTimeoutMs, DEFAULT_LOCK_TIMEOUT_MS, "lockTimeoutMs");
|
|
67
|
+
this.lockPollMs = positiveInteger(options.lockPollMs, DEFAULT_LOCK_POLL_MS, "lockPollMs");
|
|
68
|
+
this.staleLockMs = positiveInteger(options.staleLockMs, DEFAULT_STALE_LOCK_MS, "staleLockMs");
|
|
69
|
+
this.accepting = true;
|
|
70
|
+
this.stopReason = null;
|
|
71
|
+
this.pending = new Map();
|
|
72
|
+
this.chain = Promise.resolve();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
record(entry) {
|
|
76
|
+
const line = {
|
|
77
|
+
at: new Date().toISOString(),
|
|
78
|
+
queuePid: process.pid,
|
|
79
|
+
...entry,
|
|
80
|
+
};
|
|
81
|
+
delete line.payload;
|
|
82
|
+
try {
|
|
83
|
+
fs.mkdirSync(path.dirname(this.journalFile), { recursive: true, mode: 0o700 });
|
|
84
|
+
fs.appendFileSync(this.journalFile, `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
85
|
+
} catch (_) { /* intent telemetry must not break the guarded mutation */ }
|
|
86
|
+
return line;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async acquireLock(entry) {
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
const token = `${process.pid}-${entry.id}-${crypto.randomBytes(6).toString("hex")}`;
|
|
92
|
+
while (true) {
|
|
93
|
+
try {
|
|
94
|
+
fs.mkdirSync(this.lockDir, { recursive: false, mode: 0o700 });
|
|
95
|
+
try {
|
|
96
|
+
fs.writeFileSync(path.join(this.lockDir, "owner.json"), `${JSON.stringify({ token, pid: process.pid, id: entry.id, at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
try { fs.rmSync(this.lockDir, { recursive: true, force: true }); } catch (_) {}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return token;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code !== "EEXIST") {
|
|
104
|
+
throw new MutationQueueError("MEMORY_MUTATION_LOCK_FAILED", `Unable to acquire memory mutation lock: ${error.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const age = Date.now() - fs.statSync(this.lockDir).mtimeMs;
|
|
110
|
+
if (age > this.staleLockMs && !ownerIsAlive(this.lockDir)) {
|
|
111
|
+
fs.rmSync(this.lockDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
} catch (_) { /* another process may have released it */ }
|
|
114
|
+
if (Date.now() - started >= this.lockTimeoutMs) {
|
|
115
|
+
throw new MutationQueueError("MEMORY_MUTATION_LOCK_TIMEOUT", "Timed out waiting for the shared memory mutation lock", {
|
|
116
|
+
label: entry.label,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
await delay(this.lockPollMs);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
releaseLock(token) {
|
|
124
|
+
try {
|
|
125
|
+
const owner = JSON.parse(fs.readFileSync(path.join(this.lockDir, "owner.json"), "utf8"));
|
|
126
|
+
if (owner.token !== token) return;
|
|
127
|
+
} catch (_) { return; }
|
|
128
|
+
try { fs.rmSync(this.lockDir, { recursive: true, force: true }); } catch (_) {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
enqueue(label, task, metadata = {}) {
|
|
132
|
+
const normalizedLabel = safeLabel(label);
|
|
133
|
+
if (typeof task !== "function") return Promise.reject(new TypeError("memory mutation task must be a function"));
|
|
134
|
+
if (!this.accepting) {
|
|
135
|
+
const error = new MutationQueueError("MEMORY_MUTATION_QUEUE_CLOSED", "Memory mutation admission is closed", {
|
|
136
|
+
label: normalizedLabel,
|
|
137
|
+
reason: this.stopReason,
|
|
138
|
+
});
|
|
139
|
+
this.record({ id: null, label: normalizedLabel, status: "rejected_closed", reason: this.stopReason });
|
|
140
|
+
return Promise.reject(error);
|
|
141
|
+
}
|
|
142
|
+
if (this.pending.size >= this.maxPending) {
|
|
143
|
+
const error = new MutationQueueError("MEMORY_MUTATION_QUEUE_FULL", "Memory mutation queue is full", {
|
|
144
|
+
label: normalizedLabel,
|
|
145
|
+
maxPending: this.maxPending,
|
|
146
|
+
});
|
|
147
|
+
this.record({ id: null, label: normalizedLabel, status: "rejected_full" });
|
|
148
|
+
return Promise.reject(error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const id = typeof crypto.randomUUID === "function"
|
|
152
|
+
? crypto.randomUUID()
|
|
153
|
+
: `${Date.now()}-${crypto.randomBytes(8).toString("hex")}`;
|
|
154
|
+
const entry = {
|
|
155
|
+
id,
|
|
156
|
+
label: normalizedLabel,
|
|
157
|
+
metadata: safeMetadata(metadata),
|
|
158
|
+
admittedAt: Date.now(),
|
|
159
|
+
promise: null,
|
|
160
|
+
};
|
|
161
|
+
this.record({ id, label: normalizedLabel, status: "admitted", ...entry.metadata });
|
|
162
|
+
|
|
163
|
+
const execute = async () => {
|
|
164
|
+
let token = null;
|
|
165
|
+
try {
|
|
166
|
+
token = await this.acquireLock(entry);
|
|
167
|
+
this.record({ id, label: normalizedLabel, status: "started", ...entry.metadata });
|
|
168
|
+
const result = await task({ id, label: normalizedLabel, metadata: { ...entry.metadata } });
|
|
169
|
+
this.record({ id, label: normalizedLabel, status: "completed", ...entry.metadata });
|
|
170
|
+
return result;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.record({
|
|
173
|
+
id,
|
|
174
|
+
label: normalizedLabel,
|
|
175
|
+
status: "failed",
|
|
176
|
+
code: String(error.code || "MEMORY_MUTATION_FAILED").slice(0, 80),
|
|
177
|
+
reason: String(error.message || "memory mutation failed").slice(0, 240),
|
|
178
|
+
...entry.metadata,
|
|
179
|
+
});
|
|
180
|
+
throw error;
|
|
181
|
+
} finally {
|
|
182
|
+
if (token) this.releaseLock(token);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const promise = this.chain.then(execute, execute);
|
|
187
|
+
entry.promise = promise;
|
|
188
|
+
this.pending.set(id, entry);
|
|
189
|
+
this.chain = promise.then(() => undefined, () => undefined);
|
|
190
|
+
promise.then(
|
|
191
|
+
() => this.pending.delete(id),
|
|
192
|
+
() => this.pending.delete(id),
|
|
193
|
+
);
|
|
194
|
+
return promise;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
stopAdmission(reason = "shutdown") {
|
|
198
|
+
if (!this.accepting) return false;
|
|
199
|
+
this.accepting = false;
|
|
200
|
+
this.stopReason = String(reason || "shutdown").slice(0, 160);
|
|
201
|
+
this.record({ id: null, label: "queue", status: "admission_stopped", reason: this.stopReason });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async drain(options = {}) {
|
|
206
|
+
const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_DRAIN_TIMEOUT_MS, "drain timeoutMs");
|
|
207
|
+
const snapshot = [...this.pending.values()];
|
|
208
|
+
if (!snapshot.length) return { drained: true, pending: [] };
|
|
209
|
+
let timer;
|
|
210
|
+
const completed = Promise.allSettled(snapshot.map((entry) => entry.promise)).then(() => true);
|
|
211
|
+
const timed = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); });
|
|
212
|
+
const drained = await Promise.race([completed, timed]);
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
if (drained) return { drained: true, pending: [] };
|
|
215
|
+
const pending = snapshot
|
|
216
|
+
.filter((entry) => this.pending.has(entry.id))
|
|
217
|
+
.map((entry) => ({ id: entry.id, label: entry.label, ...entry.metadata }));
|
|
218
|
+
for (const entry of pending) {
|
|
219
|
+
this.record({ ...entry, status: "pending_on_shutdown", reason: this.stopReason || "bounded drain timed out" });
|
|
220
|
+
}
|
|
221
|
+
return { drained: false, pending };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
stats() {
|
|
225
|
+
return {
|
|
226
|
+
accepting: this.accepting,
|
|
227
|
+
pending: this.pending.size,
|
|
228
|
+
active: [...this.pending.values()].map((entry) => ({ id: entry.id, label: entry.label, ...entry.metadata })),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const memoryMutationQueue = new MemoryMutationQueue();
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
DEFAULT_DRAIN_TIMEOUT_MS,
|
|
237
|
+
DEFAULT_MAX_PENDING,
|
|
238
|
+
MemoryMutationQueue,
|
|
239
|
+
MutationQueueError,
|
|
240
|
+
memoryMutationQueue,
|
|
241
|
+
};
|