@hivemagnus/ardent 0.0.0-stage → 1.0.2

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 (115) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +274 -2
  3. package/dist/a2a-client.d.ts +35 -0
  4. package/dist/a2a-client.js +172 -0
  5. package/dist/a2a-hermes-alias.d.ts +3 -0
  6. package/dist/a2a-hermes-alias.js +57 -0
  7. package/dist/a2a-idempotency.d.ts +13 -0
  8. package/dist/a2a-idempotency.js +86 -0
  9. package/dist/a2a-peers.d.ts +28 -0
  10. package/dist/a2a-peers.js +108 -0
  11. package/dist/a2a-push-inbox.d.ts +22 -0
  12. package/dist/a2a-push-inbox.js +84 -0
  13. package/dist/a2a-push.d.ts +27 -0
  14. package/dist/a2a-push.js +188 -0
  15. package/dist/a2a-server.d.ts +37 -0
  16. package/dist/a2a-server.js +186 -0
  17. package/dist/a2a-store.d.ts +10 -0
  18. package/dist/a2a-store.js +122 -0
  19. package/dist/a2a.d.ts +19 -0
  20. package/dist/a2a.js +466 -0
  21. package/dist/api.d.ts +6 -0
  22. package/dist/api.js +381 -0
  23. package/dist/artifacts.d.ts +15 -0
  24. package/dist/artifacts.js +41 -0
  25. package/dist/backend-events.d.ts +49 -0
  26. package/dist/backend-events.js +8 -0
  27. package/dist/channels.d.ts +52 -0
  28. package/dist/channels.js +184 -0
  29. package/dist/cli.d.ts +2 -0
  30. package/dist/cli.js +845 -0
  31. package/dist/codex-output-budget.d.ts +17 -0
  32. package/dist/codex-output-budget.js +31 -0
  33. package/dist/codex-output-budget.test.d.ts +1 -0
  34. package/dist/codex-output-budget.test.js +29 -0
  35. package/dist/codex.d.ts +82 -0
  36. package/dist/codex.js +431 -0
  37. package/dist/config.d.ts +171 -0
  38. package/dist/config.js +244 -0
  39. package/dist/context-checkpoints.d.ts +49 -0
  40. package/dist/context-checkpoints.js +248 -0
  41. package/dist/context.d.ts +16 -0
  42. package/dist/context.js +101 -0
  43. package/dist/gateway-lock.d.ts +18 -0
  44. package/dist/gateway-lock.js +104 -0
  45. package/dist/hermes-compat.d.ts +15 -0
  46. package/dist/hermes-compat.js +62 -0
  47. package/dist/identity.d.ts +15 -0
  48. package/dist/identity.js +52 -0
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.js +8 -0
  51. package/dist/job-engine.d.ts +222 -0
  52. package/dist/job-engine.js +497 -0
  53. package/dist/job-news-handlers.d.ts +54 -0
  54. package/dist/job-news-handlers.js +153 -0
  55. package/dist/job-ownership.d.ts +9 -0
  56. package/dist/job-ownership.js +17 -0
  57. package/dist/json-schema.d.ts +9 -0
  58. package/dist/json-schema.js +20 -0
  59. package/dist/llamacpp.d.ts +49 -0
  60. package/dist/llamacpp.js +216 -0
  61. package/dist/magnus-control.d.ts +3 -0
  62. package/dist/magnus-control.js +99 -0
  63. package/dist/magnus-node-dependency-impact.d.ts +1 -0
  64. package/dist/magnus-node-dependency-impact.js +126 -0
  65. package/dist/magnus-owner-reads.d.ts +4 -0
  66. package/dist/magnus-owner-reads.js +99 -0
  67. package/dist/magnus-readiness.d.ts +1 -0
  68. package/dist/magnus-readiness.js +108 -0
  69. package/dist/mattermost.d.ts +18 -0
  70. package/dist/mattermost.js +303 -0
  71. package/dist/memory-tools.d.ts +5 -0
  72. package/dist/memory-tools.js +49 -0
  73. package/dist/memory.d.ts +40 -0
  74. package/dist/memory.js +128 -0
  75. package/dist/model-policy.d.ts +64 -0
  76. package/dist/model-policy.js +56 -0
  77. package/dist/news.d.ts +10 -0
  78. package/dist/news.js +291 -0
  79. package/dist/owner-read.d.ts +32 -0
  80. package/dist/owner-read.js +130 -0
  81. package/dist/plugins.d.ts +74 -0
  82. package/dist/plugins.js +115 -0
  83. package/dist/research.d.ts +110 -0
  84. package/dist/research.js +164 -0
  85. package/dist/runtime.d.ts +33 -0
  86. package/dist/runtime.js +665 -0
  87. package/dist/skills.d.ts +13 -0
  88. package/dist/skills.js +87 -0
  89. package/dist/store.d.ts +142 -0
  90. package/dist/store.js +322 -0
  91. package/dist/team-artifacts.d.ts +138 -0
  92. package/dist/team-artifacts.js +241 -0
  93. package/dist/team-child.d.ts +21 -0
  94. package/dist/team-child.js +130 -0
  95. package/dist/team-contract.d.ts +116 -0
  96. package/dist/team-contract.js +84 -0
  97. package/dist/team-control.d.ts +8 -0
  98. package/dist/team-control.js +152 -0
  99. package/dist/telegram.d.ts +35 -0
  100. package/dist/telegram.js +284 -0
  101. package/dist/tools.d.ts +48 -0
  102. package/dist/tools.js +58 -0
  103. package/dist/version.d.ts +1 -0
  104. package/dist/version.js +5 -0
  105. package/model-policy.default.json +20 -0
  106. package/package.json +84 -4
  107. package/profiles/assistant/IDENTITY.md +9 -0
  108. package/profiles/assistant/SOUL.md +8 -0
  109. package/profiles/assistant/config.yaml +88 -0
  110. package/profiles/assistant/peers.yaml +12 -0
  111. package/profiles/assistant/skills/grounded-answers/SKILL.md +18 -0
  112. package/protocol/LICENSE-A2A.txt +202 -0
  113. package/protocol/README.md +12 -0
  114. package/protocol/a2a.proto +811 -0
  115. package/protocol/lock.json +9 -0
