@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.
Files changed (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. 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
- fs.writeFileSync(LESSONS_META_FILE, JSON.stringify(meta, null, 2) + "\n", { mode: 0o600 });
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
- fs.writeFileSync(LESSONS_FILE, content, { mode: 0o600 });
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
- // runClaude inherit OC_SEND_URL, OC_SEND_TOKEN, OC_CHANNEL_ID, and
5
- // OC_CHANNEL_ADAPTER in their env; the `open-claudia send-file /
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
- return reply(res, 200, { ok: true, jobs: list });
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(payload.command).slice(0, 800).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre>`,
539
+ `<pre>${String(rec.command).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</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 ok = await adapter.send(channelId, text, sendOpts);
516
- if (!ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
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
+ };