@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.
@@ -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