@poncho-ai/harness 0.11.2 → 0.13.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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +17 -0
- package/dist/index.d.ts +60 -1
- package/dist/index.js +660 -134
- package/package.json +2 -2
- package/src/agent-parser.ts +76 -0
- package/src/config.ts +10 -0
- package/src/harness.ts +215 -24
- package/src/index.ts +1 -0
- package/src/upload-store.ts +387 -0
- package/test/agent-parser.test.ts +118 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import type { UploadsConfig } from "./config.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Try to dynamically import a module, first from the harness's own
|
|
9
|
+
* node_modules, then from the user's project directory. This handles
|
|
10
|
+
* the case where the CLI is globally linked and optional deps are
|
|
11
|
+
* installed in the user's project but not in the poncho-ai monorepo.
|
|
12
|
+
*/
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
const tryImport = async (mod: string, workingDir?: string): Promise<any> => {
|
|
15
|
+
try {
|
|
16
|
+
return await import(/* webpackIgnore: true */ mod);
|
|
17
|
+
} catch {
|
|
18
|
+
if (workingDir) {
|
|
19
|
+
const require = createRequire(resolve(workingDir, "package.json"));
|
|
20
|
+
const resolved = require.resolve(mod);
|
|
21
|
+
return await import(/* webpackIgnore: true */ resolved);
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Cannot find module "${mod}"`);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const PONCHO_UPLOAD_SCHEME = "poncho-upload://";
|
|
28
|
+
|
|
29
|
+
export interface UploadStore {
|
|
30
|
+
put(key: string, data: Buffer, mediaType: string): Promise<string>;
|
|
31
|
+
get(urlOrKey: string): Promise<Buffer>;
|
|
32
|
+
delete(urlOrKey: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write-behind cache that wraps any UploadStore. `put()` caches the
|
|
37
|
+
* data in memory and returns immediately with a `poncho-upload://` ref
|
|
38
|
+
* while the actual cloud upload happens in the background. `get()`
|
|
39
|
+
* serves from cache when available, eliminating the round-trip back
|
|
40
|
+
* to the cloud store that would otherwise block the model request.
|
|
41
|
+
*/
|
|
42
|
+
class CachedUploadStore implements UploadStore {
|
|
43
|
+
private readonly inner: UploadStore;
|
|
44
|
+
private readonly cache = new Map<string, { data: Buffer; ts: number }>();
|
|
45
|
+
private readonly maxEntries: number;
|
|
46
|
+
private readonly ttlMs: number;
|
|
47
|
+
|
|
48
|
+
constructor(inner: UploadStore, maxEntries = 64, ttlMs = 10 * 60 * 1000) {
|
|
49
|
+
this.inner = inner;
|
|
50
|
+
this.maxEntries = maxEntries;
|
|
51
|
+
this.ttlMs = ttlMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async put(key: string, data: Buffer, mediaType: string): Promise<string> {
|
|
55
|
+
const ref = `${PONCHO_UPLOAD_SCHEME}${key}`;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
this.cache.set(ref, { data, ts: now });
|
|
58
|
+
this.cache.set(key, { data, ts: now });
|
|
59
|
+
this.evict();
|
|
60
|
+
|
|
61
|
+
// Fire off the real upload in the background — don't block the caller.
|
|
62
|
+
this.inner.put(key, data, mediaType).catch((err) => {
|
|
63
|
+
console.error("[poncho] background upload failed:", err instanceof Error ? err.message : err);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return ref;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async get(urlOrKey: string): Promise<Buffer> {
|
|
70
|
+
const cached = this.cache.get(urlOrKey);
|
|
71
|
+
if (cached && Date.now() - cached.ts < this.ttlMs) {
|
|
72
|
+
return cached.data;
|
|
73
|
+
}
|
|
74
|
+
return this.inner.get(urlOrKey);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async delete(urlOrKey: string): Promise<void> {
|
|
78
|
+
this.cache.delete(urlOrKey);
|
|
79
|
+
return this.inner.delete(urlOrKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private evict(): void {
|
|
83
|
+
if (this.cache.size <= this.maxEntries) return;
|
|
84
|
+
let oldest: string | undefined;
|
|
85
|
+
let oldestTs = Infinity;
|
|
86
|
+
for (const [k, v] of this.cache) {
|
|
87
|
+
if (v.ts < oldestTs) {
|
|
88
|
+
oldestTs = v.ts;
|
|
89
|
+
oldest = k;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (oldest) this.cache.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Derive a content-addressed key from file data. */
|
|
97
|
+
export const deriveUploadKey = (
|
|
98
|
+
data: Buffer,
|
|
99
|
+
mediaType: string,
|
|
100
|
+
): string => {
|
|
101
|
+
const hash = createHash("sha256").update(data).digest("hex").slice(0, 24);
|
|
102
|
+
const ext = mimeToExt(mediaType);
|
|
103
|
+
return `${hash}${ext}`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const MIME_EXT_MAP: Record<string, string> = {
|
|
107
|
+
"image/jpeg": ".jpg",
|
|
108
|
+
"image/png": ".png",
|
|
109
|
+
"image/gif": ".gif",
|
|
110
|
+
"image/webp": ".webp",
|
|
111
|
+
"image/svg+xml": ".svg",
|
|
112
|
+
"application/pdf": ".pdf",
|
|
113
|
+
"text/plain": ".txt",
|
|
114
|
+
"text/csv": ".csv",
|
|
115
|
+
"text/html": ".html",
|
|
116
|
+
"application/json": ".json",
|
|
117
|
+
"video/mp4": ".mp4",
|
|
118
|
+
"video/webm": ".webm",
|
|
119
|
+
"audio/mpeg": ".mp3",
|
|
120
|
+
"audio/wav": ".wav",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const mimeToExt = (mediaType: string): string =>
|
|
124
|
+
MIME_EXT_MAP[mediaType] ?? `.${mediaType.split("/").pop() ?? "bin"}`;
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Local filesystem implementation
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export class LocalUploadStore implements UploadStore {
|
|
131
|
+
private readonly uploadsDir: string;
|
|
132
|
+
|
|
133
|
+
constructor(workingDir: string) {
|
|
134
|
+
this.uploadsDir = resolve(workingDir, ".poncho", "uploads");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async put(_key: string, data: Buffer, mediaType: string): Promise<string> {
|
|
138
|
+
const key = deriveUploadKey(data, mediaType);
|
|
139
|
+
const filePath = resolve(this.uploadsDir, key);
|
|
140
|
+
await mkdir(this.uploadsDir, { recursive: true });
|
|
141
|
+
await writeFile(filePath, data);
|
|
142
|
+
return `${PONCHO_UPLOAD_SCHEME}${key}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async get(urlOrKey: string): Promise<Buffer> {
|
|
146
|
+
const key = urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME)
|
|
147
|
+
? urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length)
|
|
148
|
+
: urlOrKey;
|
|
149
|
+
return readFile(resolve(this.uploadsDir, key));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async delete(urlOrKey: string): Promise<void> {
|
|
153
|
+
const key = urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME)
|
|
154
|
+
? urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length)
|
|
155
|
+
: urlOrKey;
|
|
156
|
+
await rm(resolve(this.uploadsDir, key), { force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Vercel Blob implementation (optional dependency)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export class VercelBlobUploadStore implements UploadStore {
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
+
private sdk: any;
|
|
167
|
+
private readonly workingDir?: string;
|
|
168
|
+
private readonly access: "public" | "private";
|
|
169
|
+
|
|
170
|
+
constructor(workingDir?: string, access: "public" | "private" = "public") {
|
|
171
|
+
this.workingDir = workingDir;
|
|
172
|
+
this.access = access;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async loadSdk() {
|
|
176
|
+
if (this.sdk) return this.sdk;
|
|
177
|
+
try {
|
|
178
|
+
this.sdk = await tryImport("@vercel/blob", this.workingDir);
|
|
179
|
+
return this.sdk;
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(
|
|
182
|
+
'uploads: vercel-blob provider requires the "@vercel/blob" package. Install it with: pnpm add @vercel/blob',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async put(key: string, data: Buffer, mediaType: string): Promise<string> {
|
|
188
|
+
const sdk = await this.loadSdk();
|
|
189
|
+
await sdk.put(key, data, {
|
|
190
|
+
access: this.access,
|
|
191
|
+
contentType: mediaType,
|
|
192
|
+
addRandomSuffix: false,
|
|
193
|
+
allowOverwrite: true,
|
|
194
|
+
});
|
|
195
|
+
return `${PONCHO_UPLOAD_SCHEME}${key}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async get(urlOrKey: string): Promise<Buffer> {
|
|
199
|
+
let pathname = urlOrKey;
|
|
200
|
+
if (urlOrKey.startsWith(PONCHO_UPLOAD_SCHEME)) {
|
|
201
|
+
pathname = urlOrKey.slice(PONCHO_UPLOAD_SCHEME.length);
|
|
202
|
+
} else if (urlOrKey.startsWith("https://") || urlOrKey.startsWith("http://")) {
|
|
203
|
+
pathname = new URL(urlOrKey).pathname.slice(1);
|
|
204
|
+
}
|
|
205
|
+
if (this.access === "private") {
|
|
206
|
+
const sdk = await this.loadSdk();
|
|
207
|
+
const result = await sdk.get(pathname, { access: "private" });
|
|
208
|
+
if (!result || result.statusCode !== 200) {
|
|
209
|
+
throw new Error(`uploads: failed to fetch private blob "${pathname}": ${result?.statusCode ?? "not found"}`);
|
|
210
|
+
}
|
|
211
|
+
const chunks: Uint8Array[] = [];
|
|
212
|
+
const reader = result.stream.getReader();
|
|
213
|
+
for (;;) {
|
|
214
|
+
const { done, value } = await reader.read();
|
|
215
|
+
if (done) break;
|
|
216
|
+
chunks.push(value);
|
|
217
|
+
}
|
|
218
|
+
return Buffer.concat(chunks);
|
|
219
|
+
}
|
|
220
|
+
const sdk = await this.loadSdk();
|
|
221
|
+
const blob = await sdk.head(pathname);
|
|
222
|
+
const response = await fetch(blob.url);
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
throw new Error(`uploads: failed to fetch blob "${pathname}": ${response.status}`);
|
|
225
|
+
}
|
|
226
|
+
return Buffer.from(await response.arrayBuffer());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async delete(urlOrKey: string): Promise<void> {
|
|
230
|
+
const sdk = await this.loadSdk();
|
|
231
|
+
await sdk.del(urlOrKey);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// S3-compatible implementation (optional dependency)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export class S3UploadStore implements UploadStore {
|
|
240
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
241
|
+
private s3Sdk: any;
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
243
|
+
private presignerSdk: any;
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
|
+
private client: any;
|
|
246
|
+
private readonly bucket: string;
|
|
247
|
+
private readonly region?: string;
|
|
248
|
+
private readonly endpoint?: string;
|
|
249
|
+
private readonly workingDir?: string;
|
|
250
|
+
|
|
251
|
+
constructor(bucket: string, region?: string, endpoint?: string, workingDir?: string) {
|
|
252
|
+
this.bucket = bucket;
|
|
253
|
+
this.region = region;
|
|
254
|
+
this.endpoint = endpoint;
|
|
255
|
+
this.workingDir = workingDir;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async ensureClient() {
|
|
259
|
+
if (this.client) return;
|
|
260
|
+
try {
|
|
261
|
+
this.s3Sdk = await tryImport("@aws-sdk/client-s3", this.workingDir);
|
|
262
|
+
this.presignerSdk = await tryImport("@aws-sdk/s3-request-presigner", this.workingDir);
|
|
263
|
+
} catch {
|
|
264
|
+
throw new Error(
|
|
265
|
+
'uploads: s3 provider requires "@aws-sdk/client-s3" and "@aws-sdk/s3-request-presigner". ' +
|
|
266
|
+
"Install with: pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
this.client = new this.s3Sdk.S3Client({
|
|
270
|
+
region: this.region ?? process.env.AWS_REGION ?? "us-east-1",
|
|
271
|
+
...(this.endpoint ? { endpoint: this.endpoint, forcePathStyle: true } : {}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async put(key: string, data: Buffer, mediaType: string): Promise<string> {
|
|
276
|
+
await this.ensureClient();
|
|
277
|
+
await this.client.send(
|
|
278
|
+
new this.s3Sdk.PutObjectCommand({
|
|
279
|
+
Bucket: this.bucket,
|
|
280
|
+
Key: key,
|
|
281
|
+
Body: data,
|
|
282
|
+
ContentType: mediaType,
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
const url: string = await this.presignerSdk.getSignedUrl(
|
|
286
|
+
this.client,
|
|
287
|
+
new this.s3Sdk.GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
288
|
+
{ expiresIn: 7 * 24 * 60 * 60 },
|
|
289
|
+
);
|
|
290
|
+
return url;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async get(urlOrKey: string): Promise<Buffer> {
|
|
294
|
+
if (urlOrKey.startsWith("https://") || urlOrKey.startsWith("http://")) {
|
|
295
|
+
const response = await fetch(urlOrKey);
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
throw new Error(`uploads: failed to fetch S3 object at ${urlOrKey}: ${response.status}`);
|
|
298
|
+
}
|
|
299
|
+
return Buffer.from(await response.arrayBuffer());
|
|
300
|
+
}
|
|
301
|
+
await this.ensureClient();
|
|
302
|
+
const result = await this.client.send(
|
|
303
|
+
new this.s3Sdk.GetObjectCommand({ Bucket: this.bucket, Key: urlOrKey }),
|
|
304
|
+
);
|
|
305
|
+
if (!result.Body) throw new Error(`uploads: empty body for S3 key ${urlOrKey}`);
|
|
306
|
+
return Buffer.from(await result.Body.transformToByteArray());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async delete(urlOrKey: string): Promise<void> {
|
|
310
|
+
await this.ensureClient();
|
|
311
|
+
const key = urlOrKey.startsWith("https://")
|
|
312
|
+
? new URL(urlOrKey).pathname.slice(1)
|
|
313
|
+
: urlOrKey;
|
|
314
|
+
await this.client.send(
|
|
315
|
+
new this.s3Sdk.DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Factory with graceful fallback
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
const warn = (msg: string) => {
|
|
325
|
+
console.warn(`[poncho] ⚠ ${msg}`);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export const createUploadStore = async (
|
|
329
|
+
config: UploadsConfig | undefined,
|
|
330
|
+
workingDir: string,
|
|
331
|
+
): Promise<UploadStore> => {
|
|
332
|
+
const provider = config?.provider ?? "local";
|
|
333
|
+
|
|
334
|
+
if (provider === "vercel-blob") {
|
|
335
|
+
if (!process.env.BLOB_READ_WRITE_TOKEN) {
|
|
336
|
+
warn(
|
|
337
|
+
"uploads: vercel-blob configured but BLOB_READ_WRITE_TOKEN not found in environment. Falling back to local filesystem.\n" +
|
|
338
|
+
" Make sure BLOB_READ_WRITE_TOKEN is set in your .env file or environment.",
|
|
339
|
+
);
|
|
340
|
+
return new LocalUploadStore(workingDir);
|
|
341
|
+
}
|
|
342
|
+
const store = new VercelBlobUploadStore(workingDir, config?.access ?? "public");
|
|
343
|
+
try {
|
|
344
|
+
await store.loadSdk();
|
|
345
|
+
console.log("[poncho] uploads: using vercel-blob store");
|
|
346
|
+
return new CachedUploadStore(store);
|
|
347
|
+
} catch {
|
|
348
|
+
warn(
|
|
349
|
+
'uploads: vercel-blob configured but "@vercel/blob" package is not installed. Falling back to local filesystem.\n' +
|
|
350
|
+
" Run `poncho build <target>` to auto-add it, or install manually: pnpm add @vercel/blob",
|
|
351
|
+
);
|
|
352
|
+
return new LocalUploadStore(workingDir);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (provider === "s3") {
|
|
357
|
+
const bucket = config?.bucket ?? process.env.PONCHO_UPLOADS_BUCKET;
|
|
358
|
+
if (!process.env.AWS_ACCESS_KEY_ID || !bucket) {
|
|
359
|
+
const missing = !process.env.AWS_ACCESS_KEY_ID
|
|
360
|
+
? "AWS_ACCESS_KEY_ID"
|
|
361
|
+
: "bucket (config.uploads.bucket or PONCHO_UPLOADS_BUCKET)";
|
|
362
|
+
warn(
|
|
363
|
+
`uploads: s3 configured but ${missing} not found in environment. Falling back to local filesystem.`,
|
|
364
|
+
);
|
|
365
|
+
return new LocalUploadStore(workingDir);
|
|
366
|
+
}
|
|
367
|
+
const store = new S3UploadStore(
|
|
368
|
+
bucket,
|
|
369
|
+
config?.region ?? process.env.AWS_REGION,
|
|
370
|
+
config?.endpoint ?? process.env.PONCHO_UPLOADS_ENDPOINT,
|
|
371
|
+
workingDir,
|
|
372
|
+
);
|
|
373
|
+
try {
|
|
374
|
+
await store.ensureClient();
|
|
375
|
+
console.log(`[poncho] uploads: using s3 store (bucket: ${bucket})`);
|
|
376
|
+
return new CachedUploadStore(store);
|
|
377
|
+
} catch {
|
|
378
|
+
warn(
|
|
379
|
+
"uploads: s3 configured but AWS SDK packages are not installed. Falling back to local filesystem.\n" +
|
|
380
|
+
" Run `poncho build <target>` to auto-add them, or install manually: pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner",
|
|
381
|
+
);
|
|
382
|
+
return new LocalUploadStore(workingDir);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return new LocalUploadStore(workingDir);
|
|
387
|
+
};
|
|
@@ -37,6 +37,124 @@ Env: {{runtime.environment}}
|
|
|
37
37
|
expect(prompt).toContain("Env: development");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
describe("cron jobs", () => {
|
|
41
|
+
it("parses cron jobs from frontmatter", () => {
|
|
42
|
+
const parsed = parseAgentMarkdown(`---
|
|
43
|
+
name: test-agent
|
|
44
|
+
cron:
|
|
45
|
+
daily-report:
|
|
46
|
+
schedule: "0 9 * * *"
|
|
47
|
+
task: "Generate the daily report"
|
|
48
|
+
health-check:
|
|
49
|
+
schedule: "*/30 * * * *"
|
|
50
|
+
timezone: "America/New_York"
|
|
51
|
+
task: "Check all APIs"
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
# Agent
|
|
55
|
+
`);
|
|
56
|
+
expect(parsed.frontmatter.cron).toBeDefined();
|
|
57
|
+
expect(Object.keys(parsed.frontmatter.cron!)).toEqual([
|
|
58
|
+
"daily-report",
|
|
59
|
+
"health-check",
|
|
60
|
+
]);
|
|
61
|
+
expect(parsed.frontmatter.cron!["daily-report"]).toEqual({
|
|
62
|
+
schedule: "0 9 * * *",
|
|
63
|
+
task: "Generate the daily report",
|
|
64
|
+
timezone: undefined,
|
|
65
|
+
});
|
|
66
|
+
expect(parsed.frontmatter.cron!["health-check"]).toEqual({
|
|
67
|
+
schedule: "*/30 * * * *",
|
|
68
|
+
task: "Check all APIs",
|
|
69
|
+
timezone: "America/New_York",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns undefined cron when not defined", () => {
|
|
74
|
+
const parsed = parseAgentMarkdown(`---
|
|
75
|
+
name: test-agent
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
# Agent
|
|
79
|
+
`);
|
|
80
|
+
expect(parsed.frontmatter.cron).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws on missing schedule", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
parseAgentMarkdown(`---
|
|
86
|
+
name: test-agent
|
|
87
|
+
cron:
|
|
88
|
+
bad-job:
|
|
89
|
+
task: "Do something"
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# Agent
|
|
93
|
+
`),
|
|
94
|
+
).toThrow(/"schedule" is required/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws on missing task", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
parseAgentMarkdown(`---
|
|
100
|
+
name: test-agent
|
|
101
|
+
cron:
|
|
102
|
+
bad-job:
|
|
103
|
+
schedule: "0 9 * * *"
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
# Agent
|
|
107
|
+
`),
|
|
108
|
+
).toThrow(/"task" is required/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("throws on invalid cron expression", () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
parseAgentMarkdown(`---
|
|
114
|
+
name: test-agent
|
|
115
|
+
cron:
|
|
116
|
+
bad-job:
|
|
117
|
+
schedule: "every day"
|
|
118
|
+
task: "Do something"
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
# Agent
|
|
122
|
+
`),
|
|
123
|
+
).toThrow(/Invalid cron expression/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("throws on invalid timezone", () => {
|
|
127
|
+
expect(() =>
|
|
128
|
+
parseAgentMarkdown(`---
|
|
129
|
+
name: test-agent
|
|
130
|
+
cron:
|
|
131
|
+
bad-job:
|
|
132
|
+
schedule: "0 9 * * *"
|
|
133
|
+
timezone: "Fake/Zone"
|
|
134
|
+
task: "Do something"
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
# Agent
|
|
138
|
+
`),
|
|
139
|
+
).toThrow(/Invalid timezone/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("accepts valid timezone", () => {
|
|
143
|
+
const parsed = parseAgentMarkdown(`---
|
|
144
|
+
name: test-agent
|
|
145
|
+
cron:
|
|
146
|
+
job:
|
|
147
|
+
schedule: "0 9 * * *"
|
|
148
|
+
timezone: "Europe/London"
|
|
149
|
+
task: "Do something"
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
# Agent
|
|
153
|
+
`);
|
|
154
|
+
expect(parsed.frontmatter.cron!["job"]!.timezone).toBe("Europe/London");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
40
158
|
it("parses approval-required with relative script paths", () => {
|
|
41
159
|
const parsed = parseAgentMarkdown(`---
|
|
42
160
|
name: test-agent
|