@devosurf/tesser 0.1.0-alpha.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/LICENSE +202 -0
- package/README.md +41 -0
- package/bin/tesser.mjs +2 -0
- package/dist/index.js +2361 -0
- package/dist/index.js.map +7 -0
- package/package.json +34 -0
- package/src/client.ts +63 -0
- package/src/commands/auth.test.ts +19 -0
- package/src/commands/auth.ts +154 -0
- package/src/commands/deploy.ts +80 -0
- package/src/commands/dev.ts +119 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/replay.ts +81 -0
- package/src/commands/test.ts +149 -0
- package/src/config.ts +87 -0
- package/src/exit-codes.ts +24 -0
- package/src/index.ts +508 -0
- package/src/output.ts +47 -0
- package/src/project.ts +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2361 @@
|
|
|
1
|
+
// packages/cli/src/index.ts
|
|
2
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
3
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
4
|
+
import { promisify as promisify2 } from "node:util";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// packages/sdk/src/internal/codec.ts
|
|
8
|
+
var NotSerializableError = class extends TypeError {
|
|
9
|
+
constructor(path, kind) {
|
|
10
|
+
super(
|
|
11
|
+
`value at ${path} is not serializable (${kind}); step results are journaled and must be plain data \u2014 string/number/boolean/null/bigint/Date/Array/Map/Set/Uint8Array/plain object`
|
|
12
|
+
);
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.kind = kind;
|
|
15
|
+
this.name = "NotSerializableError";
|
|
16
|
+
}
|
|
17
|
+
path;
|
|
18
|
+
kind;
|
|
19
|
+
};
|
|
20
|
+
function describe(value) {
|
|
21
|
+
if (typeof value === "function") return "a function";
|
|
22
|
+
if (typeof value === "symbol") return "a symbol";
|
|
23
|
+
if (value instanceof Promise) return "a Promise \u2014 await it inside the step";
|
|
24
|
+
const proto = Object.getPrototypeOf(value);
|
|
25
|
+
const ctor = proto?.constructor?.name;
|
|
26
|
+
return ctor ? `a ${ctor} instance \u2014 a live handle` : "an exotic object";
|
|
27
|
+
}
|
|
28
|
+
function encodeJournal(value, path = "$") {
|
|
29
|
+
if (value === void 0) return { $t: "undef" };
|
|
30
|
+
if (value === null) return null;
|
|
31
|
+
const t = typeof value;
|
|
32
|
+
if (t === "string" || t === "boolean") return value;
|
|
33
|
+
if (t === "number") {
|
|
34
|
+
const n = value;
|
|
35
|
+
if (Number.isFinite(n)) return n;
|
|
36
|
+
return { $t: "num", v: String(n) };
|
|
37
|
+
}
|
|
38
|
+
if (t === "bigint") return { $t: "bigint", v: value.toString() };
|
|
39
|
+
if (t === "function" || t === "symbol") throw new NotSerializableError(path, describe(value));
|
|
40
|
+
if (value instanceof Date) {
|
|
41
|
+
const ms = value.getTime();
|
|
42
|
+
return { $t: "date", v: Number.isNaN(ms) ? null : value.toISOString() };
|
|
43
|
+
}
|
|
44
|
+
if (value instanceof Map) {
|
|
45
|
+
return {
|
|
46
|
+
$t: "map",
|
|
47
|
+
v: [...value.entries()].map(([k, v], i) => [
|
|
48
|
+
encodeJournal(k, `${path}.<key ${i}>`),
|
|
49
|
+
encodeJournal(v, `${path}.<entry ${i}>`)
|
|
50
|
+
])
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (value instanceof Set) {
|
|
54
|
+
return {
|
|
55
|
+
$t: "set",
|
|
56
|
+
v: [...value.values()].map((v, i) => encodeJournal(v, `${path}.<item ${i}>`))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (value instanceof Uint8Array) {
|
|
60
|
+
return { $t: "bytes", v: Buffer.from(value).toString("base64") };
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
return value.map((v, i) => encodeJournal(v, `${path}[${i}]`));
|
|
64
|
+
}
|
|
65
|
+
if (t === "object") {
|
|
66
|
+
const proto = Object.getPrototypeOf(value);
|
|
67
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
68
|
+
throw new NotSerializableError(path, describe(value));
|
|
69
|
+
}
|
|
70
|
+
const obj = value;
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const key of Object.keys(obj)) {
|
|
73
|
+
out[key] = encodeJournal(obj[key], `${path}.${key}`);
|
|
74
|
+
}
|
|
75
|
+
if (Object.prototype.hasOwnProperty.call(obj, "$t")) return { $t: "obj", v: out };
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
throw new NotSerializableError(path, describe(value));
|
|
79
|
+
}
|
|
80
|
+
function decodeJournal(json) {
|
|
81
|
+
if (json === null || typeof json !== "object") return json;
|
|
82
|
+
if (Array.isArray(json)) return json.map(decodeJournal);
|
|
83
|
+
const tag = json["$t"];
|
|
84
|
+
if (tag === void 0) {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const key of Object.keys(json)) out[key] = decodeJournal(json[key]);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
const v = json["v"];
|
|
90
|
+
switch (tag) {
|
|
91
|
+
case "undef":
|
|
92
|
+
return void 0;
|
|
93
|
+
case "num":
|
|
94
|
+
return Number(v);
|
|
95
|
+
case "bigint":
|
|
96
|
+
return BigInt(v);
|
|
97
|
+
case "date":
|
|
98
|
+
return v === null ? /* @__PURE__ */ new Date(NaN) : new Date(v);
|
|
99
|
+
case "map":
|
|
100
|
+
return new Map(v.map((e) => {
|
|
101
|
+
const [k, val] = e;
|
|
102
|
+
return [decodeJournal(k), decodeJournal(val)];
|
|
103
|
+
}));
|
|
104
|
+
case "set":
|
|
105
|
+
return new Set(v.map(decodeJournal));
|
|
106
|
+
case "bytes":
|
|
107
|
+
return new Uint8Array(Buffer.from(v, "base64"));
|
|
108
|
+
case "obj": {
|
|
109
|
+
const out = {};
|
|
110
|
+
const inner = v;
|
|
111
|
+
for (const key of Object.keys(inner)) out[key] = decodeJournal(inner[key]);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
throw new TypeError(`journal codec: unknown tag "${String(tag)}"`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// packages/sdk/src/internal/duration.ts
|
|
120
|
+
var UNIT_MS = {
|
|
121
|
+
ms: 1,
|
|
122
|
+
s: 1e3,
|
|
123
|
+
m: 6e4,
|
|
124
|
+
h: 36e5,
|
|
125
|
+
d: 864e5,
|
|
126
|
+
w: 6048e5
|
|
127
|
+
};
|
|
128
|
+
var PART = /(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/g;
|
|
129
|
+
function parseDuration(input, what = "duration") {
|
|
130
|
+
if (typeof input === "number") {
|
|
131
|
+
if (!Number.isFinite(input) || input < 0) {
|
|
132
|
+
throw new TypeError(`${what}: expected a non-negative number of ms, got ${input}`);
|
|
133
|
+
}
|
|
134
|
+
return input;
|
|
135
|
+
}
|
|
136
|
+
const s = input.trim();
|
|
137
|
+
let total = 0;
|
|
138
|
+
let matchedLen = 0;
|
|
139
|
+
for (const m of s.matchAll(PART)) {
|
|
140
|
+
total += Number(m[1]) * UNIT_MS[m[2]];
|
|
141
|
+
matchedLen += m[0].length;
|
|
142
|
+
}
|
|
143
|
+
if (matchedLen === 0 || matchedLen !== s.replace(/\s+/g, "").length) {
|
|
144
|
+
throw new TypeError(
|
|
145
|
+
`${what}: "${input}" is not a duration \u2014 use forms like "250ms", "30s", "5m", "1h30m", "2d"`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return Math.round(total);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// packages/sdk/src/errors.ts
|
|
152
|
+
var RETRYABLE = /* @__PURE__ */ Symbol.for("tesser.error.retryable");
|
|
153
|
+
var TERMINAL = /* @__PURE__ */ Symbol.for("tesser.error.terminal");
|
|
154
|
+
var RetryableError = class extends Error {
|
|
155
|
+
retryAfterMs;
|
|
156
|
+
constructor(message, options) {
|
|
157
|
+
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
158
|
+
this.name = "RetryableError";
|
|
159
|
+
this.retryAfterMs = options?.retryAfterMs;
|
|
160
|
+
Object.defineProperty(this, RETRYABLE, { value: true });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
var TerminalError = class extends Error {
|
|
164
|
+
constructor(message, options) {
|
|
165
|
+
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
166
|
+
this.name = "TerminalError";
|
|
167
|
+
Object.defineProperty(this, TERMINAL, { value: true });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
function isRetryableError(err) {
|
|
171
|
+
return err instanceof RetryableError || typeof err === "object" && err !== null && RETRYABLE in err;
|
|
172
|
+
}
|
|
173
|
+
function isTerminalError(err) {
|
|
174
|
+
return err instanceof TerminalError || typeof err === "object" && err !== null && TERMINAL in err;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// packages/sdk/src/internal/standard-schema.ts
|
|
178
|
+
var SchemaValidationError = class extends TerminalError {
|
|
179
|
+
constructor(what, issues) {
|
|
180
|
+
super(
|
|
181
|
+
`${what} failed validation: ` + issues.map((i) => i.path === "$" ? i.message : `${i.path}: ${i.message}`).join("; ")
|
|
182
|
+
);
|
|
183
|
+
this.what = what;
|
|
184
|
+
this.issues = issues;
|
|
185
|
+
this.name = "SchemaValidationError";
|
|
186
|
+
}
|
|
187
|
+
what;
|
|
188
|
+
issues;
|
|
189
|
+
};
|
|
190
|
+
function pathToString(path) {
|
|
191
|
+
if (!path || path.length === 0) return "$";
|
|
192
|
+
return "$." + path.map((seg) => String(typeof seg === "object" && seg !== null && "key" in seg ? seg.key : seg)).join(".");
|
|
193
|
+
}
|
|
194
|
+
async function validateSchema(schema, value, what) {
|
|
195
|
+
let result = schema["~standard"].validate(value);
|
|
196
|
+
if (result instanceof Promise) result = await result;
|
|
197
|
+
if (result.issues) {
|
|
198
|
+
throw new SchemaValidationError(
|
|
199
|
+
what,
|
|
200
|
+
result.issues.map((i) => ({ message: i.message, path: pathToString(i.path) }))
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return result.value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// packages/sdk/src/internal/webhook-verify.ts
|
|
207
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
208
|
+
var SLACK_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
209
|
+
|
|
210
|
+
// packages/sdk/src/internal/retry.ts
|
|
211
|
+
var DEFAULT_STEP_RETRY = {
|
|
212
|
+
maxAttempts: 3,
|
|
213
|
+
type: "exponential",
|
|
214
|
+
baseMs: 1e3,
|
|
215
|
+
maxMs: 36e5,
|
|
216
|
+
jitter: true
|
|
217
|
+
};
|
|
218
|
+
function resolveRetryPolicy(policy, fallback = DEFAULT_STEP_RETRY) {
|
|
219
|
+
if (!policy) return fallback;
|
|
220
|
+
const backoff = policy.backoff;
|
|
221
|
+
if (backoff === void 0) return { ...fallback, maxAttempts: policy.maxAttempts };
|
|
222
|
+
if (typeof backoff === "string") {
|
|
223
|
+
return { ...fallback, maxAttempts: policy.maxAttempts, type: backoff };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
maxAttempts: policy.maxAttempts,
|
|
227
|
+
type: backoff.type,
|
|
228
|
+
baseMs: backoff.base !== void 0 ? parseDuration(backoff.base, "retry.backoff.base") : fallback.baseMs,
|
|
229
|
+
maxMs: backoff.max !== void 0 ? parseDuration(backoff.max, "retry.backoff.max") : fallback.maxMs,
|
|
230
|
+
jitter: backoff.jitter ?? fallback.jitter
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function nextRetryDelayMs(policy, attempt, retryAfterMs, random = Math.random) {
|
|
234
|
+
if (attempt >= policy.maxAttempts) return null;
|
|
235
|
+
let delay = policy.type === "fixed" ? policy.baseMs : policy.baseMs * Math.pow(2, attempt - 1);
|
|
236
|
+
delay = Math.min(delay, policy.maxMs);
|
|
237
|
+
if (policy.jitter) delay = delay * (0.5 + random() * 0.5);
|
|
238
|
+
if (retryAfterMs !== void 0) delay = Math.max(delay, retryAfterMs);
|
|
239
|
+
return Math.round(delay);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// packages/sdk/src/connector/index.ts
|
|
243
|
+
function isAction(node) {
|
|
244
|
+
return typeof node === "object" && node !== null && node.__action === true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// packages/sdk/src/internal/client.ts
|
|
248
|
+
function buildConnectorClient(connector, invoke) {
|
|
249
|
+
function walk(tree, path) {
|
|
250
|
+
const node = {};
|
|
251
|
+
for (const [key, child] of Object.entries(tree)) {
|
|
252
|
+
const childPath = [...path, key];
|
|
253
|
+
if (isAction(child)) {
|
|
254
|
+
node[key] = (input) => invoke(childPath, child, input);
|
|
255
|
+
} else {
|
|
256
|
+
node[key] = walk(child, childPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return Object.freeze(node);
|
|
260
|
+
}
|
|
261
|
+
return walk(connector.__connector.actions ?? {}, []);
|
|
262
|
+
}
|
|
263
|
+
function actionAtPath(connector, path) {
|
|
264
|
+
let node = connector.__connector.actions ?? {};
|
|
265
|
+
for (const key of path) {
|
|
266
|
+
if (node === void 0 || isAction(node)) return void 0;
|
|
267
|
+
node = node[key];
|
|
268
|
+
}
|
|
269
|
+
return node !== void 0 && isAction(node) ? node : void 0;
|
|
270
|
+
}
|
|
271
|
+
function isRetrySafe(def, connectorHasIdempotencyHeader) {
|
|
272
|
+
if (def.retrySafe !== void 0) return def.retrySafe;
|
|
273
|
+
if (def.safety === "read") return true;
|
|
274
|
+
return connectorHasIdempotencyHeader;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// packages/sdk/src/internal/manifest.ts
|
|
278
|
+
var ManifestError = class extends TypeError {
|
|
279
|
+
constructor(automationId, message) {
|
|
280
|
+
super(`automation "${automationId}": ${message}`);
|
|
281
|
+
this.automationId = automationId;
|
|
282
|
+
this.name = "ManifestError";
|
|
283
|
+
}
|
|
284
|
+
automationId;
|
|
285
|
+
};
|
|
286
|
+
function extractAutomationManifest(def) {
|
|
287
|
+
const connections = {};
|
|
288
|
+
const connMap = def.connections ?? {};
|
|
289
|
+
for (const [key, conn] of Object.entries(connMap)) {
|
|
290
|
+
connections[key] = { connector: conn.id, scope: conn.scope ?? "workspace" };
|
|
291
|
+
}
|
|
292
|
+
const secrets2 = {};
|
|
293
|
+
const secretMap = def.secrets ?? {};
|
|
294
|
+
for (const [key, sec] of Object.entries(secretMap)) {
|
|
295
|
+
secrets2[key] = sec.describe !== void 0 ? { describe: sec.describe } : {};
|
|
296
|
+
}
|
|
297
|
+
let trigger;
|
|
298
|
+
const t = def.trigger;
|
|
299
|
+
switch (t.kind) {
|
|
300
|
+
case "schedule": {
|
|
301
|
+
const st = t;
|
|
302
|
+
trigger = { kind: "schedule", cron: st.cron, ...st.tz !== void 0 ? { tz: st.tz } : {} };
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "webhook": {
|
|
306
|
+
const wt = t;
|
|
307
|
+
trigger = { kind: "webhook", respond: wt.respond, hasInputSchema: wt.input !== void 0 };
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "event": {
|
|
311
|
+
const et = t;
|
|
312
|
+
trigger = { kind: "event", event: et.event.name };
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "connector": {
|
|
316
|
+
const ct = t;
|
|
317
|
+
let key = ct.connectionKey;
|
|
318
|
+
if (key !== void 0) {
|
|
319
|
+
const bound = connections[key];
|
|
320
|
+
if (!bound) {
|
|
321
|
+
throw new ManifestError(def.id, `trigger names connection "${key}" but connections has no such entry`);
|
|
322
|
+
}
|
|
323
|
+
if (bound.connector !== ct.connectorId) {
|
|
324
|
+
throw new ManifestError(
|
|
325
|
+
def.id,
|
|
326
|
+
`trigger connection "${key}" is a ${bound.connector} connection, but the trigger belongs to ${ct.connectorId}`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
const candidates = Object.entries(connections).filter(([, c]) => c.connector === ct.connectorId);
|
|
331
|
+
if (candidates.length === 0) {
|
|
332
|
+
throw new ManifestError(
|
|
333
|
+
def.id,
|
|
334
|
+
`trigger ${ct.connectorId}.triggers.${ct.triggerId} requires a ${ct.connectorId} entry in connections`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (candidates.length > 1) {
|
|
338
|
+
throw new ManifestError(
|
|
339
|
+
def.id,
|
|
340
|
+
`trigger ${ct.connectorId}.triggers.${ct.triggerId} is ambiguous \u2014 name it: { connection: "<key>" } (candidates: ${candidates.map(([k]) => k).join(", ")})`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
key = candidates[0][0];
|
|
344
|
+
}
|
|
345
|
+
let params;
|
|
346
|
+
try {
|
|
347
|
+
params = encodeJournal(ct.params);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
throw new ManifestError(def.id, `trigger params must be plain data: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
trigger = {
|
|
352
|
+
kind: "connector",
|
|
353
|
+
connector: ct.connectorId,
|
|
354
|
+
trigger: ct.triggerId,
|
|
355
|
+
params,
|
|
356
|
+
connection: key,
|
|
357
|
+
...ct.every !== void 0 ? { every: ct.every } : {}
|
|
358
|
+
};
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
default:
|
|
362
|
+
throw new ManifestError(def.id, `unknown trigger kind "${t.kind}"`);
|
|
363
|
+
}
|
|
364
|
+
const models = {};
|
|
365
|
+
const modelMap = def.models ?? {};
|
|
366
|
+
for (const [key, m] of Object.entries(modelMap)) {
|
|
367
|
+
const conn = connections[m.connection];
|
|
368
|
+
if (!conn) {
|
|
369
|
+
throw new ManifestError(def.id, `models.${key} names connection "${m.connection}" but connections has no such entry`);
|
|
370
|
+
}
|
|
371
|
+
const connector = connMap[m.connection];
|
|
372
|
+
if (!connector?.__connector.modelProvider) {
|
|
373
|
+
throw new ManifestError(def.id, `models.${key} uses connection "${m.connection}" but ${conn.connector} is not model-capable`);
|
|
374
|
+
}
|
|
375
|
+
models[key] = {
|
|
376
|
+
connection: m.connection,
|
|
377
|
+
connector: conn.connector,
|
|
378
|
+
alias: m.alias,
|
|
379
|
+
...m.settings !== void 0 ? { settings: m.settings } : {}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const operators = {};
|
|
383
|
+
const operatorMap = def.operators ?? {};
|
|
384
|
+
for (const [key, op] of Object.entries(operatorMap)) {
|
|
385
|
+
if (!models[op.model]) {
|
|
386
|
+
throw new ManifestError(def.id, `operators.${key} references unknown model "${op.model}"`);
|
|
387
|
+
}
|
|
388
|
+
const tools = [];
|
|
389
|
+
for (const tool of op.tools) {
|
|
390
|
+
const [connKey, ...actionPath] = tool.split(".");
|
|
391
|
+
const conn = connections[connKey];
|
|
392
|
+
if (!conn) {
|
|
393
|
+
throw new ManifestError(def.id, `operators.${key} tool "${tool}" names undeclared connection "${connKey}"`);
|
|
394
|
+
}
|
|
395
|
+
const connector = connMap[connKey];
|
|
396
|
+
const action = connector ? actionAtPath(connector, actionPath) : void 0;
|
|
397
|
+
if (!action) {
|
|
398
|
+
throw new ManifestError(def.id, `operators.${key} tool "${tool}" is not a declared Action`);
|
|
399
|
+
}
|
|
400
|
+
tools.push({ path: tool, connection: connKey, action: actionPath.join("."), safety: action.safety });
|
|
401
|
+
}
|
|
402
|
+
operators[key] = { model: op.model, instructions: op.instructions, tools, maxTurns: op.maxTurns };
|
|
403
|
+
}
|
|
404
|
+
if (Object.keys(operators).length > 0 && !def.budget?.models) {
|
|
405
|
+
throw new ManifestError(def.id, `budget.models is required when operators are declared`);
|
|
406
|
+
}
|
|
407
|
+
const harnesses = {};
|
|
408
|
+
const harnessMap = def.harnesses ?? {};
|
|
409
|
+
for (const [key, h] of Object.entries(harnessMap)) {
|
|
410
|
+
const conn = connections[h.connection];
|
|
411
|
+
if (!conn) {
|
|
412
|
+
throw new ManifestError(def.id, `harnesses.${key} names connection "${h.connection}" but connections has no such entry`);
|
|
413
|
+
}
|
|
414
|
+
const connector = connMap[h.connection];
|
|
415
|
+
if (!connector?.__connector.harnessProvider) {
|
|
416
|
+
throw new ManifestError(def.id, `harnesses.${key} uses connection "${h.connection}" but ${conn.connector} is not harness-capable`);
|
|
417
|
+
}
|
|
418
|
+
harnesses[key] = {
|
|
419
|
+
connection: h.connection,
|
|
420
|
+
connector: conn.connector,
|
|
421
|
+
sandbox: h.sandbox,
|
|
422
|
+
permissions: h.permissions,
|
|
423
|
+
...h.timeout !== void 0 ? { timeout: h.timeout } : {},
|
|
424
|
+
...h.maxOutputBytes !== void 0 ? { maxOutputBytes: h.maxOutputBytes } : {}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
id: def.id,
|
|
429
|
+
trigger,
|
|
430
|
+
connections,
|
|
431
|
+
secrets: secrets2,
|
|
432
|
+
models,
|
|
433
|
+
operators,
|
|
434
|
+
harnesses,
|
|
435
|
+
...def.budget !== void 0 ? { budget: def.budget } : {},
|
|
436
|
+
...def.retry !== void 0 ? { retry: def.retry } : {},
|
|
437
|
+
...def.concurrency !== void 0 ? {
|
|
438
|
+
concurrency: {
|
|
439
|
+
limit: def.concurrency.limit,
|
|
440
|
+
hasKey: typeof def.concurrency.key === "function",
|
|
441
|
+
onConflict: def.concurrency.onConflict ?? "queue"
|
|
442
|
+
}
|
|
443
|
+
} : {},
|
|
444
|
+
hasInputSchema: def.input !== void 0,
|
|
445
|
+
hasOutputSchema: def.output !== void 0
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// packages/sdk/src/internal/operators.ts
|
|
450
|
+
var approvalSchema = {
|
|
451
|
+
"~standard": {
|
|
452
|
+
version: 1,
|
|
453
|
+
vendor: "tesser",
|
|
454
|
+
validate(value) {
|
|
455
|
+
if (typeof value === "object" && value !== null && typeof value.approved === "boolean") {
|
|
456
|
+
const v = value;
|
|
457
|
+
return {
|
|
458
|
+
value: {
|
|
459
|
+
approved: v.approved,
|
|
460
|
+
...typeof v.reason === "string" ? { reason: v.reason } : {}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return { issues: [{ message: "expected { approved: boolean }" }] };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
function buildOperators(def, ctx, callModel) {
|
|
469
|
+
const usage = { tokens: 0, outputTokens: 0 };
|
|
470
|
+
const out = {};
|
|
471
|
+
for (const [operatorKey, op] of Object.entries(def.operators ?? {})) {
|
|
472
|
+
out[operatorKey] = (input) => executeOperator({ def, ctx, operatorKey, op, input, usage, callModel });
|
|
473
|
+
}
|
|
474
|
+
return Object.freeze(out);
|
|
475
|
+
}
|
|
476
|
+
async function executeOperator(opts) {
|
|
477
|
+
const { def, ctx, operatorKey, op } = opts;
|
|
478
|
+
const modelKey = op.model;
|
|
479
|
+
const model2 = def.models?.[modelKey];
|
|
480
|
+
if (!model2) throw new TerminalError(`operator.${operatorKey}: unknown model "${modelKey}"`);
|
|
481
|
+
const validatedInput = await validateSchema(op.input, opts.input, `operator.${operatorKey} input`);
|
|
482
|
+
const serialInput = toSerializable(validatedInput, `operator.${operatorKey} input`);
|
|
483
|
+
const tools = await resolveTools(def, op, operatorKey);
|
|
484
|
+
const messages = [{ role: "user", content: JSON.stringify(serialInput) }];
|
|
485
|
+
const outputJsonSchema = await schemaJson(op.output);
|
|
486
|
+
let tainted = true;
|
|
487
|
+
for (let turn = 1; turn <= op.maxTurns; turn++) {
|
|
488
|
+
assertBudget(def, model2, opts.usage, operatorKey);
|
|
489
|
+
const request = {
|
|
490
|
+
operatorKey,
|
|
491
|
+
modelKey,
|
|
492
|
+
alias: model2.alias,
|
|
493
|
+
instructions: op.instructions,
|
|
494
|
+
input: serialInput,
|
|
495
|
+
messages,
|
|
496
|
+
tools: tools.map((t) => t.descriptor),
|
|
497
|
+
...model2.settings !== void 0 ? { settings: model2.settings } : {},
|
|
498
|
+
...outputJsonSchema !== void 0 ? { outputJsonSchema } : {}
|
|
499
|
+
};
|
|
500
|
+
const response = await ctx.step(
|
|
501
|
+
`operator.${operatorKey}.model.${turn}`,
|
|
502
|
+
async () => toSerializable(
|
|
503
|
+
await opts.callModel({ automationId: def.id, operatorKey, modelKey, model: model2, request }),
|
|
504
|
+
`operator.${operatorKey} model response`
|
|
505
|
+
)
|
|
506
|
+
);
|
|
507
|
+
validateModelResponse(response, operatorKey, turn);
|
|
508
|
+
addUsage(opts.usage, response);
|
|
509
|
+
assertBudget(def, model2, opts.usage, operatorKey);
|
|
510
|
+
const toolCalls = response.toolCalls ?? [];
|
|
511
|
+
if (toolCalls.length > 0) {
|
|
512
|
+
messages.push({ role: "assistant", content: response.content ?? `requested ${toolCalls.length} tool call(s)` });
|
|
513
|
+
let index = 0;
|
|
514
|
+
for (const call of toolCalls) {
|
|
515
|
+
index++;
|
|
516
|
+
const tool = toolForCall(tools, call);
|
|
517
|
+
if (!tool) {
|
|
518
|
+
throw new TerminalError(`operator.${operatorKey}: model requested undeclared tool "${call.name}"`);
|
|
519
|
+
}
|
|
520
|
+
if (tool.action.safety === "write") {
|
|
521
|
+
const approval = await ctx.waitForSignal(`operator.${operatorKey}.approval`, {
|
|
522
|
+
schema: approvalSchema,
|
|
523
|
+
timeout: "1h"
|
|
524
|
+
});
|
|
525
|
+
if (approval === null) {
|
|
526
|
+
throw new TerminalError(`operator.${operatorKey}: approval timed out for write tool ${tool.descriptor.path}`);
|
|
527
|
+
}
|
|
528
|
+
if (!approval.approved) {
|
|
529
|
+
throw new TerminalError(`operator.${operatorKey}: approval denied for write tool ${tool.descriptor.path}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const toolResult = await ctx.step(`operator.${operatorKey}.tool.${turn}.${index}.${tool.descriptor.path}`, async () => {
|
|
533
|
+
const fn = actionFunction(ctx.connections, tool.connectionKey, tool.actionPath);
|
|
534
|
+
return toSerializable(await fn(call.input), `operator.${operatorKey} tool ${tool.descriptor.path} output`);
|
|
535
|
+
});
|
|
536
|
+
if (tool.action.safety === "read") tainted = true;
|
|
537
|
+
messages.push({
|
|
538
|
+
role: "tool",
|
|
539
|
+
toolCallId: call.id,
|
|
540
|
+
content: minimizeToolOutput(toolResult, { tainted })
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const rawOutput = response.output !== void 0 ? response.output : parseJson(response.content, operatorKey, turn);
|
|
546
|
+
return validateSchema(op.output, rawOutput, `operator.${operatorKey} output`);
|
|
547
|
+
}
|
|
548
|
+
throw new TerminalError(`operator.${operatorKey}: exceeded maxTurns (${op.maxTurns})`);
|
|
549
|
+
}
|
|
550
|
+
async function resolveTools(def, op, operatorKey) {
|
|
551
|
+
const out = [];
|
|
552
|
+
const connections = def.connections ?? {};
|
|
553
|
+
for (const toolPath of op.tools) {
|
|
554
|
+
const [connectionKey, ...actionPath] = toolPath.split(".");
|
|
555
|
+
const connector = connections[connectionKey];
|
|
556
|
+
if (!connector) throw new TerminalError(`operator.${operatorKey}: undeclared tool connection "${connectionKey}"`);
|
|
557
|
+
const action = actionAtPath(connector, actionPath);
|
|
558
|
+
if (!action) throw new TerminalError(`operator.${operatorKey}: tool "${toolPath}" is not a declared Action`);
|
|
559
|
+
const inputSchema = await schemaJson(action.input);
|
|
560
|
+
out.push({
|
|
561
|
+
connectionKey,
|
|
562
|
+
actionPath,
|
|
563
|
+
action,
|
|
564
|
+
descriptor: {
|
|
565
|
+
name: toolName(toolPath),
|
|
566
|
+
path: toolPath,
|
|
567
|
+
...action.describe !== void 0 ? { description: action.describe } : {},
|
|
568
|
+
...inputSchema !== void 0 ? { inputSchema } : {}
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
function toolForCall(tools, call) {
|
|
575
|
+
return tools.find((t) => call.name === t.descriptor.name || call.name === t.descriptor.path);
|
|
576
|
+
}
|
|
577
|
+
function toolName(path) {
|
|
578
|
+
return path.replace(/[^A-Za-z0-9_-]/g, "__");
|
|
579
|
+
}
|
|
580
|
+
function actionFunction(connections, connectionKey, actionPath) {
|
|
581
|
+
let node = connections[connectionKey];
|
|
582
|
+
for (const seg of actionPath) node = node?.[seg];
|
|
583
|
+
if (typeof node !== "function") {
|
|
584
|
+
throw new TerminalError(`operator tool ${connectionKey}.${actionPath.join(".")} is not callable`);
|
|
585
|
+
}
|
|
586
|
+
return node;
|
|
587
|
+
}
|
|
588
|
+
function validateModelResponse(response, operatorKey, turn) {
|
|
589
|
+
if (typeof response !== "object" || response === null) {
|
|
590
|
+
throw new TerminalError(`operator.${operatorKey} turn ${turn}: model adapter returned a non-object response`);
|
|
591
|
+
}
|
|
592
|
+
if (!response.usage || typeof response.usage.inputTokens !== "number" || typeof response.usage.outputTokens !== "number") {
|
|
593
|
+
throw new TerminalError(`operator.${operatorKey} turn ${turn}: model response missing usage tokens`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function addUsage(usage, response) {
|
|
597
|
+
usage.tokens += response.usage.inputTokens + response.usage.outputTokens + (response.usage.reasoningTokens ?? 0);
|
|
598
|
+
usage.outputTokens += response.usage.outputTokens;
|
|
599
|
+
}
|
|
600
|
+
function assertBudget(def, model2, usage, operatorKey) {
|
|
601
|
+
const budget = def.budget?.models;
|
|
602
|
+
if (!budget) throw new TerminalError(`operator.${operatorKey}: budget.models is required`);
|
|
603
|
+
if (usage.tokens >= budget.tokens) {
|
|
604
|
+
throw new TerminalError(`operator.${operatorKey}: model token budget exceeded (${usage.tokens}/${budget.tokens})`);
|
|
605
|
+
}
|
|
606
|
+
if (usage.outputTokens >= budget.outputTokens) {
|
|
607
|
+
throw new TerminalError(
|
|
608
|
+
`operator.${operatorKey}: model output-token budget exceeded (${usage.outputTokens}/${budget.outputTokens})`
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const maxOut = model2.settings?.maxOutputTokens;
|
|
612
|
+
if (maxOut !== void 0 && maxOut > budget.outputTokens - usage.outputTokens) {
|
|
613
|
+
throw new TerminalError(`operator.${operatorKey}: model maxOutputTokens exceeds remaining output-token budget`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function parseJson(content, operatorKey, turn) {
|
|
617
|
+
if (!content) throw new TerminalError(`operator.${operatorKey} turn ${turn}: model returned no output`);
|
|
618
|
+
const trimmed = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
619
|
+
try {
|
|
620
|
+
return JSON.parse(trimmed);
|
|
621
|
+
} catch (cause) {
|
|
622
|
+
throw new TerminalError(`operator.${operatorKey} turn ${turn}: model output was not JSON`, { cause });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function toSerializable(value, what) {
|
|
626
|
+
try {
|
|
627
|
+
return decodeJournal(encodeJournal(value));
|
|
628
|
+
} catch (cause) {
|
|
629
|
+
throw new TerminalError(`${what} is not serializable`, { cause });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function minimizeToolOutput(value, opts) {
|
|
633
|
+
const raw = JSON.stringify({ tainted: opts.tainted, value: encodeJournal(value) });
|
|
634
|
+
return raw.length <= 4e3 ? raw : raw.slice(0, 3997) + "...";
|
|
635
|
+
}
|
|
636
|
+
async function schemaJson(schema) {
|
|
637
|
+
const std = schema["~standard"];
|
|
638
|
+
try {
|
|
639
|
+
let json;
|
|
640
|
+
const direct = schema;
|
|
641
|
+
if (typeof direct.toJSONSchema === "function") json = direct.toJSONSchema();
|
|
642
|
+
else if (std?.vendor === "zod") {
|
|
643
|
+
const zod = await import("zod");
|
|
644
|
+
const convert = zod.toJSONSchema ?? zod.z?.toJSONSchema;
|
|
645
|
+
if (convert) json = convert(schema, { unrepresentable: "any" });
|
|
646
|
+
}
|
|
647
|
+
if (json === void 0) return void 0;
|
|
648
|
+
return decodeJournal(encodeJournal(json));
|
|
649
|
+
} catch {
|
|
650
|
+
return void 0;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// packages/sdk/src/internal/harnesses.ts
|
|
655
|
+
function buildHarnesses(def, _ctx, callHarness) {
|
|
656
|
+
const out = {};
|
|
657
|
+
const harnesses = def.harnesses ?? {};
|
|
658
|
+
for (const [harnessKey, h] of Object.entries(harnesses)) {
|
|
659
|
+
out[harnessKey] = {
|
|
660
|
+
run: async (request) => {
|
|
661
|
+
if (!request || typeof request.prompt !== "string" || request.prompt.length === 0) {
|
|
662
|
+
throw new TerminalError(`harness.${harnessKey}: prompt is required`);
|
|
663
|
+
}
|
|
664
|
+
if (!request.output) throw new TerminalError(`harness.${harnessKey}: output schema is required`);
|
|
665
|
+
const raw = await callHarness({ automationId: def.id, harnessKey, harness: h, request });
|
|
666
|
+
const serial = toSerializable2(raw, `harness.${harnessKey} result`);
|
|
667
|
+
const output = await validateSchema(request.output, serial.output, `harness.${harnessKey} output`);
|
|
668
|
+
return { ...serial, output: toSerializable2(output, `harness.${harnessKey} output`) };
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
return Object.freeze(out);
|
|
673
|
+
}
|
|
674
|
+
function toSerializable2(value, what) {
|
|
675
|
+
try {
|
|
676
|
+
return decodeJournal(encodeJournal(value));
|
|
677
|
+
} catch (cause) {
|
|
678
|
+
throw new TerminalError(`${what} is not serializable`, { cause });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// packages/cli/src/exit-codes.ts
|
|
683
|
+
var EXIT = {
|
|
684
|
+
OK: 0,
|
|
685
|
+
/** Unexpected/internal error. */
|
|
686
|
+
ERROR: 1,
|
|
687
|
+
/** Usage error — bad arguments or no linked project. */
|
|
688
|
+
USAGE: 2,
|
|
689
|
+
/** Tests failed (machine-actionable detail on stdout with --json). */
|
|
690
|
+
TESTS_FAILED: 3,
|
|
691
|
+
/** Deploy halted: credentials needed — a connect link is on stdout. */
|
|
692
|
+
HALTED_CREDENTIALS: 4,
|
|
693
|
+
/** Could not reach or authenticate against the instance. */
|
|
694
|
+
AUTH: 5,
|
|
695
|
+
/** Resource not found. */
|
|
696
|
+
NOT_FOUND: 6,
|
|
697
|
+
/** Conflict / invalid state for the requested operation. */
|
|
698
|
+
CONFLICT: 7,
|
|
699
|
+
/** Deploy failed (build error or red test gate). */
|
|
700
|
+
DEPLOY_FAILED: 8
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// packages/cli/src/output.ts
|
|
704
|
+
var Output = class {
|
|
705
|
+
constructor(json) {
|
|
706
|
+
this.json = json;
|
|
707
|
+
}
|
|
708
|
+
json;
|
|
709
|
+
/** Emit the command's data result. `human` renders the no-JSON form. */
|
|
710
|
+
data(value, human) {
|
|
711
|
+
if (this.json) {
|
|
712
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
713
|
+
} else {
|
|
714
|
+
process.stdout.write((human ? human(value) : JSON.stringify(value, null, 2)) + "\n");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Progress/notes → stderr, never stdout. */
|
|
718
|
+
log(msg) {
|
|
719
|
+
process.stderr.write(msg + "\n");
|
|
720
|
+
}
|
|
721
|
+
fail(code, message, extra) {
|
|
722
|
+
if (this.json) {
|
|
723
|
+
process.stdout.write(JSON.stringify({ error: { code, message, ...extra } }, null, 2) + "\n");
|
|
724
|
+
} else {
|
|
725
|
+
process.stderr.write(`error: ${message}
|
|
726
|
+
`);
|
|
727
|
+
if (extra) process.stderr.write(JSON.stringify(extra, null, 2) + "\n");
|
|
728
|
+
}
|
|
729
|
+
process.exit(code);
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
var CliError = class extends Error {
|
|
733
|
+
constructor(code, message, extra) {
|
|
734
|
+
super(message);
|
|
735
|
+
this.code = code;
|
|
736
|
+
this.extra = extra;
|
|
737
|
+
}
|
|
738
|
+
code;
|
|
739
|
+
extra;
|
|
740
|
+
};
|
|
741
|
+
function toExit(err, out) {
|
|
742
|
+
if (err instanceof CliError) out.fail(err.code, err.message, err.extra);
|
|
743
|
+
out.fail(EXIT.ERROR, err instanceof Error ? err.message : String(err));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// packages/cli/src/client.ts
|
|
747
|
+
var ApiClient = class {
|
|
748
|
+
constructor(baseUrl, token) {
|
|
749
|
+
this.baseUrl = baseUrl;
|
|
750
|
+
this.token = token;
|
|
751
|
+
}
|
|
752
|
+
baseUrl;
|
|
753
|
+
token;
|
|
754
|
+
async request(method, path, body) {
|
|
755
|
+
if (!this.token) {
|
|
756
|
+
throw new CliError(
|
|
757
|
+
EXIT.AUTH,
|
|
758
|
+
"no API token \u2014 run `tesser login --url <instance> --token <tsk_\u2026>` or set TESSER_TOKEN"
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
let res;
|
|
762
|
+
try {
|
|
763
|
+
res = await fetch(`${this.baseUrl}/api${path}`, {
|
|
764
|
+
method,
|
|
765
|
+
headers: {
|
|
766
|
+
authorization: `Bearer ${this.token}`,
|
|
767
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
768
|
+
},
|
|
769
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
770
|
+
});
|
|
771
|
+
} catch (cause) {
|
|
772
|
+
throw new CliError(EXIT.AUTH, `cannot reach instance at ${this.baseUrl} (${String(cause)})`);
|
|
773
|
+
}
|
|
774
|
+
const text = await res.text();
|
|
775
|
+
let parsed;
|
|
776
|
+
try {
|
|
777
|
+
parsed = text.length > 0 ? JSON.parse(text) : null;
|
|
778
|
+
} catch {
|
|
779
|
+
parsed = { raw: text };
|
|
780
|
+
}
|
|
781
|
+
if (!res.ok) {
|
|
782
|
+
const errBody = parsed;
|
|
783
|
+
const message = errBody?.error?.message ?? `instance responded ${res.status}`;
|
|
784
|
+
if (res.status === 401) throw new CliError(EXIT.AUTH, message);
|
|
785
|
+
if (res.status === 404) throw new CliError(EXIT.NOT_FOUND, message);
|
|
786
|
+
if (res.status === 400) throw new CliError(EXIT.USAGE, message);
|
|
787
|
+
if (res.status === 409) throw new CliError(EXIT.CONFLICT, message);
|
|
788
|
+
throw new CliError(EXIT.ERROR, message);
|
|
789
|
+
}
|
|
790
|
+
return parsed;
|
|
791
|
+
}
|
|
792
|
+
get(path) {
|
|
793
|
+
return this.request("GET", path);
|
|
794
|
+
}
|
|
795
|
+
post(path, body) {
|
|
796
|
+
return this.request("POST", path, body);
|
|
797
|
+
}
|
|
798
|
+
put(path, body) {
|
|
799
|
+
return this.request("PUT", path, body);
|
|
800
|
+
}
|
|
801
|
+
delete(path) {
|
|
802
|
+
return this.request("DELETE", path);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// packages/cli/src/config.ts
|
|
807
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
808
|
+
import { homedir } from "node:os";
|
|
809
|
+
import { dirname, join } from "node:path";
|
|
810
|
+
var CONFIG_PATH = join(
|
|
811
|
+
process.env["TESSER_CONFIG_DIR"] ?? join(homedir(), ".config", "tesser"),
|
|
812
|
+
"config.json"
|
|
813
|
+
);
|
|
814
|
+
function readConfig() {
|
|
815
|
+
try {
|
|
816
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
817
|
+
} catch {
|
|
818
|
+
return {};
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function writeConfig(config) {
|
|
822
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
823
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
824
|
+
}
|
|
825
|
+
function activeProfile(config, name) {
|
|
826
|
+
const profileName = name ?? config.current ?? "default";
|
|
827
|
+
return config.profiles?.[profileName] ?? {};
|
|
828
|
+
}
|
|
829
|
+
function findProjectRoot(start = process.cwd()) {
|
|
830
|
+
let dir = start;
|
|
831
|
+
for (; ; ) {
|
|
832
|
+
if (existsSync(join(dir, "tesser.json"))) return dir;
|
|
833
|
+
const parent = dirname(dir);
|
|
834
|
+
if (parent === dir) return null;
|
|
835
|
+
dir = parent;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function readLinkManifest(root) {
|
|
839
|
+
try {
|
|
840
|
+
return JSON.parse(readFileSync(join(root, "tesser.json"), "utf8"));
|
|
841
|
+
} catch {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function resolveTarget(opts) {
|
|
846
|
+
const config = readConfig();
|
|
847
|
+
const profile = activeProfile(config, opts.profile);
|
|
848
|
+
const projectRoot = findProjectRoot();
|
|
849
|
+
const manifest = projectRoot ? readLinkManifest(projectRoot) : null;
|
|
850
|
+
return {
|
|
851
|
+
url: opts.url ?? manifest?.instance ?? process.env["TESSER_URL"] ?? profile.url ?? "http://localhost:8377",
|
|
852
|
+
token: opts.token ?? process.env["TESSER_TOKEN"] ?? profile.token,
|
|
853
|
+
project: manifest?.project,
|
|
854
|
+
projectRoot
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// packages/cli/src/project.ts
|
|
859
|
+
import { mkdtempSync, existsSync as existsSync2, readdirSync, statSync } from "node:fs";
|
|
860
|
+
import { tmpdir } from "node:os";
|
|
861
|
+
import { join as join2 } from "node:path";
|
|
862
|
+
import { pathToFileURL } from "node:url";
|
|
863
|
+
import { build } from "esbuild";
|
|
864
|
+
function discoverLocalAutomations(projectRoot) {
|
|
865
|
+
const root = join2(projectRoot, "automations");
|
|
866
|
+
if (!existsSync2(root)) return [];
|
|
867
|
+
const out = [];
|
|
868
|
+
for (const name of readdirSync(root).sort()) {
|
|
869
|
+
const dir = join2(root, name);
|
|
870
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
871
|
+
const entry = join2(dir, "index.ts");
|
|
872
|
+
if (!existsSync2(entry)) continue;
|
|
873
|
+
const hasTests = readdirSync(dir).some((f) => /\.test\.(ts|js|mts|mjs)$/.test(f));
|
|
874
|
+
out.push({ automationId: name, dir, entry, hasTests });
|
|
875
|
+
}
|
|
876
|
+
return out;
|
|
877
|
+
}
|
|
878
|
+
async function loadAutomationDef(entry) {
|
|
879
|
+
const outDir = mkdtempSync(join2(tmpdir(), "tesser-cli-"));
|
|
880
|
+
const outFile = join2(outDir, "bundle.mjs");
|
|
881
|
+
await build({
|
|
882
|
+
entryPoints: [entry],
|
|
883
|
+
outfile: outFile,
|
|
884
|
+
bundle: true,
|
|
885
|
+
platform: "node",
|
|
886
|
+
format: "esm",
|
|
887
|
+
target: "node20",
|
|
888
|
+
packages: "bundle",
|
|
889
|
+
logLevel: "silent"
|
|
890
|
+
});
|
|
891
|
+
const mod = await import(pathToFileURL(outFile).href);
|
|
892
|
+
if (!mod.default || typeof mod.default.run !== "function") {
|
|
893
|
+
throw new Error(`${entry}: no default export from defineAutomation`);
|
|
894
|
+
}
|
|
895
|
+
return mod.default;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// packages/cli/src/commands/auth.ts
|
|
899
|
+
import { spawn } from "node:child_process";
|
|
900
|
+
async function authClaudeCode(out, instanceUrl, opts) {
|
|
901
|
+
const mode = opts.mode ?? "subscription";
|
|
902
|
+
if (mode !== "subscription" && mode !== "apiKey") {
|
|
903
|
+
throw new CliError(EXIT.USAGE, "claude-code auth mode must be subscription or apiKey");
|
|
904
|
+
}
|
|
905
|
+
const token = opts.tokenStdin === true || opts.fromEnv !== void 0 ? await tokenFromPipeOrEnv(opts) : mode === "subscription" ? await runClaudeSetupToken(out, opts.bin ?? "claude") : await tokenFromPipeOrEnv({ ...opts, tokenStdin: true });
|
|
906
|
+
await postConnection({
|
|
907
|
+
instanceUrl,
|
|
908
|
+
connect: opts.connect,
|
|
909
|
+
connector: "claude-code",
|
|
910
|
+
mode,
|
|
911
|
+
scope: opts.scope ?? "workspace",
|
|
912
|
+
...opts.endUserId !== void 0 ? { endUserId: opts.endUserId } : {},
|
|
913
|
+
fields: mode === "subscription" ? { oauth_token: token } : { api_key: token }
|
|
914
|
+
});
|
|
915
|
+
out.data({ connector: "claude-code", mode, connected: true }, () => `claude-code ${mode} connected \u2713`);
|
|
916
|
+
}
|
|
917
|
+
async function authPi(out, instanceUrl, opts) {
|
|
918
|
+
const mode = opts.mode ?? "anthropicOAuth";
|
|
919
|
+
if (mode !== "anthropicOAuth" && mode !== "anthropicApiKey") {
|
|
920
|
+
throw new CliError(EXIT.USAGE, "pi auth mode must be anthropicOAuth or anthropicApiKey");
|
|
921
|
+
}
|
|
922
|
+
const token = await tokenFromPipeOrEnv(opts);
|
|
923
|
+
await postConnection({
|
|
924
|
+
instanceUrl,
|
|
925
|
+
connect: opts.connect,
|
|
926
|
+
connector: "pi",
|
|
927
|
+
mode,
|
|
928
|
+
scope: opts.scope ?? "workspace",
|
|
929
|
+
...opts.endUserId !== void 0 ? { endUserId: opts.endUserId } : {},
|
|
930
|
+
fields: mode === "anthropicOAuth" ? { oauth_token: token } : { api_key: token }
|
|
931
|
+
});
|
|
932
|
+
out.data({ connector: "pi", mode, connected: true }, () => `pi ${mode} connected \u2713`);
|
|
933
|
+
}
|
|
934
|
+
async function tokenFromPipeOrEnv(opts) {
|
|
935
|
+
if (opts.fromEnv !== void 0) {
|
|
936
|
+
const value2 = process.env[opts.fromEnv];
|
|
937
|
+
if (!value2) throw new CliError(EXIT.USAGE, `env ${opts.fromEnv} is empty or missing`);
|
|
938
|
+
return value2;
|
|
939
|
+
}
|
|
940
|
+
if (opts.tokenStdin !== true) {
|
|
941
|
+
throw new CliError(EXIT.USAGE, "pass --token-stdin or --from-env <NAME> so the token never appears in argv");
|
|
942
|
+
}
|
|
943
|
+
const chunks = [];
|
|
944
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
945
|
+
const value = Buffer.concat(chunks).toString("utf8").trim();
|
|
946
|
+
if (value.length === 0) throw new CliError(EXIT.USAGE, "empty token on stdin");
|
|
947
|
+
return value;
|
|
948
|
+
}
|
|
949
|
+
async function runClaudeSetupToken(out, bin) {
|
|
950
|
+
out.log("starting `claude setup-token`; complete the browser login if prompted...");
|
|
951
|
+
const raw = await runInteractiveCapture(bin, ["setup-token"]);
|
|
952
|
+
const token = extractClaudeToken(raw);
|
|
953
|
+
if (!token) {
|
|
954
|
+
throw new CliError(
|
|
955
|
+
EXIT.ERROR,
|
|
956
|
+
"could not find a Claude Code OAuth token in `claude setup-token` output; rerun with `claude setup-token | tesser auth claude-code --connect <url> --token-stdin`"
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
return token;
|
|
960
|
+
}
|
|
961
|
+
function runInteractiveCapture(command, args) {
|
|
962
|
+
return new Promise((resolve, reject) => {
|
|
963
|
+
const child = spawn(command, args, { stdio: ["inherit", "pipe", "pipe"] });
|
|
964
|
+
let raw = "";
|
|
965
|
+
const onData = (buf) => {
|
|
966
|
+
const text = buf.toString("utf8");
|
|
967
|
+
raw += text;
|
|
968
|
+
process.stderr.write(sanitizeSetupOutput(text));
|
|
969
|
+
};
|
|
970
|
+
child.stdout.on("data", onData);
|
|
971
|
+
child.stderr.on("data", onData);
|
|
972
|
+
child.on("error", reject);
|
|
973
|
+
child.on("close", (code) => {
|
|
974
|
+
if (code === 0) resolve(raw);
|
|
975
|
+
else reject(new CliError(EXIT.ERROR, `${command} ${args.join(" ")} exited ${code ?? 1}`));
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
function extractClaudeToken(raw) {
|
|
980
|
+
const envMatch = raw.match(/CLAUDE_CODE_OAUTH_TOKEN\s*=\s*([^\s]+)/);
|
|
981
|
+
if (envMatch?.[1]) return envMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
|
982
|
+
const candidates = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => /^[A-Za-z0-9._-]{40,}$/.test(l) && !/^https?:/i.test(l));
|
|
983
|
+
return candidates.at(-1) ?? null;
|
|
984
|
+
}
|
|
985
|
+
function sanitizeSetupOutput(text) {
|
|
986
|
+
return text.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)[^\s]+/g, "$1[redacted]").replace(/\b[A-Za-z0-9._-]{64,}\b/g, "[redacted-token]");
|
|
987
|
+
}
|
|
988
|
+
async function postConnection(opts) {
|
|
989
|
+
const url = connectPostUrl(opts.instanceUrl, opts.connect);
|
|
990
|
+
const body = new URLSearchParams({ connector: opts.connector, mode: opts.mode, scope: opts.scope });
|
|
991
|
+
if (opts.endUserId !== void 0) body.set("end_user_id", opts.endUserId);
|
|
992
|
+
for (const [key, value] of Object.entries(opts.fields)) body.set(`field_${key}`, value);
|
|
993
|
+
const res = await fetch(url, {
|
|
994
|
+
method: "POST",
|
|
995
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
996
|
+
body: body.toString(),
|
|
997
|
+
redirect: "manual"
|
|
998
|
+
});
|
|
999
|
+
if (res.status >= 300 && res.status < 400) return;
|
|
1000
|
+
const text = await res.text().catch(() => "");
|
|
1001
|
+
throw new CliError(EXIT.ERROR, `connect page rejected ${opts.connector} ${opts.mode}: ${res.status}${text ? ` ${text}` : ""}`);
|
|
1002
|
+
}
|
|
1003
|
+
function connectPostUrl(instanceUrl, connect) {
|
|
1004
|
+
if (/^https?:\/\//i.test(connect)) {
|
|
1005
|
+
const u = new URL(connect);
|
|
1006
|
+
const match = u.pathname.match(/\/connect\/([^/]+)/);
|
|
1007
|
+
if (!match?.[1]) throw new CliError(EXIT.USAGE, "--connect must be a Tesser /connect/<token> URL or token");
|
|
1008
|
+
return `${u.origin}/connect/${match[1]}/connection`;
|
|
1009
|
+
}
|
|
1010
|
+
const token = connect.replace(/^\/?connect\//, "");
|
|
1011
|
+
if (!/^cl_[a-f0-9]+$/i.test(token)) throw new CliError(EXIT.USAGE, "--connect must be a Tesser /connect/<token> URL or token");
|
|
1012
|
+
return `${instanceUrl.replace(/\/$/, "")}/connect/${token}/connection`;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// packages/cli/src/commands/deploy.ts
|
|
1016
|
+
async function deploy(out, api2, project, opts) {
|
|
1017
|
+
await api2.post(`/projects/${project}/sync`, {
|
|
1018
|
+
...opts.ref !== void 0 ? { ref: opts.ref } : {},
|
|
1019
|
+
...opts.local !== void 0 ? { localPath: opts.local } : {}
|
|
1020
|
+
});
|
|
1021
|
+
out.log(`sync queued for ${project}${opts.local ? " (local tree)" : opts.ref ? ` @ ${opts.ref}` : ""}`);
|
|
1022
|
+
if (opts.wait === false) {
|
|
1023
|
+
out.data({ queued: true });
|
|
1024
|
+
process.exit(EXIT.OK);
|
|
1025
|
+
}
|
|
1026
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 10 * 6e4);
|
|
1027
|
+
let last = {};
|
|
1028
|
+
while (Date.now() < deadline) {
|
|
1029
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
1030
|
+
last = await api2.get(`/projects/${project}/deploys/latest`);
|
|
1031
|
+
const status = last.repo?.status;
|
|
1032
|
+
if (status === "syncing" || status === "idle" || status === void 0) continue;
|
|
1033
|
+
const report = last.repo?.report ?? {};
|
|
1034
|
+
if (status === "halted-credentials") {
|
|
1035
|
+
out.data(
|
|
1036
|
+
{ status, connectUrl: report.connectUrl, report },
|
|
1037
|
+
() => `deploy HALTED \u2014 credentials needed.
|
|
1038
|
+
Open this link in a browser to connect:
|
|
1039
|
+
${report.connectUrl}
|
|
1040
|
+
Then rerun: tesser deploy${opts.local ? " --local" : ""}`
|
|
1041
|
+
);
|
|
1042
|
+
process.exit(EXIT.HALTED_CREDENTIALS);
|
|
1043
|
+
}
|
|
1044
|
+
if (status === "failed") {
|
|
1045
|
+
out.data(
|
|
1046
|
+
{ status, report, error: last.repo?.error },
|
|
1047
|
+
() => [
|
|
1048
|
+
"deploy FAILED:",
|
|
1049
|
+
...(report.failed ?? []).map((f) => ` ${f.automation} [${f.stage}]: ${f.reason.split("\n")[0]}`),
|
|
1050
|
+
...last.repo?.error ? [` ${last.repo.error}`] : []
|
|
1051
|
+
].join("\n")
|
|
1052
|
+
);
|
|
1053
|
+
process.exit(EXIT.DEPLOY_FAILED);
|
|
1054
|
+
}
|
|
1055
|
+
if (status === "synced") {
|
|
1056
|
+
out.data(
|
|
1057
|
+
{ status, report, live: last.live },
|
|
1058
|
+
() => [
|
|
1059
|
+
`deploy OK @ ${report.sha?.slice(0, 8) ?? "?"} \u2192 ${report.env}`,
|
|
1060
|
+
` built: ${report.built?.join(", ") || "(none)"}`,
|
|
1061
|
+
` unchanged: ${report.unchanged?.join(", ") || "(none)"}`,
|
|
1062
|
+
...report.removed?.length ? [` removed: ${report.removed.join(", ")}`] : []
|
|
1063
|
+
].join("\n")
|
|
1064
|
+
);
|
|
1065
|
+
process.exit(EXIT.OK);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
throw new CliError(EXIT.ERROR, "timed out waiting for the deploy to settle", { last });
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// packages/cli/src/commands/dev.ts
|
|
1072
|
+
import { randomBytes } from "node:crypto";
|
|
1073
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1074
|
+
import { existsSync as existsSync3, watch } from "node:fs";
|
|
1075
|
+
import { join as join3, dirname as dirname2 } from "node:path";
|
|
1076
|
+
function findServerBin(start) {
|
|
1077
|
+
const envBin = process.env["TESSER_SERVER_BIN"];
|
|
1078
|
+
if (envBin && existsSync3(envBin)) {
|
|
1079
|
+
return envBin.endsWith(".mjs") || envBin.endsWith(".js") ? [process.execPath, envBin] : [envBin];
|
|
1080
|
+
}
|
|
1081
|
+
let dir = start;
|
|
1082
|
+
for (; ; ) {
|
|
1083
|
+
const entry = join3(dir, "node_modules", "@devosurf", "tesser-server", "bin", "tesser-server.mjs");
|
|
1084
|
+
if (existsSync3(entry)) return [process.execPath, entry];
|
|
1085
|
+
const shim = join3(dir, "node_modules", ".bin", "tesser-server");
|
|
1086
|
+
if (existsSync3(shim)) return [shim];
|
|
1087
|
+
const parent = dirname2(dir);
|
|
1088
|
+
if (parent === dir) return null;
|
|
1089
|
+
dir = parent;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async function dev(out, projectRoot, project, opts) {
|
|
1093
|
+
const bin = findServerBin(projectRoot);
|
|
1094
|
+
if (!bin) {
|
|
1095
|
+
throw new CliError(
|
|
1096
|
+
EXIT.USAGE,
|
|
1097
|
+
"tesser-server binary not found \u2014 install @devosurf/tesser-server (pnpm add -D @devosurf/tesser-server) or set TESSER_SERVER_BIN"
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const port = opts.port ?? 8377;
|
|
1101
|
+
const token = `tsk_${randomBytes(24).toString("hex")}`;
|
|
1102
|
+
const url = `http://localhost:${port}`;
|
|
1103
|
+
out.log(`starting local instance on ${url} (embedded postgres at .tesser/pglite)`);
|
|
1104
|
+
const child = spawn2(bin[0], bin.slice(1), {
|
|
1105
|
+
env: {
|
|
1106
|
+
...process.env,
|
|
1107
|
+
PORT: String(port),
|
|
1108
|
+
TESSER_DATA_DIR: join3(projectRoot, ".tesser"),
|
|
1109
|
+
TESSER_BOOTSTRAP_TOKEN: token,
|
|
1110
|
+
TESSER_BASE_URL: url,
|
|
1111
|
+
DATABASE_URL: ""
|
|
1112
|
+
},
|
|
1113
|
+
stdio: ["ignore", "inherit", "inherit"]
|
|
1114
|
+
});
|
|
1115
|
+
const stop = () => {
|
|
1116
|
+
child.kill("SIGTERM");
|
|
1117
|
+
};
|
|
1118
|
+
process.on("SIGINT", () => {
|
|
1119
|
+
stop();
|
|
1120
|
+
process.exit(0);
|
|
1121
|
+
});
|
|
1122
|
+
process.on("SIGTERM", stop);
|
|
1123
|
+
const api2 = new ApiClient(url, token);
|
|
1124
|
+
const deadline = Date.now() + 3e4;
|
|
1125
|
+
for (; ; ) {
|
|
1126
|
+
try {
|
|
1127
|
+
await api2.get("/health");
|
|
1128
|
+
break;
|
|
1129
|
+
} catch {
|
|
1130
|
+
if (Date.now() > deadline) throw new CliError(EXIT.ERROR, "local instance did not come up");
|
|
1131
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
await api2.post("/projects", { name: project });
|
|
1135
|
+
const syncOnce = async () => {
|
|
1136
|
+
await api2.post(`/projects/${project}/sync`, { localPath: projectRoot });
|
|
1137
|
+
for (let i = 0; i < 600; i++) {
|
|
1138
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1139
|
+
const state = await api2.get(
|
|
1140
|
+
`/projects/${project}/deploys/latest`
|
|
1141
|
+
);
|
|
1142
|
+
const status = state.repo?.status;
|
|
1143
|
+
if (status === "syncing") continue;
|
|
1144
|
+
if (status === "halted-credentials") {
|
|
1145
|
+
out.log(`HALTED \u2014 connect credentials in your browser:
|
|
1146
|
+
${state.repo?.report?.connectUrl}`);
|
|
1147
|
+
} else if (status === "failed") {
|
|
1148
|
+
out.log(`deploy failed: ${JSON.stringify(state.repo?.report?.failed ?? [])}`);
|
|
1149
|
+
} else if (status === "synced") {
|
|
1150
|
+
out.log(`deployed \u2713 \u2014 webhooks at ${url}/hooks/${project}/<automation>`);
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
await syncOnce();
|
|
1156
|
+
if (opts.watch !== false) {
|
|
1157
|
+
let timer = null;
|
|
1158
|
+
watch(join3(projectRoot, "automations"), { recursive: true }, () => {
|
|
1159
|
+
if (timer) clearTimeout(timer);
|
|
1160
|
+
timer = setTimeout(() => {
|
|
1161
|
+
out.log("change detected \u2014 redeploying\u2026");
|
|
1162
|
+
void syncOnce();
|
|
1163
|
+
}, 400);
|
|
1164
|
+
});
|
|
1165
|
+
out.log("watching automations/ for changes (ctrl-c to stop)");
|
|
1166
|
+
await new Promise(() => {
|
|
1167
|
+
});
|
|
1168
|
+
} else {
|
|
1169
|
+
stop();
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// packages/cli/src/commands/init.ts
|
|
1174
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1175
|
+
import { join as join4 } from "node:path";
|
|
1176
|
+
var EXAMPLE_AUTOMATION = `import { defineAutomation, onWebhook } from "@devosurf/tesser-sdk";
|
|
1177
|
+
import { z } from "zod";
|
|
1178
|
+
|
|
1179
|
+
export default defineAutomation({
|
|
1180
|
+
id: "hello",
|
|
1181
|
+
trigger: onWebhook({ input: z.object({ name: z.string().default("world") }) }),
|
|
1182
|
+
output: z.object({ greeting: z.string() }),
|
|
1183
|
+
|
|
1184
|
+
run: async (input, ctx) => {
|
|
1185
|
+
// Plain TypeScript. Durability ONLY from ctx.step() (every side effect goes inside one).
|
|
1186
|
+
const greeting = await ctx.step("compose", async () => \`hello, \${input.name}!\`);
|
|
1187
|
+
return { greeting };
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
`;
|
|
1191
|
+
var EXAMPLE_TEST = `import { createTest } from "@devosurf/tesser-testing";
|
|
1192
|
+
import automation from "./index";
|
|
1193
|
+
|
|
1194
|
+
test("greets by name", async () => {
|
|
1195
|
+
const t = createTest({ automation });
|
|
1196
|
+
const { result } = await t.run({ input: { name: "tesser" } });
|
|
1197
|
+
expect(result).toEqual({ greeting: "hello, tesser!" });
|
|
1198
|
+
});
|
|
1199
|
+
`;
|
|
1200
|
+
function init(out, name, opts) {
|
|
1201
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
|
|
1202
|
+
throw new CliError(EXIT.USAGE, "project name must be kebab-case");
|
|
1203
|
+
}
|
|
1204
|
+
const root = join4(opts.dir ?? process.cwd(), name);
|
|
1205
|
+
if (existsSync4(join4(root, "tesser.json"))) {
|
|
1206
|
+
throw new CliError(EXIT.CONFLICT, `${root} is already a Tesser project`);
|
|
1207
|
+
}
|
|
1208
|
+
mkdirSync2(join4(root, "automations", "hello"), { recursive: true });
|
|
1209
|
+
writeFileSync2(
|
|
1210
|
+
join4(root, "tesser.json"),
|
|
1211
|
+
JSON.stringify({ project: name, ...opts.instance !== void 0 ? { instance: opts.instance } : {} }, null, 2) + "\n"
|
|
1212
|
+
);
|
|
1213
|
+
writeFileSync2(
|
|
1214
|
+
join4(root, "package.json"),
|
|
1215
|
+
JSON.stringify(
|
|
1216
|
+
{
|
|
1217
|
+
name,
|
|
1218
|
+
private: true,
|
|
1219
|
+
type: "module",
|
|
1220
|
+
packageManager: "pnpm@9.12.0",
|
|
1221
|
+
scripts: { test: "tesser test", deploy: "tesser deploy", dev: "tesser dev" },
|
|
1222
|
+
dependencies: { "@devosurf/tesser-sdk": "latest", "@devosurf/tesser-connectors": "latest", zod: "^4" },
|
|
1223
|
+
devDependencies: {
|
|
1224
|
+
"@devosurf/tesser": "latest",
|
|
1225
|
+
"@devosurf/tesser-server": "latest",
|
|
1226
|
+
"@devosurf/tesser-testing": "latest",
|
|
1227
|
+
vitest: "^4"
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
null,
|
|
1231
|
+
2
|
|
1232
|
+
) + "\n"
|
|
1233
|
+
);
|
|
1234
|
+
writeFileSync2(
|
|
1235
|
+
join4(root, "tsconfig.json"),
|
|
1236
|
+
JSON.stringify(
|
|
1237
|
+
{
|
|
1238
|
+
compilerOptions: {
|
|
1239
|
+
target: "ES2022",
|
|
1240
|
+
module: "ESNext",
|
|
1241
|
+
moduleResolution: "Bundler",
|
|
1242
|
+
strict: true,
|
|
1243
|
+
skipLibCheck: true,
|
|
1244
|
+
types: ["vitest/globals"]
|
|
1245
|
+
},
|
|
1246
|
+
include: ["automations"]
|
|
1247
|
+
},
|
|
1248
|
+
null,
|
|
1249
|
+
2
|
|
1250
|
+
) + "\n"
|
|
1251
|
+
);
|
|
1252
|
+
writeFileSync2(
|
|
1253
|
+
join4(root, "vitest.config.ts"),
|
|
1254
|
+
`import { defineConfig } from "vitest/config";
|
|
1255
|
+
export default defineConfig({ test: { globals: true, include: ["automations/**/*.test.ts"] } });
|
|
1256
|
+
`
|
|
1257
|
+
);
|
|
1258
|
+
writeFileSync2(join4(root, ".gitignore"), "node_modules/\n.tesser/\n.env\n");
|
|
1259
|
+
writeFileSync2(join4(root, "automations", "hello", "index.ts"), EXAMPLE_AUTOMATION);
|
|
1260
|
+
writeFileSync2(join4(root, "automations", "hello", "index.test.ts"), EXAMPLE_TEST);
|
|
1261
|
+
out.data(
|
|
1262
|
+
{ created: root, next: ["cd " + name, "git init && git add -A && git commit -m init", "npm install (or pnpm)", "tesser link", "tesser test"] },
|
|
1263
|
+
() => `created ${root}
|
|
1264
|
+
next:
|
|
1265
|
+
cd ${name}
|
|
1266
|
+
git init && git add -A && git commit -m init
|
|
1267
|
+
pnpm install
|
|
1268
|
+
tesser link # register on your instance
|
|
1269
|
+
tesser test # green in milliseconds`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// packages/cli/src/commands/replay.ts
|
|
1274
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, existsSync as existsSync5 } from "node:fs";
|
|
1275
|
+
import { join as join5 } from "node:path";
|
|
1276
|
+
async function replay(out, api2, projectRoot, runId) {
|
|
1277
|
+
const { replay: run } = await api2.get(`/runs/${runId}/replay`);
|
|
1278
|
+
const dir = join5(projectRoot, "automations", run.automation_id);
|
|
1279
|
+
if (!existsSync5(dir)) {
|
|
1280
|
+
throw new CliError(EXIT.NOT_FOUND, `automation directory not found locally: automations/${run.automation_id}`);
|
|
1281
|
+
}
|
|
1282
|
+
const shortId = run.id.slice(0, 8);
|
|
1283
|
+
const fixtureDir = join5(dir, "__replays__");
|
|
1284
|
+
mkdirSync3(fixtureDir, { recursive: true });
|
|
1285
|
+
const fixturePath = join5(fixtureDir, `${shortId}.replay.json`);
|
|
1286
|
+
writeFileSync3(
|
|
1287
|
+
fixturePath,
|
|
1288
|
+
JSON.stringify(
|
|
1289
|
+
{
|
|
1290
|
+
runId: run.id,
|
|
1291
|
+
automation: run.automation_id,
|
|
1292
|
+
recordedStatus: run.status,
|
|
1293
|
+
trigger: run.trigger,
|
|
1294
|
+
input: run.input,
|
|
1295
|
+
output: run.output,
|
|
1296
|
+
error: run.error,
|
|
1297
|
+
steps: run.journal.filter((s) => !s.name.startsWith("$"))
|
|
1298
|
+
},
|
|
1299
|
+
null,
|
|
1300
|
+
2
|
|
1301
|
+
) + "\n"
|
|
1302
|
+
);
|
|
1303
|
+
const testPath = join5(dir, `replay-${shortId}.test.ts`);
|
|
1304
|
+
writeFileSync3(
|
|
1305
|
+
testPath,
|
|
1306
|
+
`// Regression frozen from run ${run.id} (recorded status: ${run.status}).
|
|
1307
|
+
// Generated by \`tesser replay\` \u2014 adjust the final assertion once the bug is fixed.
|
|
1308
|
+
import { createTest } from "@devosurf/tesser-testing";
|
|
1309
|
+
import automation from "./index";
|
|
1310
|
+
import replay from "./__replays__/${shortId}.replay.json";
|
|
1311
|
+
|
|
1312
|
+
test("replays run ${shortId} exactly", async () => {
|
|
1313
|
+
const t = createTest({ automation });
|
|
1314
|
+
// Recorded step results replay through the journal \u2014 completed steps return their
|
|
1315
|
+
// captured values without executing, exactly like durable recovery (ADR-0002).
|
|
1316
|
+
const journal = replay.steps
|
|
1317
|
+
.filter((s) => s.status === "completed")
|
|
1318
|
+
.map((s) => ({ name: s.name, occurrence: s.occurrence, result: s.result }));
|
|
1319
|
+
const result = await t.run({ input: replay.input ?? undefined, journal });
|
|
1320
|
+
|
|
1321
|
+
// Recorded behaviour at capture time \u2014 keep as the regression contract:
|
|
1322
|
+
expect(result.status).toBe(${JSON.stringify(run.status === "completed" ? "completed" : "failed")});
|
|
1323
|
+
${run.status === "completed" ? ` expect(result.result).toEqual(replay.output);` : ` // This run FAILED in production. After fixing, flip the expectation to "completed".`}
|
|
1324
|
+
});
|
|
1325
|
+
`
|
|
1326
|
+
);
|
|
1327
|
+
out.data(
|
|
1328
|
+
{ fixture: fixturePath, test: testPath, recordedStatus: run.status },
|
|
1329
|
+
() => `frozen run ${shortId} \u2192 ${testPath}
|
|
1330
|
+
fixture: ${fixturePath}
|
|
1331
|
+
run \`tesser test\` to execute it`
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// packages/cli/src/commands/test.ts
|
|
1336
|
+
import { execFile } from "node:child_process";
|
|
1337
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
1338
|
+
import { join as join6 } from "node:path";
|
|
1339
|
+
import { promisify } from "node:util";
|
|
1340
|
+
|
|
1341
|
+
// packages/testing/src/engine.ts
|
|
1342
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1343
|
+
|
|
1344
|
+
// packages/testing/src/spy.ts
|
|
1345
|
+
var order = 1;
|
|
1346
|
+
function createSpy(name) {
|
|
1347
|
+
const mock = {
|
|
1348
|
+
calls: [],
|
|
1349
|
+
results: [],
|
|
1350
|
+
instances: [],
|
|
1351
|
+
contexts: [],
|
|
1352
|
+
invocationCallOrder: [],
|
|
1353
|
+
lastCall: void 0
|
|
1354
|
+
};
|
|
1355
|
+
const captured = [];
|
|
1356
|
+
const fn = ((...args) => {
|
|
1357
|
+
mock.calls.push(args);
|
|
1358
|
+
mock.invocationCallOrder.push(order++);
|
|
1359
|
+
mock.lastCall = args;
|
|
1360
|
+
});
|
|
1361
|
+
fn._isMockFunction = true;
|
|
1362
|
+
fn.getMockName = () => name;
|
|
1363
|
+
fn.mock = mock;
|
|
1364
|
+
fn.captured = captured;
|
|
1365
|
+
return fn;
|
|
1366
|
+
}
|
|
1367
|
+
function recordCall(spy, call) {
|
|
1368
|
+
spy(...call.args);
|
|
1369
|
+
spy.mock.results.push(
|
|
1370
|
+
call.error !== void 0 ? { type: "throw", value: call.error } : { type: "return", value: call.result }
|
|
1371
|
+
);
|
|
1372
|
+
spy.captured.push(call);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// packages/testing/src/sample.ts
|
|
1376
|
+
function toJsonSchema(schema) {
|
|
1377
|
+
const std = schema["~standard"];
|
|
1378
|
+
if (!std) return void 0;
|
|
1379
|
+
try {
|
|
1380
|
+
if (std.vendor === "zod") {
|
|
1381
|
+
const z = schema;
|
|
1382
|
+
const anySchema = z;
|
|
1383
|
+
if (typeof anySchema.toJSONSchema === "function") {
|
|
1384
|
+
return anySchema.toJSONSchema();
|
|
1385
|
+
}
|
|
1386
|
+
return void 0;
|
|
1387
|
+
}
|
|
1388
|
+
} catch {
|
|
1389
|
+
return void 0;
|
|
1390
|
+
}
|
|
1391
|
+
return void 0;
|
|
1392
|
+
}
|
|
1393
|
+
async function toJsonSchemaAsync(schema) {
|
|
1394
|
+
const direct = toJsonSchema(schema);
|
|
1395
|
+
if (direct) return direct;
|
|
1396
|
+
const std = schema["~standard"];
|
|
1397
|
+
if (std?.vendor === "zod") {
|
|
1398
|
+
try {
|
|
1399
|
+
const zod = await import("zod");
|
|
1400
|
+
const convert = zod.toJSONSchema ?? zod.z?.toJSONSchema;
|
|
1401
|
+
if (convert) return convert(schema, { unrepresentable: "any" });
|
|
1402
|
+
} catch {
|
|
1403
|
+
return void 0;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return void 0;
|
|
1407
|
+
}
|
|
1408
|
+
var SAMPLE_STRINGS = {
|
|
1409
|
+
email: "sample@example.com",
|
|
1410
|
+
uri: "https://example.com/sample",
|
|
1411
|
+
url: "https://example.com/sample",
|
|
1412
|
+
uuid: "00000000-0000-4000-8000-000000000000",
|
|
1413
|
+
"date-time": "2026-01-01T00:00:00.000Z",
|
|
1414
|
+
date: "2026-01-01",
|
|
1415
|
+
time: "00:00:00"
|
|
1416
|
+
};
|
|
1417
|
+
function sampleFromJsonSchema(js, depth = 0) {
|
|
1418
|
+
if (depth > 8) return null;
|
|
1419
|
+
if (js.default !== void 0) return js.default;
|
|
1420
|
+
if (js.const !== void 0) return js.const;
|
|
1421
|
+
if (js.enum && js.enum.length > 0) return js.enum[0];
|
|
1422
|
+
const variants = js.anyOf ?? js.oneOf;
|
|
1423
|
+
if (variants && variants.length > 0) {
|
|
1424
|
+
const pick = variants.find((v) => v.type !== "null") ?? variants[0];
|
|
1425
|
+
return sampleFromJsonSchema(pick, depth + 1);
|
|
1426
|
+
}
|
|
1427
|
+
if (js.allOf && js.allOf.length > 0) {
|
|
1428
|
+
return js.allOf.reduce((acc, part) => {
|
|
1429
|
+
const piece = sampleFromJsonSchema(part, depth + 1);
|
|
1430
|
+
return typeof piece === "object" && piece !== null ? { ...acc, ...piece } : acc;
|
|
1431
|
+
}, {});
|
|
1432
|
+
}
|
|
1433
|
+
const type = Array.isArray(js.type) ? js.type[0] : js.type;
|
|
1434
|
+
switch (type) {
|
|
1435
|
+
case "string": {
|
|
1436
|
+
if (js.format && SAMPLE_STRINGS[js.format]) return SAMPLE_STRINGS[js.format];
|
|
1437
|
+
const min = js.minLength ?? 0;
|
|
1438
|
+
const base = "sample";
|
|
1439
|
+
return base.length >= min ? base : base + "x".repeat(min - base.length);
|
|
1440
|
+
}
|
|
1441
|
+
case "number":
|
|
1442
|
+
case "integer": {
|
|
1443
|
+
let n = 1;
|
|
1444
|
+
if (js.minimum !== void 0) n = js.minimum;
|
|
1445
|
+
if (js.exclusiveMinimum !== void 0) n = js.exclusiveMinimum + 1;
|
|
1446
|
+
if (js.maximum !== void 0 && n > js.maximum) n = js.maximum;
|
|
1447
|
+
return type === "integer" ? Math.round(n) : n;
|
|
1448
|
+
}
|
|
1449
|
+
case "boolean":
|
|
1450
|
+
return true;
|
|
1451
|
+
case "null":
|
|
1452
|
+
return null;
|
|
1453
|
+
case "array": {
|
|
1454
|
+
const itemSchema = Array.isArray(js.items) ? js.items[0] : js.items;
|
|
1455
|
+
const count = Math.max(js.minItems ?? 1, 1);
|
|
1456
|
+
if (!itemSchema) return [];
|
|
1457
|
+
return Array.from({ length: count }, () => sampleFromJsonSchema(itemSchema, depth + 1));
|
|
1458
|
+
}
|
|
1459
|
+
case "object":
|
|
1460
|
+
default: {
|
|
1461
|
+
if (js.properties) {
|
|
1462
|
+
const out = {};
|
|
1463
|
+
const required = new Set(js.required ?? Object.keys(js.properties));
|
|
1464
|
+
for (const [key, prop] of Object.entries(js.properties)) {
|
|
1465
|
+
if (required.has(key) || prop.default !== void 0) {
|
|
1466
|
+
out[key] = sampleFromJsonSchema(prop, depth + 1);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return out;
|
|
1470
|
+
}
|
|
1471
|
+
if (type === "object" || type === void 0) return {};
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
async function sampleFromSchema(schema) {
|
|
1477
|
+
const js = await toJsonSchemaAsync(schema);
|
|
1478
|
+
if (!js) return void 0;
|
|
1479
|
+
return sampleFromJsonSchema(js);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// packages/testing/src/engine.ts
|
|
1483
|
+
var TestConfigError = class extends Error {
|
|
1484
|
+
constructor(message, hint) {
|
|
1485
|
+
super(hint ? `${message}
|
|
1486
|
+
hint: ${hint}` : message);
|
|
1487
|
+
this.hint = hint;
|
|
1488
|
+
this.name = "TestConfigError";
|
|
1489
|
+
}
|
|
1490
|
+
hint;
|
|
1491
|
+
};
|
|
1492
|
+
function serializeError(err) {
|
|
1493
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
1494
|
+
return {
|
|
1495
|
+
name: e.name,
|
|
1496
|
+
message: e.message,
|
|
1497
|
+
retryable: isRetryableError(err),
|
|
1498
|
+
terminal: isTerminalError(err)
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
async function executeAutomation(def, opts = {}) {
|
|
1502
|
+
const journal = [];
|
|
1503
|
+
const journalByKey = /* @__PURE__ */ new Map();
|
|
1504
|
+
for (const seeded of opts.journal ?? []) {
|
|
1505
|
+
const occurrence = seeded.occurrence ?? 1;
|
|
1506
|
+
const entry = {
|
|
1507
|
+
kind: "step",
|
|
1508
|
+
name: seeded.name,
|
|
1509
|
+
occurrence,
|
|
1510
|
+
status: "completed",
|
|
1511
|
+
attempts: 1,
|
|
1512
|
+
result: encodeJournal(seeded.result)
|
|
1513
|
+
};
|
|
1514
|
+
journalByKey.set(`step:${seeded.name}#${occurrence}`, entry);
|
|
1515
|
+
journal.push(entry);
|
|
1516
|
+
}
|
|
1517
|
+
const emitted = [];
|
|
1518
|
+
const slept = [];
|
|
1519
|
+
const undone = [];
|
|
1520
|
+
const logs = [];
|
|
1521
|
+
const connectorCalls = [];
|
|
1522
|
+
const undoStack = [];
|
|
1523
|
+
const spies = /* @__PURE__ */ new Map();
|
|
1524
|
+
const spyFor = (key) => {
|
|
1525
|
+
let s = spies.get(key);
|
|
1526
|
+
if (!s) {
|
|
1527
|
+
s = createSpy(key);
|
|
1528
|
+
spies.set(key, s);
|
|
1529
|
+
}
|
|
1530
|
+
return s;
|
|
1531
|
+
};
|
|
1532
|
+
for (const key of Object.keys(opts.mocks ?? {})) spyFor(key);
|
|
1533
|
+
const calls = new Proxy({}, {
|
|
1534
|
+
get: (_t, prop) => typeof prop === "string" ? spyFor(prop) : void 0,
|
|
1535
|
+
has: () => true,
|
|
1536
|
+
ownKeys: () => [...spies.keys()],
|
|
1537
|
+
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
|
|
1538
|
+
});
|
|
1539
|
+
const activeStep = new AsyncLocalStorage();
|
|
1540
|
+
const logger = {
|
|
1541
|
+
info: (msg, meta) => logs.push({ level: "info", msg, ...meta ? { meta } : {} }),
|
|
1542
|
+
warn: (msg, meta) => logs.push({ level: "warn", msg, ...meta ? { meta } : {} }),
|
|
1543
|
+
error: (msg, meta) => logs.push({ level: "error", msg, ...meta ? { meta } : {} })
|
|
1544
|
+
};
|
|
1545
|
+
const connections = {};
|
|
1546
|
+
for (const [connKey, connector] of Object.entries(def.connections ?? {})) {
|
|
1547
|
+
connections[connKey] = buildConnectorClient(connector, async (path, actionDef, rawInput) => {
|
|
1548
|
+
const step = activeStep.getStore();
|
|
1549
|
+
const actionPath = path.join(".");
|
|
1550
|
+
const fullPath = `${connKey}.${actionPath}`;
|
|
1551
|
+
if (!step) {
|
|
1552
|
+
throw new TestConfigError(
|
|
1553
|
+
`connector call ${fullPath} happened outside ctx.step()`,
|
|
1554
|
+
`side effects must live inside a step (ADR-0002) \u2014 wrap the call: ctx.step("name", () => ctx.connections.${fullPath}(...))`
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
const input2 = await validateSchema(
|
|
1558
|
+
actionDef.input,
|
|
1559
|
+
rawInput ?? {},
|
|
1560
|
+
`${fullPath} input`
|
|
1561
|
+
);
|
|
1562
|
+
if (!isRetrySafe(actionDef, connector.__connector.idempotencyHeader !== void 0)) {
|
|
1563
|
+
step.sawUnsafeWrite = true;
|
|
1564
|
+
}
|
|
1565
|
+
const resolve = async () => {
|
|
1566
|
+
const connMockRoot = opts.connections?.[connKey];
|
|
1567
|
+
if (connMockRoot !== void 0) {
|
|
1568
|
+
let node = connMockRoot;
|
|
1569
|
+
for (const seg of path) node = node?.[seg];
|
|
1570
|
+
if (node !== void 0) {
|
|
1571
|
+
return {
|
|
1572
|
+
value: typeof node === "function" ? await node(input2) : node,
|
|
1573
|
+
validate: false
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
const stepMock = opts.mocks?.[step.name];
|
|
1578
|
+
if (stepMock !== void 0) {
|
|
1579
|
+
return {
|
|
1580
|
+
value: typeof stepMock === "function" ? await stepMock(
|
|
1581
|
+
input2,
|
|
1582
|
+
{ action: actionPath, connection: connKey }
|
|
1583
|
+
) : stepMock,
|
|
1584
|
+
validate: false
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
const sample = connector.__connector.samples?.[actionPath];
|
|
1588
|
+
if (sample !== void 0) return { value: sample, validate: true };
|
|
1589
|
+
const derived = await sampleFromSchema(actionDef.output);
|
|
1590
|
+
if (derived !== void 0) return { value: derived, validate: true };
|
|
1591
|
+
throw new TestConfigError(
|
|
1592
|
+
`no mock for connector call ${fullPath} in step "${step.name}"`,
|
|
1593
|
+
`provide mocks: { "${step.name}": () => <result> } or connections: { ${connKey}: { ${actionPath.split(".").join(": { ")}: () => <result> ${"}".repeat(actionPath.split(".").length)} }`
|
|
1594
|
+
);
|
|
1595
|
+
};
|
|
1596
|
+
try {
|
|
1597
|
+
const { value, validate } = await resolve();
|
|
1598
|
+
const result = validate ? await validateSchema(actionDef.output, value, `${fullPath} sample output`) : value;
|
|
1599
|
+
const record = { args: [input2], step: step.name, action: fullPath, result };
|
|
1600
|
+
recordCall(spyFor(step.name), record);
|
|
1601
|
+
recordCall(spyFor(fullPath), record);
|
|
1602
|
+
connectorCalls.push({ step: step.name, action: fullPath, input: input2 });
|
|
1603
|
+
return result;
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
const record = { args: [input2], step: step.name, action: fullPath, error: String(err) };
|
|
1606
|
+
recordCall(spyFor(step.name), record);
|
|
1607
|
+
recordCall(spyFor(fullPath), record);
|
|
1608
|
+
connectorCalls.push({ step: step.name, action: fullPath, input: input2 });
|
|
1609
|
+
throw err;
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
const secretNames = Object.keys(def.secrets ?? {});
|
|
1614
|
+
const secrets2 = {};
|
|
1615
|
+
for (const name of secretNames) {
|
|
1616
|
+
secrets2[name] = opts.secrets?.[name] ?? `test-secret-${name}`;
|
|
1617
|
+
}
|
|
1618
|
+
const stepOccurrence = /* @__PURE__ */ new Map();
|
|
1619
|
+
const signalOccurrence = /* @__PURE__ */ new Map();
|
|
1620
|
+
const defaultRetry = resolveRetryPolicy(def.retry);
|
|
1621
|
+
const modelTurns = /* @__PURE__ */ new Map();
|
|
1622
|
+
const harnessInvocations = /* @__PURE__ */ new Map();
|
|
1623
|
+
const ctx = {
|
|
1624
|
+
async step(name, fn, stepOpts) {
|
|
1625
|
+
if (typeof name !== "string" || name.length === 0 || typeof fn !== "function") {
|
|
1626
|
+
throw new TestConfigError(`ctx.step: expected (name, fn) \u2014 got name=${JSON.stringify(name)}`);
|
|
1627
|
+
}
|
|
1628
|
+
const occ = (stepOccurrence.get(name) ?? 0) + 1;
|
|
1629
|
+
stepOccurrence.set(name, occ);
|
|
1630
|
+
const key = `step:${name}#${occ}`;
|
|
1631
|
+
const cached = journalByKey.get(key);
|
|
1632
|
+
if (cached?.status === "completed") {
|
|
1633
|
+
const value = decodeJournal(cached.result);
|
|
1634
|
+
if (stepOpts?.undo) undoStack.push({ name, undo: () => stepOpts.undo(value) });
|
|
1635
|
+
return value;
|
|
1636
|
+
}
|
|
1637
|
+
const policy = resolveRetryPolicy(stepOpts?.retry, defaultRetry);
|
|
1638
|
+
const timeoutMs = stepOpts?.timeout !== void 0 ? parseDuration(stepOpts.timeout, "step timeout") : void 0;
|
|
1639
|
+
const entry = { kind: "step", name, occurrence: occ, status: "failed", attempts: 0 };
|
|
1640
|
+
journal.push(entry);
|
|
1641
|
+
journalByKey.set(key, entry);
|
|
1642
|
+
let lastError;
|
|
1643
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
|
|
1644
|
+
entry.attempts = attempt;
|
|
1645
|
+
const state = { name, sawUnsafeWrite: false };
|
|
1646
|
+
try {
|
|
1647
|
+
let resultPromise = Promise.resolve(activeStep.run(state, () => fn()));
|
|
1648
|
+
if (timeoutMs !== void 0) {
|
|
1649
|
+
resultPromise = Promise.race([
|
|
1650
|
+
resultPromise,
|
|
1651
|
+
new Promise(
|
|
1652
|
+
(_r, reject) => setTimeout(
|
|
1653
|
+
() => reject(new TestConfigError(`step "${name}" exceeded its ${stepOpts?.timeout} timeout`)),
|
|
1654
|
+
Math.min(timeoutMs, 5e3)
|
|
1655
|
+
).unref?.()
|
|
1656
|
+
)
|
|
1657
|
+
]);
|
|
1658
|
+
}
|
|
1659
|
+
const result = await resultPromise;
|
|
1660
|
+
const encoded = encodeJournal(result);
|
|
1661
|
+
entry.status = "completed";
|
|
1662
|
+
entry.result = encoded;
|
|
1663
|
+
if (stepOpts?.undo) undoStack.push({ name, undo: () => stepOpts.undo(result) });
|
|
1664
|
+
return result;
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
lastError = err;
|
|
1667
|
+
entry.error = serializeError(err);
|
|
1668
|
+
if (isTerminalError(err)) break;
|
|
1669
|
+
if (state.sawUnsafeWrite && stepOpts?.retry === void 0) {
|
|
1670
|
+
logger.warn(
|
|
1671
|
+
`step "${name}" performed a non-retry-safe write and will not auto-retry \u2014 pass StepOpts.retry to opt in`
|
|
1672
|
+
);
|
|
1673
|
+
break;
|
|
1674
|
+
}
|
|
1675
|
+
const delay = nextRetryDelayMs(policy, attempt, isRetryableError(err) ? err.retryAfterMs : void 0);
|
|
1676
|
+
if (delay === null) break;
|
|
1677
|
+
slept.push(`retry:${name}#${occ}@${attempt}`);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
entry.status = "failed";
|
|
1681
|
+
throw lastError;
|
|
1682
|
+
},
|
|
1683
|
+
connections,
|
|
1684
|
+
secrets: secrets2,
|
|
1685
|
+
operators: {},
|
|
1686
|
+
harnesses: {},
|
|
1687
|
+
async sleep(duration) {
|
|
1688
|
+
parseDuration(duration, "ctx.sleep");
|
|
1689
|
+
slept.push(duration);
|
|
1690
|
+
},
|
|
1691
|
+
async waitForSignal(name, sOpts) {
|
|
1692
|
+
const occ = (signalOccurrence.get(name) ?? 0) + 1;
|
|
1693
|
+
signalOccurrence.set(name, occ);
|
|
1694
|
+
const provided = opts.signals?.[name];
|
|
1695
|
+
let value;
|
|
1696
|
+
if (typeof provided === "function") value = provided(occ);
|
|
1697
|
+
else if (Array.isArray(provided)) value = provided[occ - 1];
|
|
1698
|
+
else value = provided;
|
|
1699
|
+
const entry = { kind: "signal", name, occurrence: occ, status: "completed", attempts: 1 };
|
|
1700
|
+
journal.push(entry);
|
|
1701
|
+
if (value === void 0) {
|
|
1702
|
+
if (sOpts.timeout !== void 0) {
|
|
1703
|
+
parseDuration(sOpts.timeout, "waitForSignal timeout");
|
|
1704
|
+
entry.status = "timed-out";
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
throw new TestConfigError(
|
|
1708
|
+
`run is suspended waiting for signal "${name}" (occurrence ${occ}) with no timeout`,
|
|
1709
|
+
`provide signals: { "${name}": <payload> } in t.run(...)`
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
const validated = await validateSchema(sOpts.schema, value, `signal "${name}" payload`);
|
|
1713
|
+
entry.result = encodeJournal(validated);
|
|
1714
|
+
return validated;
|
|
1715
|
+
},
|
|
1716
|
+
async emit(event, payload) {
|
|
1717
|
+
const validated = await validateSchema(event.schema, payload, `event "${event.name}" payload`);
|
|
1718
|
+
emitted.push({ event: event.name, payload: validated });
|
|
1719
|
+
},
|
|
1720
|
+
...opts.request !== void 0 || def.trigger.kind === "webhook" ? {
|
|
1721
|
+
request: opts.request ?? {
|
|
1722
|
+
headers: { "content-type": "application/json" },
|
|
1723
|
+
query: {},
|
|
1724
|
+
rawBody: new TextEncoder().encode(JSON.stringify(opts.input ?? {}))
|
|
1725
|
+
}
|
|
1726
|
+
} : {},
|
|
1727
|
+
logger,
|
|
1728
|
+
run: { id: `test_${Math.random().toString(36).slice(2, 10)}`, attempt: 1, automationId: def.id }
|
|
1729
|
+
};
|
|
1730
|
+
ctx.operators = buildOperators(def, ctx, async ({ operatorKey, modelKey, request }) => {
|
|
1731
|
+
const key = `${operatorKey}:${modelKey}`;
|
|
1732
|
+
const turn = (modelTurns.get(key) ?? 0) + 1;
|
|
1733
|
+
modelTurns.set(key, turn);
|
|
1734
|
+
const script = opts.models?.[operatorKey]?.[modelKey];
|
|
1735
|
+
if (script === void 0) {
|
|
1736
|
+
throw new TestConfigError(
|
|
1737
|
+
`unscripted model turn for operator "${operatorKey}" model "${modelKey}" (turn ${turn})`,
|
|
1738
|
+
`provide models: { ${operatorKey}: { ${modelKey}: [{ output: <typed output>, usage: { inputTokens, outputTokens } }] } }`
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
const picked = Array.isArray(script) ? script[turn - 1] : script;
|
|
1742
|
+
if (picked === void 0) {
|
|
1743
|
+
throw new TestConfigError(`no scripted model response for operator "${operatorKey}" model "${modelKey}" turn ${turn}`);
|
|
1744
|
+
}
|
|
1745
|
+
const response = typeof picked === "function" ? await picked(request, { operatorKey, modelKey, turn }) : picked;
|
|
1746
|
+
recordCall(spyFor(`operator.${operatorKey}.model`), { args: [request], result: response });
|
|
1747
|
+
recordCall(spyFor(`operator.${operatorKey}.model.${turn}`), { args: [request], result: response });
|
|
1748
|
+
return response;
|
|
1749
|
+
});
|
|
1750
|
+
ctx.harnesses = buildHarnesses(def, ctx, async ({ harnessKey, request }) => {
|
|
1751
|
+
const step = activeStep.getStore();
|
|
1752
|
+
if (!step) {
|
|
1753
|
+
throw new TestConfigError(
|
|
1754
|
+
`harness "${harnessKey}" ran outside ctx.step()`,
|
|
1755
|
+
`Harnesses are durable Step runners \u2014 wrap the call: ctx.step("name", () => ctx.harnesses.${harnessKey}.run(...))`
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
const invocation = (harnessInvocations.get(harnessKey) ?? 0) + 1;
|
|
1759
|
+
harnessInvocations.set(harnessKey, invocation);
|
|
1760
|
+
const script = opts.harnesses?.[harnessKey];
|
|
1761
|
+
if (script === void 0) {
|
|
1762
|
+
throw new TestConfigError(
|
|
1763
|
+
`unscripted Harness run for "${harnessKey}"`,
|
|
1764
|
+
`provide harnesses: { ${harnessKey}: { output: <typed output>, status: "completed", artifacts: [], adapter: "test" } }`
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
const picked = Array.isArray(script) ? script[invocation - 1] : script;
|
|
1768
|
+
if (picked === void 0) throw new TestConfigError(`no scripted Harness result for "${harnessKey}" invocation ${invocation}`);
|
|
1769
|
+
const result = typeof picked === "function" ? await picked(request, { harnessKey, invocation }) : picked;
|
|
1770
|
+
recordCall(spyFor(`harness.${harnessKey}`), { args: [request], step: step.name, result });
|
|
1771
|
+
return result;
|
|
1772
|
+
});
|
|
1773
|
+
let input = opts.input;
|
|
1774
|
+
const trigger = def.trigger;
|
|
1775
|
+
let inputSchema = def.input ?? (trigger.kind === "webhook" ? trigger.input : trigger.kind === "event" ? trigger.event?.schema : void 0);
|
|
1776
|
+
if (trigger.kind === "connector" && trigger.connectorId !== void 0) {
|
|
1777
|
+
const connector = Object.values(
|
|
1778
|
+
def.connections ?? {}
|
|
1779
|
+
).find((c) => c.id === trigger.connectorId);
|
|
1780
|
+
const decl = connector?.__connector.triggers?.[trigger.triggerId ?? ""];
|
|
1781
|
+
if (decl) {
|
|
1782
|
+
inputSchema = def.input ?? decl.output;
|
|
1783
|
+
if (input === void 0) {
|
|
1784
|
+
input = connector?.__connector.samples?.[`trigger:${trigger.triggerId}`];
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if (input === void 0 && inputSchema && trigger.kind !== "schedule") {
|
|
1789
|
+
input = await sampleFromSchema(inputSchema);
|
|
1790
|
+
}
|
|
1791
|
+
if (inputSchema && input !== void 0) {
|
|
1792
|
+
input = await validateSchema(inputSchema, input, `automation "${def.id}" input`);
|
|
1793
|
+
}
|
|
1794
|
+
const finish = (status, result, error) => {
|
|
1795
|
+
const stepsByName = {};
|
|
1796
|
+
for (const e of journal) {
|
|
1797
|
+
if (e.kind !== "step" || e.status !== "completed") continue;
|
|
1798
|
+
(stepsByName[e.name] ??= []).push(decodeJournal(e.result));
|
|
1799
|
+
}
|
|
1800
|
+
const steps = {};
|
|
1801
|
+
for (const [name, results] of Object.entries(stepsByName)) {
|
|
1802
|
+
steps[name] = results.length === 1 ? results[0] : results;
|
|
1803
|
+
}
|
|
1804
|
+
const failedEntry = journal.find((e) => e.kind === "step" && e.status === "failed");
|
|
1805
|
+
return {
|
|
1806
|
+
status,
|
|
1807
|
+
...result !== void 0 ? { result } : {},
|
|
1808
|
+
...error !== void 0 ? { error } : {},
|
|
1809
|
+
steps,
|
|
1810
|
+
journal,
|
|
1811
|
+
calls,
|
|
1812
|
+
emitted,
|
|
1813
|
+
slept,
|
|
1814
|
+
undone,
|
|
1815
|
+
logs,
|
|
1816
|
+
failure: () => status === "completed" ? null : {
|
|
1817
|
+
automation: def.id,
|
|
1818
|
+
status: "failed",
|
|
1819
|
+
error,
|
|
1820
|
+
...failedEntry ? {
|
|
1821
|
+
failedStep: {
|
|
1822
|
+
name: failedEntry.name,
|
|
1823
|
+
occurrence: failedEntry.occurrence,
|
|
1824
|
+
attempts: failedEntry.attempts,
|
|
1825
|
+
error: failedEntry.error
|
|
1826
|
+
}
|
|
1827
|
+
} : {},
|
|
1828
|
+
steps: journal.filter((e) => e.kind === "step").map((e) => ({ name: e.name, occurrence: e.occurrence, status: e.status, attempts: e.attempts })),
|
|
1829
|
+
connectorCalls,
|
|
1830
|
+
...failedEntry?.error?.terminal === false ? {} : failedEntry ? { suggestion: `step "${failedEntry.name}" failed terminally \u2014 fix the input or mark the error retryable` } : {}
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
};
|
|
1834
|
+
try {
|
|
1835
|
+
let output = await def.run(input, ctx);
|
|
1836
|
+
if (def.output) {
|
|
1837
|
+
output = await validateSchema(def.output, output, `automation "${def.id}" output`);
|
|
1838
|
+
}
|
|
1839
|
+
return finish("completed", output);
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
for (const item of [...undoStack].reverse()) {
|
|
1842
|
+
try {
|
|
1843
|
+
await item.undo();
|
|
1844
|
+
undone.push(item.name);
|
|
1845
|
+
} catch (undoErr) {
|
|
1846
|
+
logger.error(`undo for step "${item.name}" failed: ${String(undoErr)}`);
|
|
1847
|
+
undone.push(`${item.name} (failed)`);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const failedStep = journal.find((e) => e.kind === "step" && e.status === "failed");
|
|
1851
|
+
return finish("failed", void 0, {
|
|
1852
|
+
...serializeError(err),
|
|
1853
|
+
...failedStep ? { step: failedStep.name } : {}
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// packages/testing/src/smoke.ts
|
|
1859
|
+
async function smokeTest(def) {
|
|
1860
|
+
try {
|
|
1861
|
+
const models = await smokeModelScripts(def);
|
|
1862
|
+
const result = await executeAutomation(def, Object.keys(models).length > 0 ? { models } : {});
|
|
1863
|
+
if (result.status === "completed") {
|
|
1864
|
+
return { automation: def.id, passed: true, result };
|
|
1865
|
+
}
|
|
1866
|
+
return {
|
|
1867
|
+
automation: def.id,
|
|
1868
|
+
passed: false,
|
|
1869
|
+
result,
|
|
1870
|
+
reason: result.error?.step !== void 0 ? `step "${result.error.step}" failed: ${result.error.message}` : result.error?.message ?? "run failed"
|
|
1871
|
+
};
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
return {
|
|
1874
|
+
automation: def.id,
|
|
1875
|
+
passed: false,
|
|
1876
|
+
result: {
|
|
1877
|
+
status: "failed",
|
|
1878
|
+
steps: {},
|
|
1879
|
+
journal: [],
|
|
1880
|
+
calls: {},
|
|
1881
|
+
emitted: [],
|
|
1882
|
+
slept: [],
|
|
1883
|
+
undone: [],
|
|
1884
|
+
logs: [],
|
|
1885
|
+
error: { name: err.name, message: err.message, retryable: false, terminal: true },
|
|
1886
|
+
failure: () => ({
|
|
1887
|
+
automation: def.id,
|
|
1888
|
+
status: "failed",
|
|
1889
|
+
error: { name: err.name, message: err.message, retryable: false, terminal: true },
|
|
1890
|
+
steps: [],
|
|
1891
|
+
connectorCalls: []
|
|
1892
|
+
})
|
|
1893
|
+
},
|
|
1894
|
+
reason: err.message
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
async function smokeModelScripts(def) {
|
|
1899
|
+
const models = {};
|
|
1900
|
+
const operators = def.operators ?? {};
|
|
1901
|
+
for (const [operatorKey, op] of Object.entries(operators)) {
|
|
1902
|
+
const sample = await sampleFromSchema(op.output).catch(() => void 0);
|
|
1903
|
+
(models[operatorKey] ??= {})[op.model] = {
|
|
1904
|
+
...sample !== void 0 ? { output: sample } : { content: "{}" },
|
|
1905
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
1906
|
+
provider: "tesser-smoke",
|
|
1907
|
+
model: op.model
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
return models;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// packages/testing/src/cassette.ts
|
|
1914
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1915
|
+
import { dirname as dirname3 } from "node:path";
|
|
1916
|
+
import { createHash } from "node:crypto";
|
|
1917
|
+
|
|
1918
|
+
// packages/cli/src/commands/test.ts
|
|
1919
|
+
var exec = promisify(execFile);
|
|
1920
|
+
function findVitest(projectRoot) {
|
|
1921
|
+
let dir = projectRoot;
|
|
1922
|
+
for (; ; ) {
|
|
1923
|
+
const bin = join6(dir, "node_modules", ".bin", process.platform === "win32" ? "vitest.cmd" : "vitest");
|
|
1924
|
+
if (existsSync6(bin)) return bin;
|
|
1925
|
+
const parent = join6(dir, "..");
|
|
1926
|
+
if (parent === dir) return null;
|
|
1927
|
+
dir = parent;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
async function runTests(out, projectRoot, opts) {
|
|
1931
|
+
const automations = discoverLocalAutomations(projectRoot).filter(
|
|
1932
|
+
(a) => opts.filter === void 0 || a.automationId === opts.filter
|
|
1933
|
+
);
|
|
1934
|
+
if (automations.length === 0) {
|
|
1935
|
+
throw new CliError(EXIT.USAGE, `no automations found under ${join6(projectRoot, "automations")}`);
|
|
1936
|
+
}
|
|
1937
|
+
const report = {
|
|
1938
|
+
passed: true,
|
|
1939
|
+
colocated: { ran: 0, passed: 0, failed: 0 },
|
|
1940
|
+
smoke: [],
|
|
1941
|
+
failures: []
|
|
1942
|
+
};
|
|
1943
|
+
const withTests = automations.filter((a) => a.hasTests);
|
|
1944
|
+
if (!opts.smokeOnly && withTests.length > 0) {
|
|
1945
|
+
const vitestBin = findVitest(projectRoot);
|
|
1946
|
+
if (!vitestBin) {
|
|
1947
|
+
report.colocated.skippedNoRunner = true;
|
|
1948
|
+
out.log("note: no test runner installed in the project \u2014 running smoke tests only");
|
|
1949
|
+
} else {
|
|
1950
|
+
const args = ["run", "--reporter=json", ...withTests.map((a) => a.dir)];
|
|
1951
|
+
const res = await exec(vitestBin, args, {
|
|
1952
|
+
cwd: projectRoot,
|
|
1953
|
+
env: { ...process.env, CI: "1" },
|
|
1954
|
+
maxBuffer: 64 * 1024 * 1024
|
|
1955
|
+
}).catch((err) => ({
|
|
1956
|
+
stdout: err.stdout ?? "",
|
|
1957
|
+
stderr: err.stderr ?? ""
|
|
1958
|
+
}));
|
|
1959
|
+
const jsonLine = (res.stdout ?? "").split("\n").find((l) => l.trimStart().startsWith("{"));
|
|
1960
|
+
if (!jsonLine) {
|
|
1961
|
+
report.passed = false;
|
|
1962
|
+
report.failures.push({ kind: "test", message: `test runner produced no JSON report: ${(res.stderr ?? "").slice(0, 800)}` });
|
|
1963
|
+
} else {
|
|
1964
|
+
const parsed = JSON.parse(jsonLine);
|
|
1965
|
+
report.colocated.ran = parsed.numTotalTests;
|
|
1966
|
+
report.colocated.passed = parsed.numPassedTests;
|
|
1967
|
+
report.colocated.failed = parsed.numFailedTests;
|
|
1968
|
+
if (parsed.numFailedTests > 0) {
|
|
1969
|
+
report.passed = false;
|
|
1970
|
+
for (const file of parsed.testResults) {
|
|
1971
|
+
for (const t of file.assertionResults) {
|
|
1972
|
+
if (t.status === "failed") {
|
|
1973
|
+
report.failures.push({
|
|
1974
|
+
kind: "test",
|
|
1975
|
+
file: file.name,
|
|
1976
|
+
name: t.fullName,
|
|
1977
|
+
message: t.failureMessages.join("\n").slice(0, 2e3)
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const smokeTargets = opts.smokeOnly ? automations : automations.filter((a) => !a.hasTests);
|
|
1987
|
+
for (const auto of smokeTargets) {
|
|
1988
|
+
try {
|
|
1989
|
+
const def = await loadAutomationDef(auto.entry);
|
|
1990
|
+
const outcome = await smokeTest(def);
|
|
1991
|
+
report.smoke.push({
|
|
1992
|
+
automation: auto.automationId,
|
|
1993
|
+
passed: outcome.passed,
|
|
1994
|
+
...outcome.reason !== void 0 ? { reason: outcome.reason } : {},
|
|
1995
|
+
...outcome.passed ? {} : { failure: outcome.result.failure() }
|
|
1996
|
+
});
|
|
1997
|
+
if (!outcome.passed) {
|
|
1998
|
+
report.passed = false;
|
|
1999
|
+
report.failures.push({
|
|
2000
|
+
kind: "smoke",
|
|
2001
|
+
automation: auto.automationId,
|
|
2002
|
+
message: outcome.reason ?? "smoke test failed"
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
} catch (err) {
|
|
2006
|
+
report.passed = false;
|
|
2007
|
+
report.smoke.push({ automation: auto.automationId, passed: false, reason: String(err) });
|
|
2008
|
+
report.failures.push({ kind: "smoke", automation: auto.automationId, message: String(err).slice(0, 2e3) });
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
out.data(report, (r) => {
|
|
2012
|
+
const lines = [
|
|
2013
|
+
`colocated: ${r.colocated.passed}/${r.colocated.ran} passed${r.colocated.skippedNoRunner ? " (runner missing)" : ""}`,
|
|
2014
|
+
...r.smoke.map((s) => `smoke ${s.automation}: ${s.passed ? "\u2713" : `\u2717 ${s.reason ?? ""}`}`),
|
|
2015
|
+
r.passed ? "PASS" : `FAIL (${r.failures.length} failure${r.failures.length === 1 ? "" : "s"})`
|
|
2016
|
+
];
|
|
2017
|
+
return lines.join("\n");
|
|
2018
|
+
});
|
|
2019
|
+
process.exit(report.passed ? EXIT.OK : EXIT.TESTS_FAILED);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// packages/cli/src/index.ts
|
|
2023
|
+
var exec2 = promisify2(execFile2);
|
|
2024
|
+
var program = new Command();
|
|
2025
|
+
var VERSION = JSON.parse(readFileSync3(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
2026
|
+
function setup() {
|
|
2027
|
+
const opts = program.opts();
|
|
2028
|
+
return { out: new Output(opts.json ?? false), opts };
|
|
2029
|
+
}
|
|
2030
|
+
function target(opts) {
|
|
2031
|
+
return resolveTarget({
|
|
2032
|
+
...opts.url !== void 0 ? { url: opts.url } : {},
|
|
2033
|
+
...opts.token !== void 0 ? { token: opts.token } : {},
|
|
2034
|
+
...opts.profile !== void 0 ? { profile: opts.profile } : {}
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
function api(opts) {
|
|
2038
|
+
const t = target(opts);
|
|
2039
|
+
return new ApiClient(t.url, t.token);
|
|
2040
|
+
}
|
|
2041
|
+
function requireProject(opts) {
|
|
2042
|
+
const t = target(opts);
|
|
2043
|
+
if (!t.projectRoot || !t.project) {
|
|
2044
|
+
throw new CliError(EXIT.USAGE, "not inside a Tesser project (no tesser.json) \u2014 run `tesser init <name>` or `tesser link`");
|
|
2045
|
+
}
|
|
2046
|
+
return { name: t.project, root: t.projectRoot };
|
|
2047
|
+
}
|
|
2048
|
+
program.name("tesser").version(VERSION).description("Code-first, agent-native automation. stdout = data, stderr = logs; --json everywhere.").option("--json", "machine output: one JSON document on stdout").option("--url <url>", "instance URL (overrides tesser.json / profile)").option("--token <token>", "API token (overrides TESSER_TOKEN / profile)").option("--profile <name>", "config profile");
|
|
2049
|
+
program.command("init").argument("<name>", "project name (kebab-case)").option("--dir <dir>", "parent directory").option("--instance <url>", "instance URL to write into tesser.json").description("scaffold a new Project (one repo of automations)").action((name, cmdOpts) => {
|
|
2050
|
+
const { out } = setup();
|
|
2051
|
+
try {
|
|
2052
|
+
init(out, name, cmdOpts);
|
|
2053
|
+
} catch (err) {
|
|
2054
|
+
toExit(err, out);
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
program.command("login").option("--token <token>", "API token from the instance (printed at first boot)").option("--instance <url>", "instance URL", "http://localhost:8377").option("--save-profile <name>", "profile name", "default").description("store instance credentials in ~/.config/tesser (or use env TESSER_TOKEN)").action(async (cmdOpts) => {
|
|
2058
|
+
const { out, opts } = setup();
|
|
2059
|
+
try {
|
|
2060
|
+
const token = cmdOpts.token ?? opts.token;
|
|
2061
|
+
if (!token) throw new CliError(EXIT.USAGE, "missing required option '--token <token>'");
|
|
2062
|
+
const client = new ApiClient(cmdOpts.instance, token);
|
|
2063
|
+
await client.get("/health");
|
|
2064
|
+
const config = readConfig();
|
|
2065
|
+
config.profiles = { ...config.profiles, [cmdOpts.saveProfile]: { url: cmdOpts.instance, token } };
|
|
2066
|
+
config.current = cmdOpts.saveProfile;
|
|
2067
|
+
writeConfig(config);
|
|
2068
|
+
out.data({ profile: cmdOpts.saveProfile, url: cmdOpts.instance }, () => `logged in to ${cmdOpts.instance} (profile "${cmdOpts.saveProfile}")`);
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
toExit(err, out);
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
program.command("link").option("--repo <url>", "git remote the instance should pull (defaults to origin)").description("register this Project on the instance and wire git-sync").action(async (cmdOpts) => {
|
|
2074
|
+
const { out, opts } = setup();
|
|
2075
|
+
try {
|
|
2076
|
+
const { name } = requireProject(opts);
|
|
2077
|
+
let repoUrl = cmdOpts.repo;
|
|
2078
|
+
if (repoUrl === void 0) {
|
|
2079
|
+
repoUrl = await exec2("git", ["remote", "get-url", "origin"]).then(
|
|
2080
|
+
(r) => r.stdout.trim(),
|
|
2081
|
+
() => void 0
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
const result = await api(opts).post("/projects", {
|
|
2085
|
+
name,
|
|
2086
|
+
...repoUrl !== void 0 ? { repoUrl } : {}
|
|
2087
|
+
});
|
|
2088
|
+
out.data(
|
|
2089
|
+
{ ...result, repoUrl: repoUrl ?? null },
|
|
2090
|
+
() => `linked project "${name}"${repoUrl ? ` \u2190 ${repoUrl}` : " (no git remote yet \u2014 deploy with --local or set --repo)"}
|
|
2091
|
+
deploy key: ${result.deployKeyPublic}
|
|
2092
|
+
webhook setup: ${result.webhookSetupUrl}`
|
|
2093
|
+
);
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
toExit(err, out);
|
|
2096
|
+
}
|
|
2097
|
+
});
|
|
2098
|
+
program.command("test").option("--smoke-only", "run only the auto-generated smoke tests").option("--automation <id>", "limit to one automation").description("fast in-process validation: colocated tests + auto smoke (ADR-0008)").action(async (cmdOpts) => {
|
|
2099
|
+
const { out, opts } = setup();
|
|
2100
|
+
try {
|
|
2101
|
+
const t = target(opts);
|
|
2102
|
+
const root = t.projectRoot ?? process.cwd();
|
|
2103
|
+
await runTests(out, root, { ...cmdOpts.smokeOnly !== void 0 ? { smokeOnly: cmdOpts.smokeOnly } : {}, filter: cmdOpts.automation });
|
|
2104
|
+
} catch (err) {
|
|
2105
|
+
toExit(err, out);
|
|
2106
|
+
}
|
|
2107
|
+
});
|
|
2108
|
+
program.command("build").description("build + statically extract every automation's manifest locally (CI check)").action(async () => {
|
|
2109
|
+
const { out, opts } = setup();
|
|
2110
|
+
try {
|
|
2111
|
+
const t = target(opts);
|
|
2112
|
+
const root = t.projectRoot ?? process.cwd();
|
|
2113
|
+
const autos = discoverLocalAutomations(root);
|
|
2114
|
+
if (autos.length === 0) throw new CliError(EXIT.USAGE, "no automations found");
|
|
2115
|
+
const manifests = [];
|
|
2116
|
+
for (const a of autos) {
|
|
2117
|
+
const def = await loadAutomationDef(a.entry);
|
|
2118
|
+
manifests.push(extractAutomationManifest(def));
|
|
2119
|
+
}
|
|
2120
|
+
out.data(
|
|
2121
|
+
{ automations: manifests },
|
|
2122
|
+
() => manifests.map((m) => `${m.id}: trigger=${m.trigger.kind} connections=${Object.keys(m.connections).join(",") || "-"} secrets=${Object.keys(m.secrets).join(",") || "-"}`).join("\n")
|
|
2123
|
+
);
|
|
2124
|
+
} catch (err) {
|
|
2125
|
+
toExit(err, out);
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
program.command("deploy").option("--ref <ref>", "git ref to deploy (default: the project's production branch)").option("--local", "deploy the local working tree (dev/dogfood lane, skips git)").option("--no-wait", "queue the sync and return immediately").description("git \u2192 instance: build changed, gate on tests, promote on green (ADR-0006)").action(async (cmdOpts) => {
|
|
2129
|
+
const { out, opts } = setup();
|
|
2130
|
+
try {
|
|
2131
|
+
const { name, root } = requireProject(opts);
|
|
2132
|
+
await deploy(out, api(opts), name, {
|
|
2133
|
+
ref: cmdOpts.ref,
|
|
2134
|
+
local: cmdOpts.local ? root : void 0,
|
|
2135
|
+
...cmdOpts.wait === false ? { wait: false } : {}
|
|
2136
|
+
});
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
toExit(err, out);
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
program.command("dev").option("--port <port>", "port for the local instance", "8377").option("--no-watch", "deploy once and keep serving without watching").description("zero-setup local instance (embedded postgres) + deploy-on-change").action(async (cmdOpts) => {
|
|
2142
|
+
const { out, opts } = setup();
|
|
2143
|
+
try {
|
|
2144
|
+
const { name, root } = requireProject(opts);
|
|
2145
|
+
await dev(out, root, name, { port: Number(cmdOpts.port), ...cmdOpts.watch === false ? { watch: false } : {} });
|
|
2146
|
+
} catch (err) {
|
|
2147
|
+
toExit(err, out);
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
var auth = program.command("auth").description("agent-assisted Harness credential setup; values post directly to connect links");
|
|
2151
|
+
auth.command("claude-code").requiredOption("--connect <urlOrToken>", "Tesser /connect/<token> URL or token").option("--mode <mode>", "subscription or apiKey", "subscription").option("--token-stdin", "read token from stdin instead of running claude setup-token").option("--from-env <name>", "read token from an environment variable").option("--scope <scope>", "workspace or per_user", "workspace").option("--end-user-id <id>", "end-user id for per_user connections").option("--bin <path>", "claude binary", "claude").description("connect Claude Code as a brokered Harness; subscription mode runs claude setup-token").action(async (cmdOpts) => {
|
|
2152
|
+
const { out, opts } = setup();
|
|
2153
|
+
try {
|
|
2154
|
+
await authClaudeCode(out, target(opts).url, cmdOpts);
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
toExit(err, out);
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
auth.command("pi").requiredOption("--connect <urlOrToken>", "Tesser /connect/<token> URL or token").option("--mode <mode>", "anthropicOAuth or anthropicApiKey", "anthropicOAuth").option("--token-stdin", "read token from stdin").option("--from-env <name>", "read token from an environment variable").option("--scope <scope>", "workspace or per_user", "workspace").option("--end-user-id <id>", "end-user id for per_user connections").description("connect Pi as a brokered Harness").action(async (cmdOpts) => {
|
|
2160
|
+
const { out, opts } = setup();
|
|
2161
|
+
try {
|
|
2162
|
+
await authPi(out, target(opts).url, cmdOpts);
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
toExit(err, out);
|
|
2165
|
+
}
|
|
2166
|
+
});
|
|
2167
|
+
program.command("connect").option("--wait", "poll until the human completes the link").option("--status <token>", "check an existing connect link").description("mint a connect link for missing credentials; the human completes it in a browser (ADR-0005)").action(async (cmdOpts) => {
|
|
2168
|
+
const { out, opts } = setup();
|
|
2169
|
+
try {
|
|
2170
|
+
const client = api(opts);
|
|
2171
|
+
if (cmdOpts.status) {
|
|
2172
|
+
out.data(await client.get(`/connect-links/${cmdOpts.status}/status`));
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
const { name } = requireProject(opts);
|
|
2176
|
+
const minted = await client.post(
|
|
2177
|
+
"/connect-links",
|
|
2178
|
+
{ project: name }
|
|
2179
|
+
);
|
|
2180
|
+
if (!minted.url) {
|
|
2181
|
+
out.data({ url: null, requirements: [] }, () => "nothing missing \u2014 all requirements are satisfied");
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
out.data(minted, () => `open in a browser to connect:
|
|
2185
|
+
${minted.url}`);
|
|
2186
|
+
if (cmdOpts.wait) {
|
|
2187
|
+
for (; ; ) {
|
|
2188
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
2189
|
+
const status = await client.get(`/connect-links/${minted.token}/status`);
|
|
2190
|
+
if (status.status === "completed") {
|
|
2191
|
+
out.log("connect link completed \u2713");
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
if (status.status === "expired") throw new CliError(EXIT.HALTED_CREDENTIALS, "connect link expired");
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
process.exit(EXIT.HALTED_CREDENTIALS);
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
toExit(err, out);
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
var secrets = program.command("secrets").description("workspace secrets \u2014 names only; values never echo");
|
|
2203
|
+
secrets.command("list").action(async () => {
|
|
2204
|
+
const { out, opts } = setup();
|
|
2205
|
+
try {
|
|
2206
|
+
out.data(await api(opts).get("/secrets"));
|
|
2207
|
+
} catch (err) {
|
|
2208
|
+
toExit(err, out);
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
secrets.command("set").argument("<name>").option("--value-stdin", "read the value from stdin (pipe it; value never appears in argv)").description("set a secret value (human/CI lane: pipe via --value-stdin; agents mint connect links instead)").action(async (name, cmdOpts) => {
|
|
2212
|
+
const { out, opts } = setup();
|
|
2213
|
+
try {
|
|
2214
|
+
if (!cmdOpts.valueStdin) {
|
|
2215
|
+
throw new CliError(
|
|
2216
|
+
EXIT.USAGE,
|
|
2217
|
+
"refusing a value on argv \u2014 pipe it: `printenv MY_SECRET | tesser secrets set " + name + " --value-stdin` (or use a connect link)"
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
const chunks = [];
|
|
2221
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2222
|
+
const value = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
|
|
2223
|
+
if (value.length === 0) throw new CliError(EXIT.USAGE, "empty value on stdin");
|
|
2224
|
+
await api(opts).put(`/secrets/${encodeURIComponent(name)}`, { value });
|
|
2225
|
+
out.data({ set: name }, () => `secret "${name}" set \u2713`);
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
toExit(err, out);
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
secrets.command("rm").argument("<name>").action(async (name) => {
|
|
2231
|
+
const { out, opts } = setup();
|
|
2232
|
+
try {
|
|
2233
|
+
out.data(await api(opts).delete(`/secrets/${encodeURIComponent(name)}`));
|
|
2234
|
+
} catch (err) {
|
|
2235
|
+
toExit(err, out);
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
var runs = program.command("runs").description("inspect and drive runs");
|
|
2239
|
+
runs.command("list").option("--automation <id>").option("--status <status>").option("--limit <n>", "max rows", "25").action(async (cmdOpts) => {
|
|
2240
|
+
const { out, opts } = setup();
|
|
2241
|
+
try {
|
|
2242
|
+
const { name } = requireProject(opts);
|
|
2243
|
+
const params = new URLSearchParams({ project: name, limit: cmdOpts.limit });
|
|
2244
|
+
if (cmdOpts.automation) params.set("automation", cmdOpts.automation);
|
|
2245
|
+
if (cmdOpts.status) params.set("status", cmdOpts.status);
|
|
2246
|
+
const data = await api(opts).get(`/runs?${params}`);
|
|
2247
|
+
out.data(
|
|
2248
|
+
data,
|
|
2249
|
+
(d) => d.runs.map((r) => `${r.id} ${r.automation_id} ${r.status} (${r.trigger_kind}) ${r.created_at}`).join("\n") || "(no runs)"
|
|
2250
|
+
);
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
toExit(err, out);
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
runs.command("show").argument("<runId>").action(async (runId) => {
|
|
2256
|
+
const { out, opts } = setup();
|
|
2257
|
+
try {
|
|
2258
|
+
out.data(await api(opts).get(`/runs/${runId}`));
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
toExit(err, out);
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
runs.command("trigger").argument("<automation>").option("--input <json>", "input payload as JSON").option("--env <env>", "environment", "production").action(async (automation, cmdOpts) => {
|
|
2264
|
+
const { out, opts } = setup();
|
|
2265
|
+
try {
|
|
2266
|
+
const { name } = requireProject(opts);
|
|
2267
|
+
const body = { project: name, automation, env: cmdOpts.env };
|
|
2268
|
+
if (cmdOpts.input !== void 0) body["input"] = JSON.parse(cmdOpts.input);
|
|
2269
|
+
out.data(await api(opts).post("/runs", body));
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
toExit(err, out);
|
|
2272
|
+
}
|
|
2273
|
+
});
|
|
2274
|
+
runs.command("signal").argument("<runId>").argument("<name>").option("--payload <json>", "signal payload as JSON").action(async (runId, name, cmdOpts) => {
|
|
2275
|
+
const { out, opts } = setup();
|
|
2276
|
+
try {
|
|
2277
|
+
out.data(
|
|
2278
|
+
await api(opts).post(`/runs/${runId}/signals/${encodeURIComponent(name)}`, {
|
|
2279
|
+
...cmdOpts.payload !== void 0 ? { payload: JSON.parse(cmdOpts.payload) } : {}
|
|
2280
|
+
})
|
|
2281
|
+
);
|
|
2282
|
+
} catch (err) {
|
|
2283
|
+
toExit(err, out);
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
runs.command("cancel").argument("<runId>").action(async (runId) => {
|
|
2287
|
+
const { out, opts } = setup();
|
|
2288
|
+
try {
|
|
2289
|
+
out.data(await api(opts).post(`/runs/${runId}/cancel`));
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
toExit(err, out);
|
|
2292
|
+
}
|
|
2293
|
+
});
|
|
2294
|
+
program.command("logs").argument("<runId>").option("--follow", "poll until the run settles").description("step logs for one run").action(async (runId, cmdOpts) => {
|
|
2295
|
+
const { out, opts } = setup();
|
|
2296
|
+
try {
|
|
2297
|
+
const client = api(opts);
|
|
2298
|
+
let printed = 0;
|
|
2299
|
+
for (; ; ) {
|
|
2300
|
+
const detail = await client.get(`/runs/${runId}`);
|
|
2301
|
+
const fresh = detail.logs.slice(printed);
|
|
2302
|
+
printed = detail.logs.length;
|
|
2303
|
+
if (out.json && !cmdOpts.follow) {
|
|
2304
|
+
out.data(detail);
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
for (const l of fresh) out.log(`[${l.level}]${l.step ? ` (${l.step})` : ""} ${l.msg}`);
|
|
2308
|
+
if (!cmdOpts.follow || ["completed", "failed", "cancelled"].includes(detail.run.status)) {
|
|
2309
|
+
if (!out.json) out.log(`run ${detail.run.status}`);
|
|
2310
|
+
else out.data(detail);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2314
|
+
}
|
|
2315
|
+
} catch (err) {
|
|
2316
|
+
toExit(err, out);
|
|
2317
|
+
}
|
|
2318
|
+
});
|
|
2319
|
+
program.command("replay").argument("<runId>").description("freeze a real run as a committed regression fixture + test (ADR-0008)").action(async (runId) => {
|
|
2320
|
+
const { out, opts } = setup();
|
|
2321
|
+
try {
|
|
2322
|
+
const { root } = requireProject(opts);
|
|
2323
|
+
await replay(out, api(opts), root, runId);
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
toExit(err, out);
|
|
2326
|
+
}
|
|
2327
|
+
});
|
|
2328
|
+
program.command("rollback").argument("<automation>").requiredOption("--to <version>", "version number to re-point the alias at").option("--env <env>", "environment", "production").description("instant alias re-point to a prior immutable version (no rebuild)").action(async (automation, cmdOpts) => {
|
|
2329
|
+
const { out, opts } = setup();
|
|
2330
|
+
try {
|
|
2331
|
+
const { name } = requireProject(opts);
|
|
2332
|
+
out.data(
|
|
2333
|
+
await api(opts).post(`/projects/${name}/rollback`, {
|
|
2334
|
+
automation,
|
|
2335
|
+
toVersion: Number(cmdOpts.to),
|
|
2336
|
+
env: cmdOpts.env
|
|
2337
|
+
})
|
|
2338
|
+
);
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
toExit(err, out);
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
program.command("status").description("instance + project deploy status").action(async () => {
|
|
2344
|
+
const { out, opts } = setup();
|
|
2345
|
+
try {
|
|
2346
|
+
const t = target(opts);
|
|
2347
|
+
const client = api(opts);
|
|
2348
|
+
const health = await client.get("/health");
|
|
2349
|
+
const status = await client.get("/status");
|
|
2350
|
+
const manifest = t.projectRoot ? readLinkManifest(t.projectRoot) : null;
|
|
2351
|
+
const projectStatus = manifest ? await client.get(`/projects/${manifest.project}/deploys/latest`).catch(() => null) : null;
|
|
2352
|
+
out.data({ instance: t.url, health, status, project: manifest?.project ?? null, deploy: projectStatus });
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
toExit(err, out);
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
program.parseAsync().catch((err) => {
|
|
2358
|
+
const out = new Output(process.argv.includes("--json"));
|
|
2359
|
+
toExit(err, out);
|
|
2360
|
+
});
|
|
2361
|
+
//# sourceMappingURL=index.js.map
|