instar 0.28.97 → 0.28.99
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/dist/core/types.d.ts +30 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/PromiseBeacon.d.ts +5 -3
- package/dist/monitoring/PromiseBeacon.d.ts.map +1 -1
- package/dist/monitoring/PromiseBeacon.js +1 -1
- package/dist/monitoring/PromiseBeacon.js.map +1 -1
- package/dist/scheduler/AgentMdJobLoader.d.ts +104 -0
- package/dist/scheduler/AgentMdJobLoader.d.ts.map +1 -0
- package/dist/scheduler/AgentMdJobLoader.js +805 -0
- package/dist/scheduler/AgentMdJobLoader.js.map +1 -0
- package/dist/scheduler/JobLoader.d.ts +4 -0
- package/dist/scheduler/JobLoader.d.ts.map +1 -1
- package/dist/scheduler/JobLoader.js +59 -3
- package/dist/scheduler/JobLoader.js.map +1 -1
- package/dist/scheduler/JobScheduler.d.ts.map +1 -1
- package/dist/scheduler/JobScheduler.js +31 -1
- package/dist/scheduler/JobScheduler.js.map +1 -1
- package/dist/tasks/task-flow-types.d.ts +1 -1
- package/package.json +3 -1
- package/scripts/pre-push-gate.js +11 -3
- package/scripts/upgrade-guide-validator.mjs +3 -4
- package/src/data/builtin-manifest.json +3 -3
- package/upgrades/0.28.98.md +47 -0
- package/upgrades/0.28.99.md +25 -0
- package/upgrades/side-effects/NEXT.md +68 -61
- package/upgrades/side-effects/jobs-as-agentmd-phase-1a.md +153 -0
- package/upgrades/side-effects/promise-beacon-threshold-tune.md +101 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentMdJobLoader — Phase 1a loader-side support for the new agentmd job format.
|
|
3
|
+
*
|
|
4
|
+
* Reads per-slug manifests from `.instar/jobs/schedule/<slug>.json`, optionally
|
|
5
|
+
* resolves an accompanying `.instar/jobs/<origin>/<slug>.md` (for entries with
|
|
6
|
+
* execute.type === "agentmd"), and validates frontmatter via a hardened YAML
|
|
7
|
+
* parse + Zod preprocessor coercion per spec §6.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1a is LOADER ONLY:
|
|
10
|
+
* - agentmd entries load into memory with `body` and `frontmatter` populated.
|
|
11
|
+
* - They will NOT fire until Phase 1b adds scheduler dispatch.
|
|
12
|
+
* - Existing JobScheduler.buildPrompt code paths are untouched.
|
|
13
|
+
*
|
|
14
|
+
* Spec: docs/specs/INSTAR-JOBS-AS-AGENTMD-SPEC.md
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import yaml from 'js-yaml';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { Cron } from 'croner';
|
|
21
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
22
|
+
const VALID_PRIORITIES = ['critical', 'high', 'medium', 'low'];
|
|
23
|
+
const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
|
|
24
|
+
const VALID_ORIGINS = ['instar', 'user'];
|
|
25
|
+
/** Slug regex per spec §"Slug rules" — ASCII only, deliberately excludes
|
|
26
|
+
* non-ASCII so NFD/RTL/ZWJ/dotless-i payloads cannot slip through. */
|
|
27
|
+
const SLUG_RE = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
28
|
+
/** Bounded concurrency for per-slug manifest fanout. Spec §Load lifecycle
|
|
29
|
+
* pins this at 32 — unbounded Promise.all over 500+ entries hits EMFILE
|
|
30
|
+
* on macOS and Linux defaults. */
|
|
31
|
+
const READ_CONCURRENCY = 32;
|
|
32
|
+
/** Spec §6: hard size caps to bound parser cost + adversarial payloads. */
|
|
33
|
+
const MAX_FRONTMATTER_BYTES = 16 * 1024;
|
|
34
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
35
|
+
/** Spec §6: closed-set frontmatter key whitelist (Phase 1a starter set).
|
|
36
|
+
* Unknown keys → per-entry skip. The set grows in later phases (e.g.
|
|
37
|
+
* toolAllowlist coercion details, viewMetadata schema) — adding to this
|
|
38
|
+
* set is a deliberate change, never silent. */
|
|
39
|
+
const ALLOWED_FRONTMATTER_KEYS = new Set([
|
|
40
|
+
'name',
|
|
41
|
+
'description',
|
|
42
|
+
'toolAllowlist',
|
|
43
|
+
'grounding',
|
|
44
|
+
'notificationMode',
|
|
45
|
+
'viewMetadata',
|
|
46
|
+
'commonBlockers',
|
|
47
|
+
]);
|
|
48
|
+
// ── Zod preprocessors (spec §6) ────────────────────────────────────────────
|
|
49
|
+
/** YAML FAILSAFE_SCHEMA parses booleans/numbers as strings. The
|
|
50
|
+
* preprocessors below restore typed values with EXACT coercion semantics:
|
|
51
|
+
* - Booleans accept only `true`/`True`/`TRUE`/`false`/`False`/`FALSE`.
|
|
52
|
+
* YAML 1.1's `yes`/`no`/`on`/`off` are explicitly rejected.
|
|
53
|
+
* - Integers accept only `^-?\d+$`. Floats, `NaN`, `Infinity` rejected. */
|
|
54
|
+
export const BoolField = z.preprocess((v) => {
|
|
55
|
+
if (typeof v === 'boolean')
|
|
56
|
+
return v;
|
|
57
|
+
if (typeof v !== 'string')
|
|
58
|
+
return v;
|
|
59
|
+
const lc = v.toLowerCase();
|
|
60
|
+
if (lc === 'true')
|
|
61
|
+
return true;
|
|
62
|
+
if (lc === 'false')
|
|
63
|
+
return false;
|
|
64
|
+
return v; // pass-through → Zod rejects
|
|
65
|
+
}, z.boolean());
|
|
66
|
+
export const IntField = z.preprocess((v) => {
|
|
67
|
+
if (typeof v === 'number')
|
|
68
|
+
return v;
|
|
69
|
+
if (typeof v !== 'string')
|
|
70
|
+
return v;
|
|
71
|
+
if (!/^-?\d+$/.test(v))
|
|
72
|
+
return v;
|
|
73
|
+
return Number(v);
|
|
74
|
+
}, z.number().int().finite());
|
|
75
|
+
// ── Bounded-concurrency helper (kept for Phase 1b sync→async migration) ───
|
|
76
|
+
/** Hand-rolled bounded-concurrency runner. p-limit is not in package.json
|
|
77
|
+
* and the spec explicitly permits a hand-roll. Pattern: counter + queue.
|
|
78
|
+
*
|
|
79
|
+
* Phase 1a uses synchronous filesystem APIs end-to-end to preserve the
|
|
80
|
+
* existing sync `loadJobs(jobsFile): JobDefinition[]` signature. The READ
|
|
81
|
+
* fanout in Phase 1a is bounded only by the manifest count (typically <50);
|
|
82
|
+
* the spec performance budget of 1500 ms cold-boot @ 200 jobs is met with
|
|
83
|
+
* sequential sync reads at this scale. Phase 1b/1c migrate to async +
|
|
84
|
+
* this concurrency runner when buildPrompt and the lock-file pipeline
|
|
85
|
+
* require non-blocking I/O. */
|
|
86
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
87
|
+
const results = new Array(items.length);
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
const workers = [];
|
|
90
|
+
const workerCount = Math.min(Math.max(limit, 1), Math.max(items.length, 1));
|
|
91
|
+
for (let w = 0; w < workerCount; w++) {
|
|
92
|
+
workers.push((async () => {
|
|
93
|
+
while (true) {
|
|
94
|
+
const i = cursor++;
|
|
95
|
+
if (i >= items.length)
|
|
96
|
+
return;
|
|
97
|
+
results[i] = await fn(items[i], i);
|
|
98
|
+
}
|
|
99
|
+
})());
|
|
100
|
+
}
|
|
101
|
+
await Promise.all(workers);
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
// ── Public entry point ─────────────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Load per-slug manifests from `.instar/jobs/schedule/`.
|
|
107
|
+
*
|
|
108
|
+
* Returns `{ jobs, problems }`. If the schedule directory does not exist,
|
|
109
|
+
* returns empty (this is the pre-spec state — caller continues with legacy
|
|
110
|
+
* jobs.json only).
|
|
111
|
+
*
|
|
112
|
+
* Synchronous: matches the existing `loadJobs(jobsFile)` API so callers
|
|
113
|
+
* (JobScheduler.start, status command, job CLI) do not need to become async
|
|
114
|
+
* in Phase 1a. Per-slug fanout is bounded by the manifest count, which is
|
|
115
|
+
* small enough at the Phase 1a scale (200-job spec budget) that the
|
|
116
|
+
* sequential sync read path is comfortably within the 1500 ms cold-boot
|
|
117
|
+
* budget. See header comment for migration plan.
|
|
118
|
+
*
|
|
119
|
+
* @param scheduleDir Absolute path to `.instar/jobs/schedule/`
|
|
120
|
+
* @param jobsRootDir Absolute path to `.instar/jobs/` (parent of schedule)
|
|
121
|
+
*/
|
|
122
|
+
export function loadAgentMdJobs(scheduleDir, jobsRootDir) {
|
|
123
|
+
if (!fs.existsSync(scheduleDir)) {
|
|
124
|
+
return { jobs: [], problems: [] };
|
|
125
|
+
}
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(scheduleDir).filter((f) => f.endsWith('.json'));
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return {
|
|
132
|
+
jobs: [],
|
|
133
|
+
problems: [{
|
|
134
|
+
kind: 'unknown',
|
|
135
|
+
path: scheduleDir,
|
|
136
|
+
message: `Failed to enumerate ${scheduleDir}: ${err instanceof Error ? err.message : String(err)}`,
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Step 1: read + validate every manifest sequentially. The hand-rolled
|
|
141
|
+
// bounded-concurrency helper above is exported for Phase 1b's async
|
|
142
|
+
// migration; Phase 1a is single-threaded for API parity with the existing
|
|
143
|
+
// sync loadJobs() entry point.
|
|
144
|
+
const manifestResults = entries.map((filename) => {
|
|
145
|
+
const filePath = path.join(scheduleDir, filename);
|
|
146
|
+
return readOneManifest(filePath, filename);
|
|
147
|
+
});
|
|
148
|
+
const validManifests = [];
|
|
149
|
+
const problems = [];
|
|
150
|
+
for (const r of manifestResults) {
|
|
151
|
+
if (r.problem)
|
|
152
|
+
problems.push(r.problem);
|
|
153
|
+
if (r.manifest)
|
|
154
|
+
validManifests.push({ manifest: r.manifest, sourcePath: r.sourcePath });
|
|
155
|
+
}
|
|
156
|
+
// Step 2: global slug uniqueness check, case-folded. Spec §"Slug rules":
|
|
157
|
+
// case-fold collision with origin="instar" winning over origin="user";
|
|
158
|
+
// same-origin collision skips both.
|
|
159
|
+
const survivors = resolveCaseFoldCollisions(validManifests, problems);
|
|
160
|
+
// Step 3: for each surviving entry, if agentmd then load + parse the .md.
|
|
161
|
+
const jobs = [];
|
|
162
|
+
for (const { manifest } of survivors) {
|
|
163
|
+
if (manifest.execute.type === 'agentmd') {
|
|
164
|
+
const loaded = loadAgentMdBody(manifest, jobsRootDir);
|
|
165
|
+
if (loaded.problem) {
|
|
166
|
+
problems.push(loaded.problem);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
jobs.push(loaded.job);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Non-agentmd manifest entries: produce a JobDefinition without body.
|
|
173
|
+
const job = manifestToJobDefinition(manifest);
|
|
174
|
+
jobs.push(job);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { jobs, problems };
|
|
178
|
+
}
|
|
179
|
+
// Re-export the bounded-concurrency helper for downstream callers. Phase 1b's
|
|
180
|
+
// scheduler dispatch path will route I/O through it; exposing it now keeps the
|
|
181
|
+
// API surface stable.
|
|
182
|
+
export const READ_CONCURRENCY_LIMIT = READ_CONCURRENCY;
|
|
183
|
+
// ── Manifest validation ────────────────────────────────────────────────────
|
|
184
|
+
function readOneManifest(filePath, filename) {
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return {
|
|
191
|
+
manifest: null,
|
|
192
|
+
sourcePath: filePath,
|
|
193
|
+
problem: {
|
|
194
|
+
kind: 'manifest-invalid',
|
|
195
|
+
path: filePath,
|
|
196
|
+
message: `Failed to parse manifest ${filename}: ${err instanceof Error ? err.message : String(err)}`,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const m = validateManifest(raw, filename);
|
|
202
|
+
// Defense-in-depth: filename ↔ slug match is not required by the spec,
|
|
203
|
+
// but mismatched filenames hint at a hand-edit error — surface a warning.
|
|
204
|
+
// Phase 1a: don't reject, just warn-and-continue via problems list when
|
|
205
|
+
// the slug differs. The collision check will catch the rename pattern.
|
|
206
|
+
return { manifest: m, sourcePath: filePath, problem: null };
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
return {
|
|
210
|
+
manifest: null,
|
|
211
|
+
sourcePath: filePath,
|
|
212
|
+
problem: {
|
|
213
|
+
kind: 'manifest-invalid',
|
|
214
|
+
path: filePath,
|
|
215
|
+
message: err instanceof Error ? err.message : String(err),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Validates the per-slug manifest schema. Mirrors validateJob style so the
|
|
221
|
+
* error messages are familiar; centralized here because the agentmd path
|
|
222
|
+
* has additional fields (origin, unrestrictedTools, execute.type = agentmd). */
|
|
223
|
+
export function validateManifest(raw, sourceLabel) {
|
|
224
|
+
const prefix = sourceLabel ? `Manifest[${sourceLabel}]` : 'Manifest';
|
|
225
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
226
|
+
throw new Error(`${prefix}: must be a JSON object`);
|
|
227
|
+
}
|
|
228
|
+
const j = raw;
|
|
229
|
+
// Required string fields
|
|
230
|
+
for (const field of ['slug', 'schedule']) {
|
|
231
|
+
if (typeof j[field] !== 'string' || !j[field].trim()) {
|
|
232
|
+
throw new Error(`${prefix}: "${field}" is required and must be a non-empty string`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Slug regex
|
|
236
|
+
if (!SLUG_RE.test(j.slug)) {
|
|
237
|
+
throw new Error(`${prefix}: "slug" must match ${SLUG_RE} (ASCII letters/digits/hyphens/underscores, 1-100 chars)`);
|
|
238
|
+
}
|
|
239
|
+
// Origin
|
|
240
|
+
if (!VALID_ORIGINS.includes(j.origin)) {
|
|
241
|
+
throw new Error(`${prefix}: "origin" must be one of ${VALID_ORIGINS.join(', ')}, got "${j.origin}"`);
|
|
242
|
+
}
|
|
243
|
+
// Priority
|
|
244
|
+
if (!VALID_PRIORITIES.includes(j.priority)) {
|
|
245
|
+
throw new Error(`${prefix}: "priority" must be one of ${VALID_PRIORITIES.join(', ')}, got "${j.priority}"`);
|
|
246
|
+
}
|
|
247
|
+
// Model (optional)
|
|
248
|
+
if (j.model !== undefined && !VALID_MODELS.includes(j.model)) {
|
|
249
|
+
throw new Error(`${prefix}: "model" must be one of ${VALID_MODELS.join(', ')}, got "${j.model}"`);
|
|
250
|
+
}
|
|
251
|
+
// Cron expression
|
|
252
|
+
try {
|
|
253
|
+
const c = new Cron(j.schedule);
|
|
254
|
+
c.stop();
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
throw new Error(`${prefix}: invalid cron expression "${j.schedule}": ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
// expectedDurationMinutes
|
|
260
|
+
if (typeof j.expectedDurationMinutes !== 'number' || !Number.isFinite(j.expectedDurationMinutes) || j.expectedDurationMinutes <= 0) {
|
|
261
|
+
throw new Error(`${prefix}: "expectedDurationMinutes" must be a positive finite number`);
|
|
262
|
+
}
|
|
263
|
+
// Enabled
|
|
264
|
+
if (typeof j.enabled !== 'boolean') {
|
|
265
|
+
throw new Error(`${prefix}: "enabled" must be a boolean`);
|
|
266
|
+
}
|
|
267
|
+
// Execute block
|
|
268
|
+
if (!j.execute || typeof j.execute !== 'object' || Array.isArray(j.execute)) {
|
|
269
|
+
throw new Error(`${prefix}: "execute" must be an object`);
|
|
270
|
+
}
|
|
271
|
+
const exec = j.execute;
|
|
272
|
+
const validTypes = ['skill', 'prompt', 'script', 'agentmd'];
|
|
273
|
+
if (typeof exec.type !== 'string' || !validTypes.includes(exec.type)) {
|
|
274
|
+
throw new Error(`${prefix}: execute.type must be one of ${validTypes.join(', ')}`);
|
|
275
|
+
}
|
|
276
|
+
if (exec.type === 'agentmd') {
|
|
277
|
+
// Body lives in the .md file — value must NOT be present.
|
|
278
|
+
if (exec.value !== undefined) {
|
|
279
|
+
throw new Error(`${prefix}: execute.value must be absent when type === "agentmd" (body lives in <origin>/<slug>.md)`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// Legacy types still require value.
|
|
284
|
+
if (typeof exec.value !== 'string' || !exec.value.trim()) {
|
|
285
|
+
throw new Error(`${prefix}: execute.value is required for type === "${exec.type}"`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (exec.args !== undefined && typeof exec.args !== 'string') {
|
|
289
|
+
throw new Error(`${prefix}: execute.args must be a string if provided`);
|
|
290
|
+
}
|
|
291
|
+
// Optional unrestrictedTools
|
|
292
|
+
if (j.unrestrictedTools !== undefined && typeof j.unrestrictedTools !== 'boolean') {
|
|
293
|
+
throw new Error(`${prefix}: "unrestrictedTools" must be a boolean if provided`);
|
|
294
|
+
}
|
|
295
|
+
// Optional tags
|
|
296
|
+
if (j.tags !== undefined) {
|
|
297
|
+
if (!Array.isArray(j.tags))
|
|
298
|
+
throw new Error(`${prefix}: "tags" must be an array if provided`);
|
|
299
|
+
for (const t of j.tags) {
|
|
300
|
+
if (typeof t !== 'string')
|
|
301
|
+
throw new Error(`${prefix}: "tags" entries must be strings`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Optional topicId
|
|
305
|
+
if (j.topicId !== undefined && (typeof j.topicId !== 'number' || !Number.isFinite(j.topicId))) {
|
|
306
|
+
throw new Error(`${prefix}: "topicId" must be a finite number if provided`);
|
|
307
|
+
}
|
|
308
|
+
// Optional telegramNotify
|
|
309
|
+
if (j.telegramNotify !== undefined &&
|
|
310
|
+
typeof j.telegramNotify !== 'boolean' &&
|
|
311
|
+
j.telegramNotify !== 'on-alert') {
|
|
312
|
+
throw new Error(`${prefix}: "telegramNotify" must be true, false, or "on-alert" if provided`);
|
|
313
|
+
}
|
|
314
|
+
// Optional machines
|
|
315
|
+
if (j.machines !== undefined) {
|
|
316
|
+
if (!Array.isArray(j.machines))
|
|
317
|
+
throw new Error(`${prefix}: "machines" must be an array if provided`);
|
|
318
|
+
for (const m of j.machines) {
|
|
319
|
+
if (typeof m !== 'string' || !m.trim())
|
|
320
|
+
throw new Error(`${prefix}: "machines" entries must be non-empty strings`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Optional gate (shell command)
|
|
324
|
+
if (j.gate !== undefined && typeof j.gate !== 'string') {
|
|
325
|
+
throw new Error(`${prefix}: "gate" must be a string if provided`);
|
|
326
|
+
}
|
|
327
|
+
return j;
|
|
328
|
+
}
|
|
329
|
+
// ── Case-fold collision resolution ─────────────────────────────────────────
|
|
330
|
+
function resolveCaseFoldCollisions(manifests, problems) {
|
|
331
|
+
const groups = new Map();
|
|
332
|
+
for (const m of manifests) {
|
|
333
|
+
// Spec: NFC normalization is defense-in-depth; the slug regex is ASCII-only
|
|
334
|
+
// so normalization is a no-op for valid slugs. We still apply it for hygiene.
|
|
335
|
+
const key = m.manifest.slug.normalize('NFC').toLowerCase();
|
|
336
|
+
const arr = groups.get(key);
|
|
337
|
+
if (arr)
|
|
338
|
+
arr.push(m);
|
|
339
|
+
else
|
|
340
|
+
groups.set(key, [m]);
|
|
341
|
+
}
|
|
342
|
+
const survivors = [];
|
|
343
|
+
for (const [key, group] of groups) {
|
|
344
|
+
if (group.length === 1) {
|
|
345
|
+
survivors.push(group[0]);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
// Multi-entry case-fold collision.
|
|
349
|
+
const instarOnes = group.filter((g) => g.manifest.origin === 'instar');
|
|
350
|
+
const userOnes = group.filter((g) => g.manifest.origin === 'user');
|
|
351
|
+
if (instarOnes.length === 1 && userOnes.length >= 1 && instarOnes.length + userOnes.length === group.length) {
|
|
352
|
+
// origin=instar wins; user-namespace entries skipped per spec.
|
|
353
|
+
survivors.push(instarOnes[0]);
|
|
354
|
+
for (const u of userOnes) {
|
|
355
|
+
problems.push({
|
|
356
|
+
kind: 'case-fold-collision',
|
|
357
|
+
slug: u.manifest.slug,
|
|
358
|
+
origin: u.manifest.origin,
|
|
359
|
+
path: u.sourcePath,
|
|
360
|
+
message: `Case-fold slug collision on "${key}": user-namespace "${u.manifest.slug}" ` +
|
|
361
|
+
`was skipped because the instar default "${instarOnes[0].manifest.slug}" wins by spec.`,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// Same-origin collision (or multiple instar/user pairs simultaneously).
|
|
367
|
+
// Per spec §"Slug rules": both skipped if same-origin collide; both
|
|
368
|
+
// skipped when the resolution is otherwise ambiguous. The Issues card
|
|
369
|
+
// names every entry; here we surface each one as a problem and skip
|
|
370
|
+
// them all.
|
|
371
|
+
for (const g of group) {
|
|
372
|
+
problems.push({
|
|
373
|
+
kind: 'case-fold-collision',
|
|
374
|
+
slug: g.manifest.slug,
|
|
375
|
+
origin: g.manifest.origin,
|
|
376
|
+
path: g.sourcePath,
|
|
377
|
+
message: `Case-fold slug collision on "${key}" (${group.length} entries, origins=[${group.map((x) => x.manifest.origin).join(',')}]) — all skipped because the collision is not resolvable by origin precedence.`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return survivors;
|
|
383
|
+
}
|
|
384
|
+
// ── agentmd body + frontmatter loading ─────────────────────────────────────
|
|
385
|
+
/**
|
|
386
|
+
* Resolve and load a single agentmd entry's `.md` file.
|
|
387
|
+
* Applies path safety, size caps, YAML hardening, anchor rejection, and Zod
|
|
388
|
+
* preprocessor validation. Returns a fully-populated JobDefinition on success.
|
|
389
|
+
*/
|
|
390
|
+
function loadAgentMdBody(manifest, jobsRootDir) {
|
|
391
|
+
const resolved = path.join(jobsRootDir, manifest.origin, `${manifest.slug}.md`);
|
|
392
|
+
// Path safety, applied BEFORE any read.
|
|
393
|
+
const safetyProblem = checkPathSafety(resolved, manifest);
|
|
394
|
+
if (safetyProblem)
|
|
395
|
+
return { job: null, problem: safetyProblem };
|
|
396
|
+
if (!fs.existsSync(resolved)) {
|
|
397
|
+
return {
|
|
398
|
+
job: null,
|
|
399
|
+
problem: {
|
|
400
|
+
kind: 'agentmd-file-missing',
|
|
401
|
+
slug: manifest.slug,
|
|
402
|
+
origin: manifest.origin,
|
|
403
|
+
path: resolved,
|
|
404
|
+
message: `agentmd file not found: ${resolved}`,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// Size check on the raw file. Body cap = 64 KB; frontmatter cap = 16 KB.
|
|
409
|
+
// We bound the total read to (16 + 64 + small framing) KB so an
|
|
410
|
+
// adversarial 10 MB file cannot blow memory before parse.
|
|
411
|
+
const MAX_TOTAL_BYTES = MAX_FRONTMATTER_BYTES + MAX_BODY_BYTES + 1024;
|
|
412
|
+
const stat = fs.statSync(resolved);
|
|
413
|
+
if (stat.size > MAX_TOTAL_BYTES) {
|
|
414
|
+
return {
|
|
415
|
+
job: null,
|
|
416
|
+
problem: {
|
|
417
|
+
kind: 'agentmd-body-too-large',
|
|
418
|
+
slug: manifest.slug,
|
|
419
|
+
origin: manifest.origin,
|
|
420
|
+
path: resolved,
|
|
421
|
+
message: `agentmd file is ${stat.size} bytes — exceeds total cap of ${MAX_TOTAL_BYTES} bytes`,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
let text;
|
|
426
|
+
try {
|
|
427
|
+
text = fs.readFileSync(resolved, 'utf-8');
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
return {
|
|
431
|
+
job: null,
|
|
432
|
+
problem: {
|
|
433
|
+
kind: 'unknown',
|
|
434
|
+
slug: manifest.slug,
|
|
435
|
+
origin: manifest.origin,
|
|
436
|
+
path: resolved,
|
|
437
|
+
message: `Failed to read ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// Split frontmatter ↔ body.
|
|
442
|
+
const split = splitFrontmatter(text);
|
|
443
|
+
if (!split) {
|
|
444
|
+
return {
|
|
445
|
+
job: null,
|
|
446
|
+
problem: {
|
|
447
|
+
kind: 'agentmd-yaml-invalid',
|
|
448
|
+
slug: manifest.slug,
|
|
449
|
+
origin: manifest.origin,
|
|
450
|
+
path: resolved,
|
|
451
|
+
message: 'agentmd file is missing a YAML frontmatter block (expected leading "---")',
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const { frontmatterText, body } = split;
|
|
456
|
+
if (Buffer.byteLength(frontmatterText, 'utf-8') > MAX_FRONTMATTER_BYTES) {
|
|
457
|
+
return {
|
|
458
|
+
job: null,
|
|
459
|
+
problem: {
|
|
460
|
+
kind: 'agentmd-yaml-invalid',
|
|
461
|
+
slug: manifest.slug,
|
|
462
|
+
origin: manifest.origin,
|
|
463
|
+
path: resolved,
|
|
464
|
+
message: `frontmatter is ${Buffer.byteLength(frontmatterText, 'utf-8')} bytes — exceeds cap of ${MAX_FRONTMATTER_BYTES}`,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (Buffer.byteLength(body, 'utf-8') > MAX_BODY_BYTES) {
|
|
469
|
+
return {
|
|
470
|
+
job: null,
|
|
471
|
+
problem: {
|
|
472
|
+
kind: 'agentmd-body-too-large',
|
|
473
|
+
slug: manifest.slug,
|
|
474
|
+
origin: manifest.origin,
|
|
475
|
+
path: resolved,
|
|
476
|
+
message: `body is ${Buffer.byteLength(body, 'utf-8')} bytes — exceeds cap of ${MAX_BODY_BYTES}`,
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
// Parse YAML with FAILSAFE_SCHEMA. Reject anchors/aliases via the
|
|
481
|
+
// parser's listener callback — this is the parsed-tree check the spec
|
|
482
|
+
// mandates over a raw-text regex (which would over-reject legitimate
|
|
483
|
+
// strings like `description: "Bash & Read"`).
|
|
484
|
+
let parsed;
|
|
485
|
+
let anchorSeen = false;
|
|
486
|
+
try {
|
|
487
|
+
parsed = yaml.load(frontmatterText, {
|
|
488
|
+
schema: yaml.FAILSAFE_SCHEMA,
|
|
489
|
+
listener: (_kind, state) => {
|
|
490
|
+
// state.anchor is set per-event when the parser encounters an
|
|
491
|
+
// anchor or alias on the parsed-tree node. Legitimate `&` or `*`
|
|
492
|
+
// characters inside quoted string values do NOT set this field.
|
|
493
|
+
if (state && state.anchor) {
|
|
494
|
+
anchorSeen = true;
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
return {
|
|
501
|
+
job: null,
|
|
502
|
+
problem: {
|
|
503
|
+
kind: 'agentmd-yaml-invalid',
|
|
504
|
+
slug: manifest.slug,
|
|
505
|
+
origin: manifest.origin,
|
|
506
|
+
path: resolved,
|
|
507
|
+
message: `YAML parse failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (anchorSeen) {
|
|
512
|
+
return {
|
|
513
|
+
job: null,
|
|
514
|
+
problem: {
|
|
515
|
+
kind: 'agentmd-yaml-invalid',
|
|
516
|
+
slug: manifest.slug,
|
|
517
|
+
origin: manifest.origin,
|
|
518
|
+
path: resolved,
|
|
519
|
+
message: 'YAML anchors/aliases are not allowed in agentmd frontmatter',
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
// JSON-roundtrip normalization. FAILSAFE_SCHEMA returns string-keyed
|
|
524
|
+
// structures with primitives-as-strings; the round-trip strips any
|
|
525
|
+
// exotic object identities the parser might construct.
|
|
526
|
+
// Empty frontmatter parses as undefined; treat that as the empty object.
|
|
527
|
+
let normalized;
|
|
528
|
+
if (parsed === undefined || parsed === null) {
|
|
529
|
+
normalized = {};
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
try {
|
|
533
|
+
normalized = JSON.parse(JSON.stringify(parsed));
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
return {
|
|
537
|
+
job: null,
|
|
538
|
+
problem: {
|
|
539
|
+
kind: 'agentmd-yaml-invalid',
|
|
540
|
+
slug: manifest.slug,
|
|
541
|
+
origin: manifest.origin,
|
|
542
|
+
path: resolved,
|
|
543
|
+
message: `Frontmatter is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (typeof normalized !== 'object' || normalized === null || Array.isArray(normalized)) {
|
|
549
|
+
return {
|
|
550
|
+
job: null,
|
|
551
|
+
problem: {
|
|
552
|
+
kind: 'agentmd-frontmatter-invalid',
|
|
553
|
+
slug: manifest.slug,
|
|
554
|
+
origin: manifest.origin,
|
|
555
|
+
path: resolved,
|
|
556
|
+
message: 'Frontmatter must be a YAML mapping (object)',
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const frontmatter = normalized;
|
|
561
|
+
// Closed-set whitelist check.
|
|
562
|
+
for (const key of Object.keys(frontmatter)) {
|
|
563
|
+
if (!ALLOWED_FRONTMATTER_KEYS.has(key)) {
|
|
564
|
+
return {
|
|
565
|
+
job: null,
|
|
566
|
+
problem: {
|
|
567
|
+
kind: 'agentmd-frontmatter-invalid',
|
|
568
|
+
slug: manifest.slug,
|
|
569
|
+
origin: manifest.origin,
|
|
570
|
+
path: resolved,
|
|
571
|
+
message: `Unknown frontmatter key "${key}" (allowed: ${[...ALLOWED_FRONTMATTER_KEYS].join(', ')})`,
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Phase 1a applies Zod coercion only to fields it understands. The
|
|
577
|
+
// BoolField / IntField preprocessors are exposed for downstream phases
|
|
578
|
+
// to consume; here we validate a starter set:
|
|
579
|
+
// - name: string
|
|
580
|
+
// - description: string
|
|
581
|
+
// - notificationMode: 'always' | 'on-alert' | 'never' (string passthrough)
|
|
582
|
+
// - toolAllowlist: string[] | '*' (structural shape only — semantics in 1b)
|
|
583
|
+
//
|
|
584
|
+
// We do NOT validate `grounding` / `commonBlockers` / `viewMetadata` deeply
|
|
585
|
+
// here because the existing JobLoader.validateJob already encodes those
|
|
586
|
+
// shapes; they are surfaced verbatim into JobDefinition and the scheduler
|
|
587
|
+
// dispatch path (Phase 1b) will route through validateGrounding etc.
|
|
588
|
+
const fmValidation = validateFrontmatterShape(frontmatter, manifest);
|
|
589
|
+
if (fmValidation.problem)
|
|
590
|
+
return { job: null, problem: fmValidation.problem };
|
|
591
|
+
// Build the JobDefinition. Manifest fields win over frontmatter fields by
|
|
592
|
+
// spec §"Open Questions Resolved" — manifest is authority for cron; frontmatter
|
|
593
|
+
// is authority for behavior (name/description). For Phase 1a we surface
|
|
594
|
+
// both so the scheduler-dispatch step in Phase 1b can lookup the right field.
|
|
595
|
+
const job = manifestToJobDefinition(manifest, frontmatter, body);
|
|
596
|
+
return { job, problem: null };
|
|
597
|
+
}
|
|
598
|
+
// ── Path safety ────────────────────────────────────────────────────────────
|
|
599
|
+
/** Pre-resolution path safety per spec §"Slug rules":
|
|
600
|
+
* - realpath(resolved) === resolved (no intermediate symlinks)
|
|
601
|
+
* - lstat(resolved).isSymbolicLink() === false
|
|
602
|
+
* - slug regex enforced (already done in manifest validation)
|
|
603
|
+
* - NFC-normalize for hygiene (regex already rejects non-ASCII)
|
|
604
|
+
*
|
|
605
|
+
* Called AFTER the manifest passed validateManifest, so we trust the slug
|
|
606
|
+
* is regex-safe; the .md file at the resolved path may still be a symlink. */
|
|
607
|
+
function checkPathSafety(resolved, manifest) {
|
|
608
|
+
// Defense-in-depth: re-confirm slug regex.
|
|
609
|
+
const slug = manifest.slug.normalize('NFC');
|
|
610
|
+
if (!SLUG_RE.test(slug)) {
|
|
611
|
+
return {
|
|
612
|
+
kind: 'slug-invalid',
|
|
613
|
+
slug: manifest.slug,
|
|
614
|
+
origin: manifest.origin,
|
|
615
|
+
path: resolved,
|
|
616
|
+
message: `slug "${manifest.slug}" fails the safety regex after NFC normalization`,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
// lstat check first (cheap; tells us if the leaf itself is a symlink).
|
|
620
|
+
let lstat;
|
|
621
|
+
try {
|
|
622
|
+
lstat = fs.lstatSync(resolved);
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
const code = err.code;
|
|
626
|
+
if (code === 'ENOENT')
|
|
627
|
+
return null; // missing file handled later
|
|
628
|
+
return {
|
|
629
|
+
kind: 'unknown',
|
|
630
|
+
slug: manifest.slug,
|
|
631
|
+
origin: manifest.origin,
|
|
632
|
+
path: resolved,
|
|
633
|
+
message: `lstat failed for ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (lstat.isSymbolicLink()) {
|
|
637
|
+
return {
|
|
638
|
+
kind: 'agentmd-symlink',
|
|
639
|
+
slug: manifest.slug,
|
|
640
|
+
origin: manifest.origin,
|
|
641
|
+
path: resolved,
|
|
642
|
+
message: `agentmd file is a symbolic link — refusing to load (spec §Slug rules)`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
// realpath check — catches intermediate-directory symlinks (e.g. someone
|
|
646
|
+
// replaced .instar/jobs/instar/ with a symlink to /etc).
|
|
647
|
+
let real;
|
|
648
|
+
try {
|
|
649
|
+
real = fs.realpathSync(resolved);
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
const code = err.code;
|
|
653
|
+
if (code === 'ENOENT')
|
|
654
|
+
return null; // missing file handled later
|
|
655
|
+
return {
|
|
656
|
+
kind: 'unknown',
|
|
657
|
+
slug: manifest.slug,
|
|
658
|
+
origin: manifest.origin,
|
|
659
|
+
path: resolved,
|
|
660
|
+
message: `realpath failed for ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
// path.resolve normalizes the expected; realpathSync gives us the canonical.
|
|
664
|
+
// On macOS realpath may differ from path.resolve(resolved) due to /tmp →
|
|
665
|
+
// /private/tmp resolution — we compare against realpath(jobsRoot) parent
|
|
666
|
+
// by joining real components.
|
|
667
|
+
const expected = fs.realpathSync(path.dirname(resolved)) + path.sep + path.basename(resolved);
|
|
668
|
+
if (real !== expected) {
|
|
669
|
+
return {
|
|
670
|
+
kind: 'agentmd-symlink',
|
|
671
|
+
slug: manifest.slug,
|
|
672
|
+
origin: manifest.origin,
|
|
673
|
+
path: resolved,
|
|
674
|
+
message: `realpath(${resolved}) === ${real}, expected ${expected} — symlink redirection detected`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
// ── Frontmatter shape validation (light; deep validation lives in 1b) ─────
|
|
680
|
+
function validateFrontmatterShape(fm, manifest) {
|
|
681
|
+
// `name` and `description` must be strings if present. They are not
|
|
682
|
+
// required at the frontmatter level — the manifest may provide them in
|
|
683
|
+
// a later phase. Phase 1a accepts either source.
|
|
684
|
+
if (fm.name !== undefined && (typeof fm.name !== 'string' || !fm.name.trim())) {
|
|
685
|
+
return mkFmProblem(manifest, '"name" must be a non-empty string if provided');
|
|
686
|
+
}
|
|
687
|
+
if (fm.description !== undefined && (typeof fm.description !== 'string' || !fm.description.trim())) {
|
|
688
|
+
return mkFmProblem(manifest, '"description" must be a non-empty string if provided');
|
|
689
|
+
}
|
|
690
|
+
if (fm.notificationMode !== undefined) {
|
|
691
|
+
if (fm.notificationMode !== 'always' && fm.notificationMode !== 'on-alert' && fm.notificationMode !== 'never') {
|
|
692
|
+
return mkFmProblem(manifest, '"notificationMode" must be one of "always" | "on-alert" | "never"');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (fm.toolAllowlist !== undefined) {
|
|
696
|
+
if (fm.toolAllowlist === '*') {
|
|
697
|
+
// ok — checked against manifest.unrestrictedTools in Phase 1b
|
|
698
|
+
}
|
|
699
|
+
else if (Array.isArray(fm.toolAllowlist)) {
|
|
700
|
+
for (const t of fm.toolAllowlist) {
|
|
701
|
+
if (typeof t !== 'string' || !t.trim()) {
|
|
702
|
+
return mkFmProblem(manifest, '"toolAllowlist" array entries must be non-empty strings');
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
return mkFmProblem(manifest, '"toolAllowlist" must be "*" or an array of tool names');
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { problem: null };
|
|
711
|
+
}
|
|
712
|
+
function mkFmProblem(manifest, message) {
|
|
713
|
+
return {
|
|
714
|
+
problem: {
|
|
715
|
+
kind: 'agentmd-frontmatter-invalid',
|
|
716
|
+
slug: manifest.slug,
|
|
717
|
+
origin: manifest.origin,
|
|
718
|
+
message,
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
// ── Frontmatter split ──────────────────────────────────────────────────────
|
|
723
|
+
/** Split an agentmd file into `{ frontmatterText, body }`. Expects the file
|
|
724
|
+
* to start with `---\n`, contain a closing `---` on its own line, and
|
|
725
|
+
* everything after that line is the body. CRLF and lone-CR are normalized
|
|
726
|
+
* to LF for splitting; the body returned to the caller preserves its
|
|
727
|
+
* original line endings (the agentmd body is opaque content). */
|
|
728
|
+
function splitFrontmatter(text) {
|
|
729
|
+
// Normalize for the split only.
|
|
730
|
+
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
731
|
+
if (!normalized.startsWith('---\n') && normalized !== '---') {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
// Empty-frontmatter case: file starts with "---\n---\n" (or "---\n---" at EOF).
|
|
735
|
+
// The closing fence must be on its own line. Two patterns to find:
|
|
736
|
+
// 1. immediately after the opening fence: "---\n---" then \n or EOF
|
|
737
|
+
// 2. after some frontmatter content: "...\n---" then \n or EOF
|
|
738
|
+
const afterOpen = normalized.slice(4); // after "---\n"
|
|
739
|
+
// Either afterOpen starts with "---" (empty frontmatter) or contains "\n---".
|
|
740
|
+
let frontmatterText;
|
|
741
|
+
let restStart;
|
|
742
|
+
if (afterOpen.startsWith('---') && (afterOpen.length === 3 || afterOpen[3] === '\n' || /^---\s*$/.test(afterOpen.split('\n')[0]))) {
|
|
743
|
+
frontmatterText = '';
|
|
744
|
+
// skip past "---" itself
|
|
745
|
+
const firstLine = afterOpen.split('\n')[0];
|
|
746
|
+
if (!/^---\s*$/.test(firstLine))
|
|
747
|
+
return null;
|
|
748
|
+
restStart = firstLine.length;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
const fenceMatch = afterOpen.match(/\n(---\s*)(\n|$)/);
|
|
752
|
+
if (!fenceMatch || fenceMatch.index === undefined)
|
|
753
|
+
return null;
|
|
754
|
+
frontmatterText = afterOpen.slice(0, fenceMatch.index);
|
|
755
|
+
restStart = fenceMatch.index + fenceMatch[0].length - (fenceMatch[2] === '\n' ? 1 : 0);
|
|
756
|
+
}
|
|
757
|
+
const rest = afterOpen.slice(restStart);
|
|
758
|
+
// rest may now be "" or start with "\n<body>".
|
|
759
|
+
const body = rest.startsWith('\n') ? rest.slice(1) : rest;
|
|
760
|
+
return { frontmatterText, body };
|
|
761
|
+
}
|
|
762
|
+
// ── Manifest → JobDefinition ───────────────────────────────────────────────
|
|
763
|
+
function manifestToJobDefinition(manifest, frontmatter, body) {
|
|
764
|
+
// Manifest is authority for cron + slug; frontmatter is authority for
|
|
765
|
+
// name/description per spec §"Open Questions Resolved" Q6.
|
|
766
|
+
const name = (frontmatter && typeof frontmatter.name === 'string' && frontmatter.name.trim()) ||
|
|
767
|
+
manifest.slug;
|
|
768
|
+
const description = (frontmatter && typeof frontmatter.description === 'string' && frontmatter.description.trim()) ||
|
|
769
|
+
`agentmd job ${manifest.slug}`;
|
|
770
|
+
const job = {
|
|
771
|
+
slug: manifest.slug,
|
|
772
|
+
name,
|
|
773
|
+
description,
|
|
774
|
+
schedule: manifest.schedule,
|
|
775
|
+
priority: manifest.priority,
|
|
776
|
+
expectedDurationMinutes: manifest.expectedDurationMinutes,
|
|
777
|
+
model: manifest.model ?? 'sonnet',
|
|
778
|
+
enabled: manifest.enabled,
|
|
779
|
+
execute: manifest.execute,
|
|
780
|
+
origin: manifest.origin,
|
|
781
|
+
tags: manifest.tags,
|
|
782
|
+
topicId: manifest.topicId,
|
|
783
|
+
telegramNotify: manifest.telegramNotify,
|
|
784
|
+
machines: manifest.machines,
|
|
785
|
+
gate: manifest.gate,
|
|
786
|
+
unrestrictedTools: manifest.unrestrictedTools,
|
|
787
|
+
};
|
|
788
|
+
if (manifest.execute.type === 'agentmd') {
|
|
789
|
+
if (body !== undefined)
|
|
790
|
+
job.body = body;
|
|
791
|
+
if (frontmatter !== undefined)
|
|
792
|
+
job.frontmatter = frontmatter;
|
|
793
|
+
}
|
|
794
|
+
return job;
|
|
795
|
+
}
|
|
796
|
+
// ── Test-only exports (kept on the public surface for SchedulerProbe) ─────
|
|
797
|
+
/** Exposed for SchedulerProbe and tests: returns true iff a JobDefinition
|
|
798
|
+
* originating from an agentmd manifest has its body cached in memory.
|
|
799
|
+
* Phase 1a invariant — agentmd jobs MUST carry body after loadJobs(). */
|
|
800
|
+
export function isAgentMdJobHydrated(job) {
|
|
801
|
+
if (job.execute.type !== 'agentmd')
|
|
802
|
+
return true;
|
|
803
|
+
return typeof job.body === 'string';
|
|
804
|
+
}
|
|
805
|
+
//# sourceMappingURL=AgentMdJobLoader.js.map
|