@@ -0,0 +1,497 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, existsSync, mkdirSync } from 'node:fs';
3
+ import { open, readFile, unlink } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { z } from 'zod';
6
+ const identifier = z.string().regex(/^[a-z][a-z0-9._-]{0,79}$/);
7
+ const jobId = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,79}$/);
8
+ const timezone = z.string().min(1).max(80).refine(value => {
9
+ try {
10
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }, 'Unknown IANA timezone');
17
+ const cronSchedule = z.object({
18
+ cron: z.string().min(9).max(100), timezone,
19
+ missed_window: z.enum(['skip', 'catch_up']),
20
+ catch_up_minutes: z.number().int().min(0).max(1440)
21
+ }).strict();
22
+ const intervalSchedule = z.object({
23
+ kind: z.literal('interval'), every_minutes: z.number().int().min(1).max(1440),
24
+ anchor_at: z.string().datetime({ offset: false }).refine(value => value.endsWith('Z'), 'Interval anchor must be UTC')
25
+ }).strict();
26
+ const definition = z.object({
27
+ id: jobId,
28
+ /** `external` marks a job retained for reference but owned by another scheduler. */
29
+ owner: z.enum(['ardent', 'external']),
30
+ enabled: z.boolean(),
31
+ handler: identifier,
32
+ schedule: z.union([cronSchedule, intervalSchedule]),
33
+ input: z.unknown().optional(),
34
+ delivery: z.object({ kind: z.enum(['none', 'telegram', 'mattermost']), target_ref: identifier.optional() }).strict(),
35
+ policy: z.object({ max_attempts: z.number().int().min(1).max(5), retry_backoff_seconds: z.number().int().min(1).max(3600) }).strict()
36
+ }).strict();
37
+ export const JobDefinitionSchema = definition;
38
+ export const JobDefinitionsSchema = z.object({
39
+ schema_version: z.literal(1), profile: identifier, revision: z.number().int().positive(),
40
+ jobs: z.array(definition).max(100)
41
+ }).strict();
42
+ const rowSchema = z.object({ window_key: z.string(), job_id: z.string(), local_key: z.string(),
43
+ scheduled_ms: z.number().int(), definition_revision: z.number().int(), definition_sha256: z.string(),
44
+ state: z.enum(['queued', 'running', 'retry', 'completed', 'failed', 'unknown', 'superseded']),
45
+ attempts: z.number().int(), next_attempt_ms: z.number().int(), receipt_json: z.string().nullable(),
46
+ receipt_sha256: z.string().nullable(), error: z.string().nullable(), updated_ms: z.number().int() });
47
+ function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
48
+ function encode(value) {
49
+ const encoded = JSON.stringify(value);
50
+ if (!encoded || Buffer.byteLength(encoded, 'utf8') > 32768)
51
+ throw new Error('Job receipt exceeds 32 KiB or is not JSON');
52
+ return encoded;
53
+ }
54
+ function cronField(source, min, max) {
55
+ const values = new Set();
56
+ for (const part of source.split(',')) {
57
+ const match = /^(\*|\d{1,2}(?:-\d{1,2})?)(?:\/(\d{1,2}))?$/.exec(part);
58
+ if (!match)
59
+ throw new Error(`Invalid cron field: ${source}`);
60
+ const step = match[2] ? Number(match[2]) : 1;
61
+ if (step < 1 || step > max - min + 1)
62
+ throw new Error(`Invalid cron step: ${source}`);
63
+ const range = match[1];
64
+ const [start, end] = range === '*' ? [min, max] : range.includes('-') ? range.split('-').map(Number)
65
+ : [Number(range), match[2] ? max : Number(range)];
66
+ if (start === undefined || end === undefined || start < min || end > max || start > end)
67
+ throw new Error(`Invalid cron range: ${source}`);
68
+ for (let value = start; value <= end; value += step)
69
+ values.add(value);
70
+ }
71
+ if (!values.size)
72
+ throw new Error(`Empty cron field: ${source}`);
73
+ return values;
74
+ }
75
+ function parseCron(expression) {
76
+ const fields = expression.trim().split(/\s+/);
77
+ if (fields.length !== 5)
78
+ throw new Error('Job cron must contain five fields');
79
+ const minute = fields[0];
80
+ const hour = fields[1];
81
+ const day = fields[2];
82
+ const month = fields[3];
83
+ const weekday = fields[4];
84
+ return { minute: cronField(minute, 0, 59), hour: cronField(hour, 0, 23), day: cronField(day, 1, 31),
85
+ month: cronField(month, 1, 12), weekday: cronField(weekday, 0, 6), anyDay: day === '*', anyWeekday: weekday === '*' };
86
+ }
87
+ const formatters = new Map();
88
+ function localParts(at, zone) {
89
+ let formatter = formatters.get(zone);
90
+ if (!formatter) {
91
+ formatter = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
92
+ hour: '2-digit', minute: '2-digit', weekday: 'short', hourCycle: 'h23' });
93
+ formatters.set(zone, formatter);
94
+ }
95
+ const parts = Object.fromEntries(formatter.formatToParts(at).filter(part => part.type !== 'literal').map(part => [part.type, part.value]));
96
+ const week = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(parts.weekday ?? '');
97
+ if (week < 0)
98
+ throw new Error('Failed to derive local weekday');
99
+ return { key: `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`, minute: Number(parts.minute),
100
+ hour: Number(parts.hour), day: Number(parts.day), month: Number(parts.month), weekday: week };
101
+ }
102
+ function matches(cron, local) {
103
+ const dayMatches = cron.day.has(local.day);
104
+ const weekdayMatches = cron.weekday.has(local.weekday);
105
+ const calendarMatches = cron.anyDay && cron.anyWeekday ? true : cron.anyDay ? weekdayMatches : cron.anyWeekday ? dayMatches : dayMatches || weekdayMatches;
106
+ return cron.minute.has(local.minute) && cron.hour.has(local.hour) && cron.month.has(local.month) && calendarMatches;
107
+ }
108
+ /** Validates all data before it can be selected. Definitions are data, never executable code. */
109
+ export function validateJobDefinitions(raw, registry, expectedProfile) {
110
+ const definitions = JobDefinitionsSchema.parse(raw);
111
+ if (expectedProfile && definitions.profile !== expectedProfile)
112
+ throw new Error('Job definition profile differs from selected profile');
113
+ const ids = new Set();
114
+ for (const job of definitions.jobs) {
115
+ if (ids.has(job.id))
116
+ throw new Error(`Duplicate job ID ${job.id}`);
117
+ ids.add(job.id);
118
+ if (!('kind' in job.schedule)) {
119
+ parseCron(job.schedule.cron);
120
+ if (job.schedule.missed_window === 'skip' && job.schedule.catch_up_minutes !== 0)
121
+ throw new Error(`Skip job ${job.id} has a catch-up horizon`);
122
+ if (job.schedule.missed_window === 'catch_up' && job.schedule.catch_up_minutes < 1)
123
+ throw new Error(`Catch-up job ${job.id} needs a horizon`);
124
+ }
125
+ if (job.delivery.kind === 'none' && job.delivery.target_ref)
126
+ throw new Error(`No-delivery job ${job.id} has a target`);
127
+ if (job.delivery.kind !== 'none' && !job.delivery.target_ref)
128
+ throw new Error(`Delivery job ${job.id} lacks a target reference`);
129
+ const handler = registry[job.handler];
130
+ if (!handler)
131
+ throw new Error(`Unknown job handler ${job.handler}`);
132
+ handler.input.parse(job.input);
133
+ if (handler.delivery)
134
+ handler.delivery.parse(job.delivery);
135
+ else if (job.delivery.kind !== 'none')
136
+ throw new Error(`Job handler ${job.handler} does not support delivery`);
137
+ }
138
+ return { definitions, sha256: sha256(JSON.stringify(definitions)), job_count: definitions.jobs.length };
139
+ }
140
+ /** A profile-local SQLite scheduler. No work is admitted without a fresh ownership check. */
141
+ export class JobEngine {
142
+ db;
143
+ profileDir;
144
+ profile;
145
+ registry;
146
+ ownershipCheck;
147
+ clock;
148
+ #lastValidationError = null;
149
+ #lastActive = null;
150
+ #busy = false;
151
+ /** `clock` measures elapsed time within a tick; the injected tick time stays the timeline origin. */
152
+ constructor(db, profileDir, profile, registry, ownershipCheck, clock = () => Date.now()) {
153
+ this.db = db;
154
+ this.profileDir = profileDir;
155
+ this.profile = profile;
156
+ this.registry = registry;
157
+ this.ownershipCheck = ownershipCheck;
158
+ this.clock = clock;
159
+ identifier.parse(profile);
160
+ mkdirSync(join(profileDir, 'state', 'job-definition-releases'), { recursive: true, mode: 0o700 });
161
+ db.exec(`CREATE TABLE IF NOT EXISTS job_windows (
162
+ window_key TEXT PRIMARY KEY, job_id TEXT NOT NULL, local_key TEXT NOT NULL, scheduled_ms INTEGER NOT NULL,
163
+ definition_revision INTEGER NOT NULL, definition_sha256 TEXT NOT NULL,
164
+ state TEXT NOT NULL, attempts INTEGER NOT NULL, next_attempt_ms INTEGER NOT NULL,
165
+ receipt_json TEXT, receipt_sha256 TEXT, error TEXT, updated_ms INTEGER NOT NULL);
166
+ CREATE INDEX IF NOT EXISTS job_windows_due ON job_windows(state,next_attempt_ms);
167
+ CREATE TABLE IF NOT EXISTS job_attempts (
168
+ window_key TEXT NOT NULL REFERENCES job_windows(window_key), attempt INTEGER NOT NULL,
169
+ state TEXT NOT NULL, receipt_json TEXT NOT NULL, receipt_sha256 TEXT NOT NULL,
170
+ recorded_ms INTEGER NOT NULL, PRIMARY KEY(window_key,attempt));
171
+ CREATE TABLE IF NOT EXISTS job_scan (
172
+ job_id TEXT PRIMARY KEY, scanned_ms INTEGER NOT NULL, updated_ms INTEGER NOT NULL,
173
+ definition_revision INTEGER NOT NULL DEFAULT 0, definition_sha256 TEXT NOT NULL DEFAULT '');`);
174
+ // Profiles installed before scan binding retain their timestamps, but they
175
+ // cannot claim current-definition coverage until the next scheduler tick.
176
+ const scanColumns = new Set(this.db.prepare('PRAGMA table_info(job_scan)').all().map(row => z.object({ name: z.string() }).parse(row).name));
177
+ if (!scanColumns.has('definition_revision'))
178
+ db.exec('ALTER TABLE job_scan ADD COLUMN definition_revision INTEGER NOT NULL DEFAULT 0');
179
+ if (!scanColumns.has('definition_sha256'))
180
+ db.exec("ALTER TABLE job_scan ADD COLUMN definition_sha256 TEXT NOT NULL DEFAULT ''");
181
+ }
182
+ async load() {
183
+ try {
184
+ const file = await readFile(join(this.profileDir, 'jobs', 'active.json'), 'utf8');
185
+ const raw = JSON.parse(file);
186
+ const parsed = validateJobDefinitions(raw, this.registry, this.profile);
187
+ const fileSha256 = sha256(file);
188
+ this.#lastActive = { revision: parsed.definitions.revision, sha256: fileSha256 };
189
+ this.#lastValidationError = null;
190
+ return { ...parsed, sha256: fileSha256 };
191
+ }
192
+ catch (error) {
193
+ this.#lastActive = null;
194
+ // JSON parser diagnostics can quote configuration content, including secret references.
195
+ this.#lastValidationError = error instanceof SyntaxError ? 'Job definitions contain invalid JSON'
196
+ : error instanceof z.ZodError ? 'Job definitions failed schema validation'
197
+ : error instanceof Error ? error.message.replace(/[\r\n]/g, ' ').slice(0, 200) : 'Invalid job definitions';
198
+ throw error;
199
+ }
200
+ }
201
+ recover(now = new Date()) {
202
+ // An interrupted handler may have sent an external effect. It is never replayed automatically.
203
+ return this.db.transaction(() => {
204
+ const running = this.db.prepare("SELECT * FROM job_windows WHERE state='running'").all().map(row => rowSchema.parse(row));
205
+ for (const window of running) {
206
+ const receipt = encode({ effect_status: 'unknown', evidence: 'interrupted during handler execution' });
207
+ this.db.prepare('INSERT OR IGNORE INTO job_attempts(window_key,attempt,state,receipt_json,receipt_sha256,recorded_ms) VALUES (?,?,?,?,?,?)')
208
+ .run(window.window_key, window.attempts, 'unknown', receipt, sha256(receipt), now.getTime());
209
+ this.db.prepare("UPDATE job_windows SET state='unknown',receipt_json=?,receipt_sha256=?,error='interrupted during handler execution',updated_ms=? WHERE window_key=? AND state='running'")
210
+ .run(receipt, sha256(receipt), now.getTime(), window.window_key);
211
+ }
212
+ return running.length;
213
+ })();
214
+ }
215
+ /** Startup reconciliation requires the gateway's exclusive profile lock. */
216
+ async recoverAfterRestart(now = new Date()) {
217
+ const markerPath = this.#tickMarker();
218
+ let markerFile;
219
+ try {
220
+ markerFile = await open(markerPath, constants.O_RDONLY | constants.O_NOFOLLOW);
221
+ }
222
+ catch (error) {
223
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
224
+ return this.recover(now);
225
+ throw error;
226
+ }
227
+ let marker;
228
+ try {
229
+ const stat = await markerFile.stat();
230
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 512)
231
+ throw new Error('Job tick marker is invalid');
232
+ marker = z.object({ pid: z.number().int().positive(), started_at: z.string().datetime({ offset: true }) }).strict()
233
+ .parse(JSON.parse(await markerFile.readFile({ encoding: 'utf8' })));
234
+ }
235
+ catch {
236
+ throw new Error('Job tick marker is malformed; inspect before clearing');
237
+ }
238
+ finally {
239
+ await markerFile.close();
240
+ }
241
+ try {
242
+ process.kill(marker.pid, 0);
243
+ throw new Error(`Job tick marker belongs to live PID ${marker.pid}; do not recover concurrently`);
244
+ }
245
+ catch (error) {
246
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH'))
247
+ throw error;
248
+ }
249
+ const recovered = this.recover(now);
250
+ await unlink(markerPath);
251
+ return recovered;
252
+ }
253
+ async status(limit = 50, now = new Date()) {
254
+ let active = null;
255
+ try {
256
+ active = (await this.load()).definitions;
257
+ }
258
+ catch { /* Invalid definitions are reported and cannot execute. */ }
259
+ const counts = Object.fromEntries(this.db.prepare('SELECT state,count(*) AS count FROM job_windows GROUP BY state').all()
260
+ .map(row => { const value = z.object({ state: z.string(), count: z.number().int() }).parse(row); return [value.state, value.count]; }));
261
+ const windows = this.db.prepare('SELECT * FROM job_windows ORDER BY scheduled_ms DESC,job_id LIMIT ?').all(Math.max(1, Math.min(limit, 100)))
262
+ .map(row => {
263
+ const { receipt_json: _receipt, error, ...rest } = rowSchema.parse(row);
264
+ return { ...rest, error: error?.slice(0, 200) ?? null };
265
+ });
266
+ const jobs = (active?.jobs ?? []).map(job => {
267
+ const localCounts = Object.fromEntries(this.db.prepare('SELECT state,count(*) AS count FROM job_windows WHERE job_id=? GROUP BY state').all(job.id)
268
+ .map(row => { const value = z.object({ state: z.string(), count: z.number().int() }).parse(row); return [value.state, value.count]; }));
269
+ const last = this.db.prepare('SELECT local_key FROM job_windows WHERE job_id=? ORDER BY scheduled_ms DESC LIMIT 1').get(job.id);
270
+ const lastKey = last ? z.object({ local_key: z.string() }).parse(last).local_key : null;
271
+ return { job_id: job.id, next_window: this.#nextWindow(job, now), last_window: lastKey, counts: localCounts };
272
+ });
273
+ return { active_revision: this.#lastActive?.revision ?? null, active_sha256: this.#lastActive?.sha256 ?? null,
274
+ last_validation_error: this.#lastValidationError, active_jobs: (counts.queued ?? 0) + (counts.running ?? 0) + (counts.retry ?? 0),
275
+ unknown_jobs: counts.unknown ?? 0, counts, jobs, windows };
276
+ }
277
+ #nextWindow(job, now) {
278
+ if (!job.enabled || job.owner !== 'ardent')
279
+ return null;
280
+ if ('kind' in job.schedule) {
281
+ const due = this.#intervalDue(job);
282
+ return due === null ? null : new Date(due).toISOString();
283
+ }
284
+ const cron = parseCron(job.schedule.cron);
285
+ const start = Math.floor(now.getTime() / 60_000) * 60_000 + 60_000;
286
+ for (let at = start; at < start + 32 * 24 * 60 * 60_000; at += 60_000) {
287
+ const local = localParts(new Date(at), job.schedule.timezone);
288
+ if (matches(cron, local))
289
+ return `${local.key}@${job.schedule.timezone}`;
290
+ }
291
+ return null;
292
+ }
293
+ #intervalDue(job) {
294
+ const schedule = job.schedule;
295
+ if (!('kind' in schedule))
296
+ throw new Error('Interval schedule required');
297
+ const intervalMs = schedule.every_minutes * 60_000;
298
+ const anchorMs = Date.parse(schedule.anchor_at);
299
+ const last = this.db.prepare('SELECT * FROM job_windows WHERE job_id=? ORDER BY scheduled_ms DESC,rowid DESC LIMIT 1')
300
+ .get(job.id);
301
+ if (!last)
302
+ return anchorMs + intervalMs;
303
+ const window = rowSchema.parse(last);
304
+ // A pending, failed, or unknown window is a one-owner hold. A definition
305
+ // swap can supersede an unstarted window and re-anchor from that decision.
306
+ if (window.state === 'completed')
307
+ return window.updated_ms + intervalMs;
308
+ if (window.state === 'superseded')
309
+ return Math.max(anchorMs, window.updated_ms) + intervalMs;
310
+ return null;
311
+ }
312
+ /**
313
+ * `now` selects which windows are due. Ownership checks, claims, receipts and
314
+ * retry backoff use a fresh instant on the same timeline. `signal` aborts
315
+ * running handlers; `stopping` only prevents new claims.
316
+ */
317
+ async tick(now = new Date(), signal = AbortSignal.timeout(50_000), stopping) {
318
+ if (this.#busy)
319
+ return { scanned: 0, claimed: 0, completed: 0, retry: 0, unknown: 0 };
320
+ this.#busy = true;
321
+ const clockStart = this.clock();
322
+ const current = () => new Date(now.getTime() + Math.max(0, this.clock() - clockStart));
323
+ const stopClaims = stopping ? AbortSignal.any([signal, stopping]) : signal;
324
+ const tickMarker = this.#tickMarker();
325
+ let marker;
326
+ try {
327
+ // The release helper holds .release.lock while it waits for this marker to
328
+ // disappear. Creating our marker before checking its lock closes the race
329
+ // between preflight and a definition swap.
330
+ marker = await open(tickMarker, 'wx', 0o600).catch((error) => {
331
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')
332
+ throw new Error('Job tick marker already exists; inspect and reconcile before proceeding');
333
+ throw error;
334
+ });
335
+ await marker.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
336
+ const { definitions, sha256: definitionSha256 } = await this.load();
337
+ if (this.#releaseLocked())
338
+ throw new Error('Job-definition release lock is held');
339
+ if (this.#admissionFenced())
340
+ throw new Error('Profile admission is fenced');
341
+ let scanned = 0;
342
+ let claimed = 0;
343
+ let completed = 0;
344
+ let retry = 0;
345
+ let unknown = 0;
346
+ for (const job of definitions.jobs) {
347
+ if (!job.enabled || job.owner !== 'ardent')
348
+ continue;
349
+ const nowMinute = Math.floor(now.getTime() / 60_000) * 60_000;
350
+ const candidates = [];
351
+ if ('kind' in job.schedule) {
352
+ scanned++;
353
+ const due = this.#intervalDue(job);
354
+ if (due !== null && due <= now.getTime()) {
355
+ const local = new Date(due).toISOString();
356
+ candidates.push({ key: `${job.id}:interval:${due}`, local, scheduled: due });
357
+ }
358
+ }
359
+ else {
360
+ const cron = parseCron(job.schedule.cron);
361
+ const previous = this.db.prepare('SELECT scanned_ms FROM job_scan WHERE job_id=?').get(job.id);
362
+ const priorMs = previous ? z.object({ scanned_ms: z.number().int() }).parse(previous).scanned_ms : nowMinute - 60_000;
363
+ const horizonMs = job.schedule.catch_up_minutes * 60_000;
364
+ const start = job.schedule.missed_window === 'skip' ? nowMinute - 60_000 : Math.max(priorMs, nowMinute - horizonMs - 60_000);
365
+ for (let minuteMs = Math.floor(start / 60_000) * 60_000 + 60_000; minuteMs <= nowMinute; minuteMs += 60_000) {
366
+ const local = localParts(new Date(minuteMs), job.schedule.timezone);
367
+ scanned++;
368
+ if (matches(cron, local))
369
+ candidates.push({ key: `${job.id}:${local.key}@${job.schedule.timezone}`, local: local.key, scheduled: minuteMs });
370
+ }
371
+ }
372
+ if (candidates.length)
373
+ await this.ownershipCheck(job, current());
374
+ if (this.#releaseLocked())
375
+ throw new Error('Job-definition release lock is held');
376
+ if (this.#admissionFenced())
377
+ throw new Error('Profile admission is fenced');
378
+ this.db.transaction(() => {
379
+ for (const item of candidates) {
380
+ const result = this.db.prepare(`INSERT OR IGNORE INTO job_windows
381
+ (window_key,job_id,local_key,scheduled_ms,definition_revision,definition_sha256,state,attempts,next_attempt_ms,receipt_json,receipt_sha256,error,updated_ms)
382
+ VALUES (?,?,?,?,?,?,'queued',0,?,NULL,NULL,NULL,?)`).run(item.key, job.id, item.local, item.scheduled, definitions.revision, definitionSha256, now.getTime(), now.getTime());
383
+ claimed += result.changes;
384
+ }
385
+ this.db.prepare(`INSERT INTO job_scan(job_id,scanned_ms,updated_ms,definition_revision,definition_sha256)
386
+ VALUES (?,?,?,?,?) ON CONFLICT(job_id) DO UPDATE SET scanned_ms=excluded.scanned_ms,
387
+ updated_ms=excluded.updated_ms,definition_revision=excluded.definition_revision,
388
+ definition_sha256=excluded.definition_sha256`)
389
+ .run(job.id, nowMinute, now.getTime(), definitions.revision, definitionSha256);
390
+ })();
391
+ const due = this.db.prepare("SELECT * FROM job_windows WHERE job_id=? AND state IN ('queued','retry') AND next_attempt_ms<=? ORDER BY scheduled_ms LIMIT 100")
392
+ .all(job.id, now.getTime()).map(row => rowSchema.parse(row));
393
+ for (const window of due) {
394
+ if (stopClaims.aborted)
395
+ break;
396
+ if (window.definition_sha256 !== definitionSha256) {
397
+ this.db.prepare("UPDATE job_windows SET state='superseded',error='active definition changed before execution',updated_ms=? WHERE window_key=? AND state IN ('queued','retry')")
398
+ .run(current().getTime(), window.window_key);
399
+ continue;
400
+ }
401
+ await this.ownershipCheck(job, current());
402
+ if (this.#releaseLocked())
403
+ throw new Error('Job-definition release lock is held');
404
+ if (this.#admissionFenced())
405
+ throw new Error('Profile admission is fenced');
406
+ if (stopClaims.aborted)
407
+ break;
408
+ const claimedAt = current();
409
+ const update = this.db.prepare("UPDATE job_windows SET state='running',attempts=attempts+1,updated_ms=? WHERE window_key=? AND state IN ('queued','retry')")
410
+ .run(claimedAt.getTime(), window.window_key);
411
+ if (!update.changes)
412
+ continue;
413
+ const attempt = window.attempts + 1;
414
+ const result = await this.#run(job, window, attempt, claimedAt, signal, stopClaims);
415
+ const finishedMs = current().getTime();
416
+ // Interval cadence is measured from a completed window's updated_ms;
417
+ // anchor it at the claim so a handler's run time does not drift the
418
+ // schedule. Failed, unknown and retry rows record when they ended.
419
+ const settledMs = result.state === 'completed' ? claimedAt.getTime() : finishedMs;
420
+ const receiptJson = encode(result.receipt);
421
+ const receiptSha = sha256(receiptJson);
422
+ const canRetry = result.state === 'retry' && result.receipt.effect_status === 'none' && attempt < job.policy.max_attempts;
423
+ const state = result.state === 'retry' ? canRetry ? 'retry' : result.receipt.effect_status === 'none' ? 'failed' : 'unknown' : result.state;
424
+ const delay = job.policy.retry_backoff_seconds * 1000 * Math.pow(2, attempt - 1);
425
+ this.db.transaction(() => {
426
+ this.db.prepare('INSERT INTO job_attempts(window_key,attempt,state,receipt_json,receipt_sha256,recorded_ms) VALUES (?,?,?,?,?,?)')
427
+ .run(window.window_key, attempt, state, receiptJson, receiptSha, finishedMs);
428
+ this.db.prepare('UPDATE job_windows SET state=?,next_attempt_ms=?,receipt_json=?,receipt_sha256=?,error=?,updated_ms=? WHERE window_key=? AND state=\'running\'')
429
+ .run(state, canRetry ? finishedMs + delay : settledMs, receiptJson, receiptSha, state === 'failed' ? 'handler failed or retry budget exhausted' : state === 'unknown' ? 'effect outcome unknown' : null, settledMs, window.window_key);
430
+ })();
431
+ if (state === 'completed')
432
+ completed++;
433
+ else if (state === 'retry')
434
+ retry++;
435
+ else if (state === 'unknown')
436
+ unknown++;
437
+ }
438
+ }
439
+ return { scanned, claimed, completed, retry, unknown };
440
+ }
441
+ finally {
442
+ // A marker cleanup failure must not wedge the scheduler; the marker
443
+ // itself still fails closed on the next tick or restart.
444
+ try {
445
+ if (marker) {
446
+ try {
447
+ await marker.close();
448
+ }
449
+ finally {
450
+ await unlink(tickMarker);
451
+ }
452
+ }
453
+ }
454
+ finally {
455
+ this.#busy = false;
456
+ }
457
+ }
458
+ }
459
+ #admissionFenced() {
460
+ try {
461
+ return Boolean(this.db.prepare('SELECT 1 FROM admission_fence WHERE singleton=1').get());
462
+ }
463
+ catch {
464
+ return true;
465
+ }
466
+ }
467
+ #releaseLocked() { return existsSync(join(this.profileDir, 'state', 'job-definition-releases', '.release.lock')); }
468
+ #tickMarker() { return join(this.profileDir, 'state', 'job-definition-releases', '.tick.lock'); }
469
+ async #run(job, window, attempt, now, signal, stopping) {
470
+ const handler = this.registry[job.handler];
471
+ if (!handler)
472
+ return { state: 'unknown', receipt: { effect_status: 'unknown', evidence: 'handler disappeared' } };
473
+ try {
474
+ if (signal.aborted)
475
+ throw new Error('Job handler aborted');
476
+ const invocation = handler.run({ job, input: handler.input.parse(job.input), window_key: window.window_key,
477
+ scheduled_at: new Date(window.scheduled_ms), now, attempt, signal, stopping });
478
+ const result = await new Promise((resolve, reject) => {
479
+ const aborted = () => reject(new Error('Job handler aborted'));
480
+ signal.addEventListener('abort', aborted, { once: true });
481
+ void invocation.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted)).catch(() => { });
482
+ });
483
+ if (!['completed', 'retry', 'failed', 'unknown'].includes(result.state) ||
484
+ !['none', 'confirmed', 'unknown'].includes(result.receipt.effect_status))
485
+ throw new Error('Invalid handler receipt');
486
+ if (result.state === 'retry' && result.receipt.effect_status !== 'none')
487
+ return { state: 'unknown', receipt: { effect_status: 'unknown', evidence: 'retry requested without no-effect proof' } };
488
+ return result;
489
+ }
490
+ catch (error) {
491
+ const ambiguous = signal.aborted || handler.effect === 'external';
492
+ return { state: ambiguous ? 'unknown' : 'retry',
493
+ receipt: { effect_status: ambiguous ? 'unknown' : 'none',
494
+ evidence: error instanceof Error ? error.message.slice(0, 500) : 'handler error' } };
495
+ }
496
+ }
497
+ }
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import type { ArdentRuntime } from './runtime.js';
3
+ import type { JobHandler, JobRegistry } from './job-engine.js';
4
+ export declare const BoundedNewsInput: z.ZodObject<{
5
+ query: z.ZodString;
6
+ category: z.ZodDefault<z.ZodEnum<{
7
+ news: "news";
8
+ general: "general";
9
+ }>>;
10
+ time_range: z.ZodDefault<z.ZodEnum<{
11
+ day: "day";
12
+ week: "week";
13
+ month: "month";
14
+ }>>;
15
+ language: z.ZodDefault<z.ZodString>;
16
+ limit: z.ZodDefault<z.ZodNumber>;
17
+ max_articles: z.ZodDefault<z.ZodNumber>;
18
+ purpose: z.ZodDefault<z.ZodString>;
19
+ retry_empty: z.ZodDefault<z.ZodBoolean>;
20
+ fallback_searches: z.ZodOptional<z.ZodArray<z.ZodObject<{
21
+ query: z.ZodString;
22
+ category: z.ZodEnum<{
23
+ news: "news";
24
+ general: "general";
25
+ }>;
26
+ }, z.core.$strict>>>;
27
+ discovery_sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
28
+ id: z.ZodString;
29
+ query: z.ZodString;
30
+ category: z.ZodDefault<z.ZodEnum<{
31
+ news: "news";
32
+ general: "general";
33
+ }>>;
34
+ time_range: z.ZodDefault<z.ZodEnum<{
35
+ day: "day";
36
+ week: "week";
37
+ month: "month";
38
+ year: "year";
39
+ all: "all";
40
+ }>>;
41
+ language: z.ZodDefault<z.ZodString>;
42
+ limit: z.ZodNumber;
43
+ max_articles: z.ZodNumber;
44
+ allow_hosts: z.ZodDefault<z.ZodArray<z.ZodString>>;
45
+ include_title_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
46
+ }, z.core.$strict>>>;
47
+ publisher_hosts: z.ZodDefault<z.ZodArray<z.ZodString>>;
48
+ }, z.core.$strict>;
49
+ /** Bounded first-party retrieval. A packet is source evidence, never claim verification or delivery. */
50
+ export declare function boundedNewsHandler(runtime: ArdentRuntime, env: NodeJS.ProcessEnv): JobHandler;
51
+ /** Built-in scheduled job handlers available to every profile. */
52
+ export declare function builtinJobHandlers(runtime: ArdentRuntime, env: NodeJS.ProcessEnv): JobRegistry;
53
+ /** Validation-only registry (no runtime) for `ardent jobs definitions validate`. */
54
+ export declare const BUILTIN_JOB_VALIDATION_REGISTRY: JobRegistry;