@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/jobs.js CHANGED
@@ -1,99 +1,616 @@
1
- // Unified persistent store for scheduled work — both one-shot wakeups
2
- // the agent set for itself ("check back in 30 min") and recurring crons
3
- // (user-defined or agent-defined). Survives bot restarts.
1
+ // Durable provider-aware store for one-shot wakeups and recurring crons.
4
2
  //
5
- // Entry shape:
6
- // {
7
- // id: "job_<ts>_<rand>",
8
- // kind: "wakeup" | "cron",
9
- // adapter: "<adapter-id>", // e.g. "telegram-1" / "kazee-1"
10
- // adapterType: "telegram"|"kazee",
11
- // channelId: "<chat or channel id>",
12
- // canonicalUserId: "telegram:<id>" or "<email>",
13
- // project: "<project-name>" or null,
14
- // prompt: "<text to send as if user>",
15
- // label: "<short>",
16
- // source: "agent" | "user",
17
- // // wakeup-only
18
- // fireAt: <ms epoch>,
19
- // // cron-only
20
- // schedule: "<5-field cron>",
21
- // // session pinning (per choice a/i):
22
- // sessionKey: "lastSessionId" | "cursorSessionId" | "codexSessionId" | null,
23
- // sessionId: "<claude-session-id>" | null,
24
- // createdAt: <ms>,
25
- // lastFireAt: <ms> | null,
26
- // lastFireOk: true|false|null,
27
- // }
3
+ // Runnable entries are pinned to one canonical user, channel, project,
4
+ // provider, provider-settings snapshot, and native session lineage. Legacy
5
+ // entries that cannot be classified safely stay in `archived` and are never
6
+ // returned by listAll() or scheduled.
28
7
 
8
+ "use strict";
9
+
10
+ const crypto = require("crypto");
29
11
  const fs = require("fs");
30
12
  const path = require("path");
31
- const { JOBS_FILE } = require("./config");
13
+ const {
14
+ CONFIG_DIR,
15
+ CRONS_FILE,
16
+ JOBS_FILE,
17
+ SESSIONS_FILE,
18
+ STATE_FILE,
19
+ } = require("./config");
20
+ const { atomicWriteFileSync } = require("./fsutil");
21
+ const {
22
+ defaultMigrationSources,
23
+ markMigrationComponentActivated,
24
+ markMigrationComponentPrepared,
25
+ requireMigrationSnapshotForComponent,
26
+ sha256,
27
+ } = require("./migration-backup");
28
+
29
+ const JOBS_SCHEMA_VERSION = 2;
30
+ const DEFAULT_MAX_ATTEMPTS = 5;
31
+ const RUNNABLE_PROVIDERS = new Set(["claude", "codex"]);
32
+ const PROVIDER_SETTING_KEYS = Object.freeze(["model", "effort", "budget", "permissionMode", "worktree"]);
33
+ const LEGACY_SESSION_KEY = "sessionKey";
34
+ const LEGACY_CRON_ARCHIVE_REASON = "Legacy cron had no saved provider/session identity and is disabled";
32
35
 
