@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/jobs.js
CHANGED
|
@@ -1,99 +1,616 @@
|
|
|
1
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
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
|
|
34
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
48
|
-
return
|
|
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
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
55
|
-
|
|
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
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
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
79
|
-
return
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
};
|