33
- function load() {
34
- try {
35
- const raw = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
36
- return Array.isArray(raw) ? raw : [];
37
- } catch (e) { return []; }
36
+ function isRecord(value) {
37
+ return !!value && typeof value === "object" && !Array.isArray(value);
38
38
  }
39
39
 
40
- function save(list) {
41
- fs.mkdirSync(path.dirname(JOBS_FILE), { recursive: true });
42
- const tmp = `${JOBS_FILE}.tmp`;
43
- fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
44
- fs.renameSync(tmp, JOBS_FILE);
40
+ function nonEmpty(value) {
41
+ const text = value === null || value === undefined ? "" : String(value).trim();
42
+ return text || null;
45
43
  }
46
44
 
47
- function nextId(kind) {
48
- return `${kind}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
45
+ function clone(value) {
46
+ return value === undefined ? undefined : structuredClone(value);
47
+ }
48
+
49
+ function occurrenceId(job, options = {}) {
50
+ if (nonEmpty(job.occurrenceId)) return nonEmpty(job.occurrenceId);
51
+ const factory = options.occurrenceIdFactory || (() => (
52
+ typeof crypto.randomUUID === "function"
53
+ ? crypto.randomUUID()
54
+ : `${Date.now()}-${crypto.randomBytes(6).toString("hex")}`
55
+ ));
56
+ return nonEmpty(factory(job)) || `${job.id || "job"}-${Date.now()}`;
49
57
  }
50
58
 
51
- function add(job) {
52
- const list = load();
53
- const entry = { createdAt: Date.now(), lastFireAt: null, lastFireOk: null, ...job };
54
- if (!entry.id) entry.id = nextId(entry.kind || "job");
55
- list.push(entry);
56
- save(list);
57
- return entry;
59
+ function nowMs(options = {}) {
60
+ const value = typeof options.now === "function" ? options.now() : Date.now();
61
+ const number = value instanceof Date ? value.getTime() : Number(value);
62
+ if (!Number.isFinite(number)) throw new TypeError("jobs clock returned an invalid time");
63
+ return number;
58
64
  }
59
65
 
60
- function remove(id) {
61
- const list = load();
62
- const idx = list.findIndex((j) => j.id === id);
63
- if (idx < 0) return null;
64
- const [removed] = list.splice(idx, 1);
65
- save(list);
66
- return removed;
66
+ function legacyProvider(record) {
67
+ const explicit = nonEmpty(record.provider)?.toLowerCase() || null;
68
+ if (explicit) return explicit;
69
+ const key = nonEmpty(record[LEGACY_SESSION_KEY]);
70
+ if (key === "lastSessionId") return "claude";
71
+ if (key === "codexSessionId") return "codex";
72
+ if (key === "cursorSessionId") return "cursor";
73
+ return null;
67
74
  }
68
75
 
69
- function update(id, patch) {
70
- const list = load();
71
- const idx = list.findIndex((j) => j.id === id);
72
- if (idx < 0) return null;
73
- list[idx] = { ...list[idx], ...patch };
74
- save(list);
75
- return list[idx];
76
+ function archiveLegacyJob(record, reason, options = {}) {
77
+ const archived = {
78
+ ...clone(record),
79
+ id: nonEmpty(record?.id) || `archived-${occurrenceId(record || {}, options)}`,
80
+ provider: legacyProvider(record || {}),
81
+ project: nonEmpty(record?.project),
82
+ sessionId: nonEmpty(record?.sessionId),
83
+ status: "archived",
84
+ disabled: true,
85
+ archiveReason: reason,
86
+ archivedAt: Number.isFinite(options.archivedAt) ? options.archivedAt : nowMs(options),
87
+ };
88
+ if (Object.prototype.hasOwnProperty.call(archived, LEGACY_SESSION_KEY)) {
89
+ archived.legacySessionPointerField = archived[LEGACY_SESSION_KEY];
90
+ delete archived[LEGACY_SESSION_KEY];
91
+ }
92
+ return archived;
76
93
  }
77
94
 
78
- function get(id) {
79
- return load().find((j) => j.id === id) || null;
95
+ function requiredLegacyContext(record, options = {}) {
96
+ return {
97
+ canonicalUserId: nonEmpty(record.canonicalUserId) || nonEmpty(options.defaultCanonicalUserId),
98
+ channelId: nonEmpty(record.channelId) || nonEmpty(options.defaultChannelId),
99
+ adapter: nonEmpty(record.adapter) || nonEmpty(options.defaultAdapter),
100
+ adapterType: nonEmpty(record.adapterType) || nonEmpty(options.defaultAdapterType),
101
+ };
80
102
  }
81
103
 
82
- function listForChannel(adapter, channelId) {
83
- return load().filter((j) =>
84
- String(j.channelId) === String(channelId) &&
85
- (j.adapter === adapter || j.adapterType === adapter),
86
- );
104
+ function normalizeProviderSettings(value) {
105
+ if (!isRecord(value)) return {};
106
+ const result = {};
107
+ for (const key of PROVIDER_SETTING_KEYS) {
108
+ if (Object.prototype.hasOwnProperty.call(value, key)) result[key] = clone(value[key]);
109
+ }
110
+ return result;
87
111
  }
88
112
 
89
- function listAll() { return load(); }
113
+ function normalizeRunnableJob(record, options = {}) {
114
+ if (!isRecord(record)) throw new TypeError("job must be an object");
115
+ const provider = legacyProvider(record);
116
+ const project = nonEmpty(record.project);
117
+ const context = requiredLegacyContext(record, options);
118
+ const id = nonEmpty(record.id);
119
+ const kind = nonEmpty(record.kind);
120
+ const prompt = nonEmpty(record.prompt);
121
+ if (!id || !["wakeup", "cron"].includes(kind) || !prompt) return null;
122
+ if (!RUNNABLE_PROVIDERS.has(provider) || !project) return null;
123
+ if (!context.canonicalUserId || !context.channelId || (!context.adapter && !context.adapterType)) return null;
124
+ if (kind === "wakeup" && !Number.isFinite(Number(record.fireAt))) return null;
125
+ if (kind === "cron" && !nonEmpty(record.schedule)) return null;
90
126
 
91
- function listByCanonicalUser(canonicalUserId) {
92
- return load().filter((j) => j.canonicalUserId === canonicalUserId);
127
+ const createdAt = Number.isFinite(Number(record.createdAt)) ? Number(record.createdAt) : nowMs(options);
128
+ const attemptCount = Math.max(0, Number.parseInt(record.attemptCount || 0, 10) || 0);
129
+ const maxAttempts = Math.max(1, Number.parseInt(record.maxAttempts || DEFAULT_MAX_ATTEMPTS, 10) || DEFAULT_MAX_ATTEMPTS);
130
+ const allowedStatuses = new Set(["pending", "running", "deferred", "failed", "succeeded", "dead-letter"]);
131
+ const status = allowedStatuses.has(record.status) ? record.status : "pending";
132
+ const disabled = record.disabled === true || status === "dead-letter";
133
+ const normalized = {
134
+ ...clone(record),
135
+ id,
136
+ kind,
137
+ adapter: context.adapter,
138
+ adapterType: context.adapterType,
139
+ channelId: context.channelId,
140
+ canonicalUserId: context.canonicalUserId,
141
+ userId: nonEmpty(record.userId),
142
+ provider,
143
+ providerSettings: normalizeProviderSettings(record.providerSettings),
144
+ project,
145
+ projectDir: nonEmpty(record.projectDir),
146
+ prompt,
147
+ label: nonEmpty(record.label) || prompt.slice(0, 60),
148
+ source: nonEmpty(record.source) || "agent",
149
+ sessionId: nonEmpty(record.sessionId),
150
+ previousSessionId: nonEmpty(record.previousSessionId),
151
+ sessionLineage: isRecord(record.sessionLineage) ? clone(record.sessionLineage) : {},
152
+ originRunId: nonEmpty(record.originRunId) || nonEmpty(record.sessionLineage?.originRunId),
153
+ occurrenceId: occurrenceId(record, options),
154
+ status,
155
+ disabled,
156
+ attemptCount,
157
+ maxAttempts,
158
+ nextAttemptAt: Number.isFinite(Number(record.nextAttemptAt)) ? Number(record.nextAttemptAt) : null,
159
+ createdAt,
160
+ lastFireAt: Number.isFinite(Number(record.lastFireAt)) ? Number(record.lastFireAt) : null,
161
+ lastFireOk: typeof record.lastFireOk === "boolean" ? record.lastFireOk : null,
162
+ lastError: isRecord(record.lastError) ? clone(record.lastError) : null,
163
+ };
164
+ if (kind === "wakeup") normalized.fireAt = Number(record.fireAt);
165
+ else normalized.schedule = nonEmpty(record.schedule);
166
+ delete normalized[LEGACY_SESSION_KEY];
167
+ delete normalized.archiveReason;
168
+ delete normalized.archivedAt;
169
+ return normalized;
93
170
  }
94
171
 
172
+ function migrationReason(record, options = {}) {
173
+ const provider = legacyProvider(record);
174
+ if (provider === "cursor") return "Pinned to removed provider Cursor Agent";
175
+ if (!provider || !RUNNABLE_PROVIDERS.has(provider)) return "Legacy provider is missing or ambiguous";
176
+ if (!nonEmpty(record.project)) return "Legacy job has no unambiguous project";
177
+ const context = requiredLegacyContext(record, options);
178
+ if (!context.canonicalUserId) return "Legacy job has no unambiguous canonical user";
179
+ if (!context.channelId || (!context.adapter && !context.adapterType)) return "Legacy job has no unambiguous channel";
180
+ if (!nonEmpty(record.id) || !["wakeup", "cron"].includes(nonEmpty(record.kind)) || !nonEmpty(record.prompt)) {
181
+ return "Legacy job shape is incomplete";
182
+ }
183
+ if (record.kind === "wakeup" && !Number.isFinite(Number(record.fireAt))) return "Legacy wakeup has no valid fire time";
184
+ if (record.kind === "cron" && !nonEmpty(record.schedule)) return "Legacy cron has no valid schedule";
185
+ return "Legacy job could not be classified safely";
186
+ }
187
+
188
+ function normalizeArchived(record, options = {}) {
189
+ if (!isRecord(record)) return null;
190
+ if (record.status === "archived" && record.disabled === true && nonEmpty(record.archiveReason)) {
191
+ return clone(record);
192
+ }
193
+ return archiveLegacyJob(record, migrationReason(record, options), options);
194
+ }
195
+
196
+ function migrateJobsDocument(raw, options = {}) {
197
+ const source = isRecord(raw) && raw.schemaVersion === JOBS_SCHEMA_VERSION
198
+ ? raw
199
+ : { jobs: Array.isArray(raw) ? raw : [], archived: [] };
200
+ const jobs = [];
201
+ const archived = [];
202
+
203
+ for (const record of Array.isArray(source.jobs) ? source.jobs : []) {
204
+ const normalized = normalizeRunnableJob(record, options);
205
+ if (normalized) jobs.push(normalized);
206
+ else archived.push(archiveLegacyJob(record, migrationReason(record, options), options));
207
+ }
208
+ for (const record of Array.isArray(source.archived) ? source.archived : []) {
209
+ const normalized = normalizeArchived(record, options);
210
+ if (normalized) archived.push(normalized);
211
+ }
212
+ const occupied = new Map([...jobs, ...archived].map((record) => [record.id, record]));
213
+ for (const cronArchive of Array.isArray(options.legacyCronArchives) ? options.legacyCronArchives : []) {
214
+ let normalized = normalizeArchived(cronArchive, options);
215
+ if (!normalized) continue;
216
+ const alreadyArchived = [...occupied.values()].some((record) => (
217
+ (normalized.legacyCronHash && record.legacyCronHash === normalized.legacyCronHash)
218
+ || (normalized.legacyCronSourceHash && record.legacyCronSourceHash === normalized.legacyCronSourceHash)
219
+ ));
220
+ if (alreadyArchived) continue;
221
+ const collision = occupied.get(normalized.id);
222
+ if (collision) {
223
+ const evidenceHash = normalized.legacyCronHash || normalized.legacyCronSourceHash;
224
+ normalized = {
225
+ ...normalized,
226
+ legacyId: normalized.id,
227
+ id: `legacy-cron-${evidenceHash.slice(0, 32)}`,
228
+ };
229
+ }
230
+ occupied.set(normalized.id, normalized);
231
+ archived.push(normalized);
232
+ }
233
+ if (options.malformedSource) {
234
+ archived.push({
235
+ id: "legacy-jobs-source",
236
+ provider: null,
237
+ project: null,
238
+ sessionId: null,
239
+ status: "archived",
240
+ disabled: true,
241
+ archiveReason: "Malformed legacy jobs source retained byte-for-byte in the verified migration snapshot",
242
+ archivedAt: nowMs(options),
243
+ sourceSnapshot: clone(options.malformedSource),
244
+ });
245
+ }
246
+
247
+ const dedupe = (records) => {
248
+ const result = new Map();
249
+ for (const record of records) result.set(nonEmpty(record.id) || occurrenceId(record, options), record);
250
+ return [...result.values()];
251
+ };
252
+ const result = {
253
+ schemaVersion: JOBS_SCHEMA_VERSION,
254
+ jobs: dedupe(jobs),
255
+ archived: dedupe(archived),
256
+ };
257
+ const legacyCronMigration = isRecord(options.legacyCronMigration)
258
+ ? options.legacyCronMigration
259
+ : source.legacyCronMigration;
260
+ if (isRecord(legacyCronMigration)) result.legacyCronMigration = clone(legacyCronMigration);
261
+ return result;
262
+ }
263
+
264
+ function migrationSources(configDir, options = {}) {
265
+ return options.sources || defaultMigrationSources({
266
+ configDir,
267
+ stateFile: options.stateFile || path.join(configDir, path.basename(STATE_FILE)),
268
+ sessionsFile: options.sessionsFile || path.join(configDir, path.basename(SESSIONS_FILE)),
269
+ jobsFile: options.jobsFile || path.join(configDir, path.basename(JOBS_FILE)),
270
+ cronsFile: options.cronsFile || path.join(configDir, path.basename(CRONS_FILE)),
271
+ });
272
+ }
273
+
274
+ function createJobsStore(options = {}) {
275
+ const jobsFile = path.resolve(options.jobsFile || JOBS_FILE);
276
+ const configDir = path.resolve(options.configDir || path.dirname(jobsFile) || CONFIG_DIR);
277
+ const sources = migrationSources(configDir, { ...options, jobsFile });
278
+ const defaults = isRecord(options.defaults) ? options.defaults : {};
279
+ const migrationOptions = () => ({
280
+ defaultCanonicalUserId: defaults.canonicalUserId,
281
+ defaultChannelId: defaults.channelId,
282
+ defaultAdapter: defaults.adapter,
283
+ defaultAdapterType: defaults.adapterType,
284
+ now: options.now,
285
+ occurrenceIdFactory: options.occurrenceIdFactory,
286
+ });
287
+
288
+ function readLegacyCronSource() {
289
+ const source = sources.find((entry) => entry.kind === "crons" && entry.path === "crons.json");
290
+ if (!source || !fs.existsSync(source.sourcePath)) {
291
+ return {
292
+ legacyCronMigration: {
293
+ path: source?.path || "crons.json",
294
+ present: false,
295
+ size: null,
296
+ sha256: null,
297
+ status: "missing",
298
+ recordCount: 0,
299
+ recordHashes: [],
300
+ },
301
+ legacyCronArchives: [],
302
+ };
303
+ }
304
+ const bytes = fs.readFileSync(source.sourcePath);
305
+ const sourceHash = sha256(bytes);
306
+ let records;
307
+ try { records = JSON.parse(bytes.toString("utf8")); }
308
+ catch (_) { records = null; }
309
+ if (!Array.isArray(records)) {
310
+ const archiveId = `legacy-crons-source-${sourceHash.slice(0, 16)}`;
311
+ return {
312
+ legacyCronMigration: {
313
+ path: source.path,
314
+ present: true,
315
+ size: bytes.length,
316
+ sha256: sourceHash,
317
+ status: "malformed-archived",
318
+ recordCount: 0,
319
+ recordHashes: [],
320
+ archiveId,
321
+ },
322
+ legacyCronArchives: [{
323
+ id: archiveId,
324
+ provider: null,
325
+ project: null,
326
+ sessionId: null,
327
+ status: "archived",
328
+ disabled: true,
329
+ archiveReason: "Malformed legacy cron source retained byte-for-byte in the verified migration snapshot",
330
+ archivedAt: nowMs(migrationOptions()),
331
+ legacyCronSourceHash: sourceHash,
332
+ }],
333
+ };
334
+ }
335
+ const recordHashes = [];
336
+ const legacyCronArchives = records.map((record) => {
337
+ const serialized = JSON.stringify(record);
338
+ const recordHash = sha256(serialized);
339
+ recordHashes.push(recordHash);
340
+ const valid = isRecord(record) && nonEmpty(record.schedule) && nonEmpty(record.prompt);
341
+ const value = isRecord(record) ? clone(record) : { legacyRecord: clone(record) };
342
+ return archiveLegacyJob({
343
+ ...value,
344
+ id: nonEmpty(record?.id) || `legacy-cron-${recordHash.slice(0, 16)}`,
345
+ kind: "cron",
346
+ legacyCronHash: recordHash,
347
+ legacyCronSource: source.path,
348
+ }, valid ? LEGACY_CRON_ARCHIVE_REASON : "Legacy cron record is incomplete and is disabled", migrationOptions());
349
+ });
350
+ return {
351
+ legacyCronMigration: {
352
+ path: source.path,
353
+ present: true,
354
+ size: bytes.length,
355
+ sha256: sourceHash,
356
+ status: "archived",
357
+ recordCount: records.length,
358
+ recordHashes,
359
+ },
360
+ legacyCronArchives,
361
+ };
362
+ }
363
+
364
+ function readSource() {
365
+ if (!fs.existsSync(jobsFile)) return { raw: [], malformedSource: null, missing: true };
366
+ const bytes = fs.readFileSync(jobsFile);
367
+ try { return { raw: JSON.parse(bytes.toString("utf8")), malformedSource: null, missing: false }; }
368
+ catch (_) {
369
+ return {
370
+ raw: [],
371
+ malformedSource: { path: "jobs.json", size: bytes.length, sha256: sha256(bytes) },
372
+ missing: false,
373
+ };
374
+ }
375
+ }
376
+
377
+ function readDocument() {
378
+ const source = readSource();
379
+ return migrateJobsDocument(source.raw, {
380
+ ...migrationOptions(),
381
+ ...readLegacyCronSource(),
382
+ malformedSource: source.malformedSource,
383
+ });
384
+ }
385
+
386
+ function saveDocument(document, saveOptions = {}) {
387
+ const source = readSource();
388
+ const alreadyProviderAware = isRecord(source.raw)
389
+ && source.raw.schemaVersion === JOBS_SCHEMA_VERSION
390
+ && Array.isArray(source.raw.jobs)
391
+ && Array.isArray(source.raw.archived);
392
+ const snapshot = requireMigrationSnapshotForComponent("jobs", {
393
+ configDir,
394
+ sources,
395
+ allowSourceMismatch: alreadyProviderAware,
396
+ });
397
+ markMigrationComponentPrepared(snapshot.snapshotDir, "jobs", {
398
+ configDir,
399
+ sources,
400
+ requireSourceMatch: !alreadyProviderAware,
401
+ });
402
+ const normalized = migrateJobsDocument(document, { ...migrationOptions(), ...readLegacyCronSource() });
403
+ atomicWriteFileSync(jobsFile, `${JSON.stringify(normalized, null, 2)}\n`, { backup: true });
404
+ if (typeof saveOptions.faultInjector === "function") {
405
+ saveOptions.faultInjector("after-jobs-write", { jobsFile, document: normalized });
406
+ }
407
+ const persisted = JSON.parse(fs.readFileSync(jobsFile, "utf8"));
408
+ if (persisted?.schemaVersion !== JOBS_SCHEMA_VERSION || !Array.isArray(persisted.jobs) || !Array.isArray(persisted.archived)) {
409
+ const error = new Error("provider-aware jobs verification failed after write");
410
+ error.code = "JOBS_MIGRATION_WRITE_INVALID";
411
+ throw error;
412
+ }
413
+ markMigrationComponentActivated(snapshot.snapshotDir, "jobs", { configDir, sources });
414
+ return normalized;
415
+ }
416
+
417
+ function ensureMigrated(saveOptions = {}) {
418
+ const source = readSource();
419
+ const document = migrateJobsDocument(source.raw, {
420
+ ...migrationOptions(),
421
+ ...readLegacyCronSource(),
422
+ malformedSource: source.malformedSource,
423
+ });
424
+ const alreadyProviderAware = isRecord(source.raw)
425
+ && source.raw.schemaVersion === JOBS_SCHEMA_VERSION
426
+ && Array.isArray(source.raw.jobs)
427
+ && Array.isArray(source.raw.archived);
428
+ if (alreadyProviderAware) {
429
+ // A crash can leave the new file durable while the journal component is
430
+ // still pending. Re-saving completes the prepare/verify/activate cycle.
431
+ return saveDocument(document, saveOptions);
432
+ }
433
+ return saveDocument(document, saveOptions);
434
+ }
435
+
436
+ function mutate(mutator) {
437
+ const document = ensureMigrated();
438
+ const result = mutator(document);
439
+ saveDocument(document);
440
+ return clone(result);
441
+ }
442
+
443
+ function listAll() {
444
+ return readDocument().jobs.map(clone);
445
+ }
446
+
447
+ function listArchived() {
448
+ return readDocument().archived.map(clone);
449
+ }
450
+
451
+ function get(id) {
452
+ return listAll().find((job) => job.id === id) || null;
453
+ }
454
+
455
+ function add(job) {
456
+ const candidate = normalizeRunnableJob({
457
+ id: nonEmpty(job?.id) || nextId(job?.kind || "job"),
458
+ createdAt: nowMs(options),
459
+ lastFireAt: null,
460
+ lastFireOk: null,
461
+ status: "pending",
462
+ disabled: false,
463
+ attemptCount: 0,
464
+ maxAttempts: DEFAULT_MAX_ATTEMPTS,
465
+ ...clone(job),
466
+ }, migrationOptions());
467
+ if (!candidate) {
468
+ const error = new TypeError("scheduled jobs require kind, provider, project, user, channel, prompt, and timing metadata");
469
+ error.code = "INVALID_SCHEDULED_JOB";
470
+ throw error;
471
+ }
472
+ return mutate((document) => {
473
+ if (document.jobs.some((entry) => entry.id === candidate.id)
474
+ || document.archived.some((entry) => entry.id === candidate.id)) {
475
+ const error = new Error(`scheduled job already exists: ${candidate.id}`);
476
+ error.code = "DUPLICATE_SCHEDULED_JOB";
477
+ throw error;
478
+ }
479
+ document.jobs.push(candidate);
480
+ return candidate;
481
+ });
482
+ }
483
+
484
+ function remove(id) {
485
+ return mutate((document) => {
486
+ const index = document.jobs.findIndex((job) => job.id === id);
487
+ if (index < 0) return null;
488
+ return document.jobs.splice(index, 1)[0];
489
+ });
490
+ }
491
+
492
+ function update(id, patch) {
493
+ return mutate((document) => {
494
+ const index = document.jobs.findIndex((job) => job.id === id);
495
+ if (index < 0) return null;
496
+ const next = normalizeRunnableJob({ ...document.jobs[index], ...clone(patch) }, migrationOptions());
497
+ if (!next) throw Object.assign(new Error(`invalid scheduled job update: ${id}`), { code: "INVALID_SCHEDULED_JOB" });
498
+ document.jobs[index] = next;
499
+ return next;
500
+ });
501
+ }
502
+
503
+ function archive(job, reason = null) {
504
+ return mutate((document) => {
505
+ const index = document.jobs.findIndex((entry) => entry.id === job.id);
506
+ const source = index >= 0 ? document.jobs.splice(index, 1)[0] : job;
507
+ const archived = archiveLegacyJob(source, reason || "Archived by scheduler", migrationOptions());
508
+ document.archived.push(archived);
509
+ return archived;
510
+ });
511
+ }
512
+
513
+ function bindOriginRunSession(originRunId, provider, project, sessionId) {
514
+ const run = nonEmpty(originRunId);
515
+ const providerId = nonEmpty(provider);
516
+ const projectName = nonEmpty(project);
517
+ const nativeSessionId = nonEmpty(sessionId);
518
+ if (!run || !providerId || !projectName || !nativeSessionId) return 0;
519
+ const pending = readDocument().jobs.some((job) => (
520
+ job.originRunId === run && job.provider === providerId && job.project === projectName && !job.sessionId
521
+ ));
522
+ if (!pending) return 0;
523
+ return mutate((document) => {
524
+ let count = 0;
525
+ for (const job of document.jobs) {
526
+ if (job.originRunId !== run || job.provider !== providerId || job.project !== projectName || job.sessionId) continue;
527
+ job.sessionId = nativeSessionId;
528
+ job.sessionLineage = {
529
+ ...(isRecord(job.sessionLineage) ? job.sessionLineage : {}),
530
+ originRunId: run,
531
+ originSessionId: nativeSessionId,
532
+ boundAt: nowMs(options),
533
+ };
534
+ count += 1;
535
+ }
536
+ return count;
537
+ });
538
+ }
539
+
540
+ function markArchivedAnnounced(ids) {
541
+ const wanted = new Set((Array.isArray(ids) ? ids : [ids]).filter(Boolean));
542
+ if (wanted.size === 0) return 0;
543
+ return mutate((document) => {
544
+ let count = 0;
545
+ for (const record of document.archived) {
546
+ if (!wanted.has(record.id) || record.announcedAt) continue;
547
+ record.announcedAt = nowMs(options);
548
+ count += 1;
549
+ }
550
+ return count;
551
+ });
552
+ }
553
+
554
+ function listForChannel(adapter, channelId) {
555
+ return listAll().filter((job) => (
556
+ String(job.channelId) === String(channelId)
557
+ && (job.adapter === adapter || job.adapterType === adapter)
558
+ ));
559
+ }
560
+
561
+ function listByCanonicalUser(canonicalUserId) {
562
+ return listAll().filter((job) => job.canonicalUserId === canonicalUserId);
563
+ }
564
+
565
+ return {
566
+ ensureMigrated,
567
+ readDocument,
568
+ saveDocument,
569
+ load: listAll,
570
+ save(list) {
571
+ const current = readDocument();
572
+ current.jobs = Array.isArray(list) ? list : [];
573
+ return saveDocument(current).jobs;
574
+ },
575
+ add,
576
+ remove,
577
+ update,
578
+ get,
579
+ archive,
580
+ bindOriginRunSession,
581
+ listAll,
582
+ listArchived,
583
+ markArchivedAnnounced,
584
+ listForChannel,
585
+ listByCanonicalUser,
586
+ };
587
+ }
588
+
589
+ function nextId(kind) {
590
+ return `${kind}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
591
+ }
592
+
593
+ const defaultStore = createJobsStore();
594
+
95
595
  module.exports = {
96
- load, save, add, remove, update, get,
97
- listAll, listForChannel, listByCanonicalUser,
596
+ JOBS_SCHEMA_VERSION,
597
+ DEFAULT_MAX_ATTEMPTS,
598
+ RUNNABLE_PROVIDERS,
599
+ migrateJobsDocument,
600
+ createJobsStore,
98
601
  nextId,
602
+ ensureMigrated: defaultStore.ensureMigrated,
603
+ load: defaultStore.load,
604
+ save: defaultStore.save,
605
+ add: defaultStore.add,
606
+ remove: defaultStore.remove,
607
+ update: defaultStore.update,
608
+ get: defaultStore.get,
609
+ archive: defaultStore.archive,
610
+ bindOriginRunSession: defaultStore.bindOriginRunSession,
611
+ listAll: defaultStore.listAll,
612
+ listArchived: defaultStore.listArchived,
613
+ markArchivedAnnounced: defaultStore.markArchivedAnnounced,
614
+ listForChannel: defaultStore.listForChannel,
615
+ listByCanonicalUser: defaultStore.listByCanonicalUser,
99
616
  };