@devosurf/tesser-server 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.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +18 -0
  3. package/bin/tesser-server.mjs +2 -0
  4. package/dist/main.js +6296 -0
  5. package/dist/main.js.map +7 -0
  6. package/package.json +42 -0
  7. package/src/broker/broker.ts +332 -0
  8. package/src/broker/connect.ts +224 -0
  9. package/src/broker/connections.ts +278 -0
  10. package/src/broker/crypto.ts +39 -0
  11. package/src/broker/masking.ts +32 -0
  12. package/src/broker/oauth.ts +170 -0
  13. package/src/config.ts +128 -0
  14. package/src/db/db.ts +114 -0
  15. package/src/db/migrate.ts +35 -0
  16. package/src/db/migrations.ts +302 -0
  17. package/src/engine/executor.ts +536 -0
  18. package/src/engine/runs.ts +83 -0
  19. package/src/engine/signals.ts +18 -0
  20. package/src/engine/types.ts +53 -0
  21. package/src/events/fanout.ts +73 -0
  22. package/src/gitsync/build.ts +102 -0
  23. package/src/gitsync/deploy-keys.ts +59 -0
  24. package/src/gitsync/reconciler.ts +429 -0
  25. package/src/http/api.ts +425 -0
  26. package/src/http/app.ts +33 -0
  27. package/src/http/connect-view.ts +290 -0
  28. package/src/http/connect.ts +351 -0
  29. package/src/http/ingress.ts +204 -0
  30. package/src/http/status.ts +171 -0
  31. package/src/http/tokens.ts +46 -0
  32. package/src/index.ts +20 -0
  33. package/src/main.ts +26 -0
  34. package/src/queue/queue.ts +133 -0
  35. package/src/queue/worker.ts +85 -0
  36. package/src/registry/loader.ts +41 -0
  37. package/src/scheduler/cron.ts +115 -0
  38. package/src/scheduler/reaper.ts +105 -0
  39. package/src/server.ts +162 -0
  40. package/src/triggers/ingress.ts +154 -0
  41. package/src/triggers/poll.ts +167 -0
  42. package/src/triggers/registrar.ts +274 -0
  43. package/src/triggers/shared.ts +188 -0
@@ -0,0 +1,536 @@
1
+ // The durable engine (ADR-0002/0003): journal-of-results over plain Postgres. The
2
+ // handler re-executes from the top on every (re)invocation; completed steps return
3
+ // journaled results; everything else is ordinary TypeScript. Suspensions (sleep, signal
4
+ // waits, durable retries) unwind via RunSuspended and a queue job re-invokes later —
5
+ // hibernate-on-wait, zero idle cost.
6
+ //
7
+ // Exactly-once INTERNAL: a step's checkpoint (result + buffered events + fan-out jobs)
8
+ // commits in ONE transaction. External connector calls stay at-least-once with the
9
+ // auto-derived idempotency key (runId + step + occurrence).
10
+
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ import type { AutomationDef, Ctx, EventDefinition, Logger, Schema, StepOpts } from "@devosurf/tesser-sdk";
13
+ import { isRetryableError, isTerminalError, TerminalError, RetryableError } from "@devosurf/tesser-sdk";
14
+ import {
15
+ buildHarnesses,
16
+ buildOperators,
17
+ decodeJournal,
18
+ encodeJournal,
19
+ nextRetryDelayMs,
20
+ parseDuration,
21
+ resolveRetryPolicy,
22
+ validateSchema,
23
+ type JsonValue,
24
+ } from "@devosurf/tesser-sdk/internal";
25
+ import { enqueue } from "../queue/queue.js";
26
+ import { RunSuspended } from "./signals.js";
27
+ import type { EngineDeps, RunOutcome, RunRow } from "./types.js";
28
+
29
+ interface StepRow {
30
+ name: string;
31
+ occurrence: number;
32
+ status: string;
33
+ attempts: number;
34
+ result: JsonValue | null;
35
+ error: { name?: string; message?: string } | null;
36
+ }
37
+
38
+ interface ActiveStep {
39
+ name: string;
40
+ occurrence: number;
41
+ idempotencyKey: string;
42
+ unsafeWrite: boolean;
43
+ events: Array<{ name: string; payload: JsonValue }>;
44
+ }
45
+
46
+ function serializeError(err: unknown): JsonValue {
47
+ const e = err instanceof Error ? err : new Error(String(err));
48
+ return {
49
+ name: e.name,
50
+ message: e.message,
51
+ retryable: isRetryableError(err),
52
+ terminal: isTerminalError(err),
53
+ ...(e.stack !== undefined ? { stack: e.stack.split("\n").slice(0, 12).join("\n") } : {}),
54
+ };
55
+ }
56
+
57
+ export async function executeRun(deps: EngineDeps, runId: string): Promise<RunOutcome> {
58
+ const { db } = deps;
59
+ const { rows: runRows } = await db.query<RunRow>(`SELECT * FROM runs WHERE id = $1`, [runId]);
60
+ const run = runRows[0];
61
+ if (!run) return "skipped";
62
+ if (["completed", "failed", "cancelled"].includes(run.status)) return "skipped";
63
+ if (!run.version_id) {
64
+ await db.query(`UPDATE runs SET status='failed', error=$2::jsonb, finished_at=now() WHERE id=$1`, [
65
+ runId,
66
+ JSON.stringify({ name: "TesserError", message: "run has no version" }),
67
+ ]);
68
+ return "failed";
69
+ }
70
+
71
+ const def = await deps.loadAutomation(run.version_id);
72
+ let input = run.input === null ? undefined : decodeJournal(run.input);
73
+
74
+ // Validate input against the declared schema (def.input, or the trigger's own).
75
+ const inputSchema =
76
+ def.input ??
77
+ (def.trigger as { input?: Schema<unknown> }).input ??
78
+ (def.trigger as { event?: { schema?: Schema<unknown> } }).event?.schema;
79
+ if (inputSchema !== undefined && input !== undefined) {
80
+ try {
81
+ input = await validateSchema(inputSchema, input, `automation "${def.id}" input`);
82
+ } catch (err) {
83
+ await db.query(`UPDATE runs SET status='failed', error=$2::jsonb, finished_at=now() WHERE id=$1`, [
84
+ runId,
85
+ JSON.stringify(serializeError(err)),
86
+ ]);
87
+ return "failed";
88
+ }
89
+ }
90
+
91
+ // ---- concurrency gate (first start only; ADR sketch: onConflict queue|drop) ----
92
+ if (run.status === "queued" && def.concurrency) {
93
+ const key =
94
+ run.concurrency_key ??
95
+ (typeof def.concurrency.key === "function" ? String(def.concurrency.key(input as never)) : "");
96
+ const decision = await db.tx(async (c) => {
97
+ await c.query(`UPDATE runs SET concurrency_key = $2 WHERE id = $1`, [runId, key]);
98
+ const { rows } = await c.query<{ n: string }>(
99
+ `SELECT count(*)::text AS n FROM runs
100
+ WHERE project_id = $1 AND automation_id = $2 AND env = $3
101
+ AND concurrency_key = $4 AND id <> $5
102
+ AND status IN ('running','suspended')`,
103
+ [run.project_id, run.automation_id, run.env, key, runId],
104
+ );
105
+ const active = Number(rows[0]?.n ?? 0);
106
+ if (active < def.concurrency!.limit) return "go";
107
+ if ((def.concurrency!.onConflict ?? "queue") === "drop") {
108
+ await c.query(
109
+ `UPDATE runs SET status='cancelled', error=$2::jsonb, finished_at=now() WHERE id=$1`,
110
+ [runId, JSON.stringify({ name: "ConcurrencyDrop", message: "dropped: concurrency limit reached" })],
111
+ );
112
+ return "drop";
113
+ }
114
+ await enqueue(c, {
115
+ kind: "run",
116
+ payload: { runId },
117
+ runAtMs: Date.now() + 2000,
118
+ dedupeKey: `run:${runId}`,
119
+ });
120
+ return "defer";
121
+ });
122
+ if (decision === "drop") return "skipped";
123
+ if (decision === "defer") return "deferred";
124
+ }
125
+
126
+ const attempt = run.attempt + 1;
127
+ await db.query(
128
+ `UPDATE runs SET status='running', attempt=$2, started_at=COALESCE(started_at, now()),
129
+ waiting_signal=NULL, wake_at=NULL WHERE id=$1`,
130
+ [runId, attempt],
131
+ );
132
+
133
+ // ---- journal ----
134
+ const { rows: stepRows } = await db.query<StepRow>(
135
+ `SELECT name, occurrence, status, attempts, result, error FROM run_steps WHERE run_id = $1`,
136
+ [runId],
137
+ );
138
+ const journal = new Map<string, StepRow>();
139
+ for (const r of stepRows) journal.set(`${r.name}#${r.occurrence}`, r);
140
+
141
+ const occurrences = new Map<string, number>();
142
+ const nextOcc = (name: string): number => {
143
+ const n = (occurrences.get(name) ?? 0) + 1;
144
+ occurrences.set(name, n);
145
+ return n;
146
+ };
147
+
148
+ const undoStack: Array<{ name: string; occurrence: number; undo: () => unknown | Promise<unknown> }> = [];
149
+ const active = new AsyncLocalStorage<ActiveStep>();
150
+ const defaultRetry = resolveRetryPolicy(def.retry);
151
+
152
+ const log = (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>, step?: string) => {
153
+ void db
154
+ .query(`INSERT INTO run_logs (run_id, step, level, msg, meta) VALUES ($1,$2,$3,$4,$5::jsonb)`, [
155
+ runId,
156
+ step ?? active.getStore()?.name ?? null,
157
+ level,
158
+ deps.mask(msg),
159
+ meta ? deps.mask(JSON.stringify(encodeJournal(meta))) : null,
160
+ ])
161
+ .catch(() => {});
162
+ };
163
+ const logger: Logger = {
164
+ info: (m, meta) => log("info", m, meta),
165
+ warn: (m, meta) => log("warn", m, meta),
166
+ error: (m, meta) => log("error", m, meta),
167
+ };
168
+
169
+ const hooks = {
170
+ current() {
171
+ const s = active.getStore();
172
+ if (!s) return undefined;
173
+ return {
174
+ name: s.name,
175
+ occurrence: s.occurrence,
176
+ idempotencyKey: s.idempotencyKey,
177
+ markUnsafeWrite: () => {
178
+ s.unsafeWrite = true;
179
+ },
180
+ };
181
+ },
182
+ };
183
+
184
+ const connections = await deps.buildConnections(run, def, hooks);
185
+ const secrets = await deps.resolveSecrets(run, def);
186
+
187
+ const suspendForRetry = async (delayMs: number, reason: string): Promise<never> => {
188
+ await db.tx(async (c) => {
189
+ await c.query(`UPDATE runs SET status='suspended', wake_at=$2 WHERE id=$1`, [
190
+ runId,
191
+ new Date(Date.now() + delayMs).toISOString(),
192
+ ]);
193
+ await enqueue(c, {
194
+ kind: "run",
195
+ payload: { runId },
196
+ runAtMs: Date.now() + delayMs,
197
+ dedupeKey: `run:${runId}`,
198
+ });
199
+ });
200
+ throw new RunSuspended(reason);
201
+ };
202
+
203
+ const ctx: Ctx<any, any, any, any> = {
204
+ async step<T>(name: string, fn: () => Promise<T> | T, opts?: StepOpts<T>): Promise<T> {
205
+ if (typeof name !== "string" || name.length === 0 || name.startsWith("$")) {
206
+ throw new TerminalError(`ctx.step: invalid step name ${JSON.stringify(name)} ("$" prefix is reserved)`);
207
+ }
208
+ if (active.getStore()) {
209
+ throw new TerminalError(`ctx.step("${name}") called inside step "${active.getStore()!.name}" — steps do not nest`);
210
+ }
211
+ const occurrence = nextOcc(name);
212
+ const key = `${name}#${occurrence}`;
213
+ const row = journal.get(key);
214
+
215
+ if (row?.status === "completed") {
216
+ const value = decodeJournal(row.result as JsonValue) as T;
217
+ if (opts?.undo) undoStack.push({ name, occurrence, undo: () => opts.undo!(value) });
218
+ return value;
219
+ }
220
+ if (row?.status === "failed") {
221
+ // The step already exhausted its attempts in a prior invocation; re-throwing the
222
+ // recorded error keeps author-level try/catch paths deterministic.
223
+ const msg = row.error?.message ?? "step failed";
224
+ throw row.error?.name === "RetryableError" ? new RetryableError(msg) : new TerminalError(msg);
225
+ }
226
+
227
+ const policy = resolveRetryPolicy(opts?.retry, defaultRetry);
228
+ const attempts = (row?.attempts ?? 0) + 1;
229
+ if (row) {
230
+ await db.query(`UPDATE run_steps SET attempts=$3, status='running' WHERE run_id=$1 AND name=$2 AND occurrence=$4`, [
231
+ runId,
232
+ name,
233
+ attempts,
234
+ occurrence,
235
+ ]);
236
+ } else {
237
+ await db.query(
238
+ `INSERT INTO run_steps (run_id, name, occurrence, status, attempts, has_undo)
239
+ VALUES ($1,$2,$3,'running',$4,$5)`,
240
+ [runId, name, occurrence, attempts, opts?.undo !== undefined],
241
+ );
242
+ }
243
+
244
+ const state: ActiveStep = {
245
+ name,
246
+ occurrence,
247
+ idempotencyKey: `${runId}:${name}:${occurrence}`,
248
+ unsafeWrite: false,
249
+ events: [],
250
+ };
251
+
252
+ try {
253
+ let p = Promise.resolve(active.run(state, () => fn()));
254
+ const timeoutMs = opts?.timeout !== undefined ? parseDuration(opts.timeout, "step timeout") : undefined;
255
+ if (timeoutMs !== undefined) {
256
+ let timer: NodeJS.Timeout;
257
+ p = Promise.race([
258
+ p,
259
+ new Promise<never>((_res, reject) => {
260
+ timer = setTimeout(
261
+ () => reject(new RetryableError(`step "${name}" timed out after ${opts?.timeout}`)),
262
+ timeoutMs,
263
+ );
264
+ timer.unref?.();
265
+ }),
266
+ ]).finally(() => clearTimeout(timer));
267
+ }
268
+ const result = await p;
269
+ const encoded = encodeJournal(result);
270
+
271
+ // THE exactly-once-internal checkpoint: result + outbox events + fan-out jobs.
272
+ await db.tx(async (c) => {
273
+ await c.query(
274
+ `UPDATE run_steps SET status='completed', result=$4::jsonb, finished_at=now()
275
+ WHERE run_id=$1 AND name=$2 AND occurrence=$3`,
276
+ [runId, name, occurrence, JSON.stringify(encoded)],
277
+ );
278
+ for (const evt of state.events) {
279
+ const { rows } = await c.query<{ id: string }>(
280
+ `INSERT INTO events (project_id, env, name, payload, emitted_by_run)
281
+ VALUES ($1,$2,$3,$4::jsonb,$5) RETURNING id`,
282
+ [run.project_id, run.env, evt.name, JSON.stringify(evt.payload), runId],
283
+ );
284
+ await enqueue(c, { kind: "event-fanout", payload: { eventId: rows[0]!.id } });
285
+ }
286
+ });
287
+ journal.set(key, { name, occurrence, status: "completed", attempts, result: encoded, error: null });
288
+ if (opts?.undo) undoStack.push({ name, occurrence, undo: () => opts.undo!(result) });
289
+ return result;
290
+ } catch (err) {
291
+ if (err instanceof RunSuspended) throw err;
292
+ const serialized = serializeError(err);
293
+ const finalize = async () => {
294
+ await db.query(
295
+ `UPDATE run_steps SET status='failed', error=$4::jsonb, finished_at=now()
296
+ WHERE run_id=$1 AND name=$2 AND occurrence=$3`,
297
+ [runId, name, occurrence, JSON.stringify(serialized)],
298
+ );
299
+ journal.set(key, { name, occurrence, status: "failed", attempts, result: null, error: serialized as never });
300
+ };
301
+
302
+ if (isTerminalError(err)) {
303
+ await finalize();
304
+ throw err;
305
+ }
306
+ if (state.unsafeWrite && opts?.retry === undefined) {
307
+ log(
308
+ "warn",
309
+ `step "${name}" performed a non-retry-safe write and will not auto-retry (ADR-0012) — pass StepOpts.retry to opt in`,
310
+ undefined,
311
+ name,
312
+ );
313
+ await finalize();
314
+ throw err;
315
+ }
316
+ const delay = nextRetryDelayMs(policy, attempts, isRetryableError(err) ? err.retryAfterMs : undefined);
317
+ if (delay === null) {
318
+ await finalize();
319
+ throw err;
320
+ }
321
+ await db.query(
322
+ `UPDATE run_steps SET error=$4::jsonb WHERE run_id=$1 AND name=$2 AND occurrence=$3`,
323
+ [runId, name, occurrence, JSON.stringify(serialized)],
324
+ );
325
+ log("warn", `step "${name}" attempt ${attempts} failed (${(err as Error).message}) — retrying in ${delay}ms`, undefined, name);
326
+ return suspendForRetry(delay, `retry step ${name}#${occurrence}`);
327
+ }
328
+ },
329
+
330
+ connections: connections as never,
331
+ secrets: secrets as never,
332
+ operators: {} as never,
333
+ harnesses: {} as never,
334
+
335
+ async sleep(duration: string): Promise<void> {
336
+ if (active.getStore()) {
337
+ throw new TerminalError(`ctx.sleep cannot be called inside step "${active.getStore()!.name}"`);
338
+ }
339
+ const ms = parseDuration(duration, "ctx.sleep");
340
+ const occurrence = nextOcc("$sleep");
341
+ const row = journal.get(`$sleep#${occurrence}`);
342
+ if (row?.status === "completed") return;
343
+
344
+ let wakeAtMs: number;
345
+ if (row) {
346
+ wakeAtMs = Date.parse((decodeJournal(row.result as JsonValue) as { wakeAt: string }).wakeAt);
347
+ } else {
348
+ wakeAtMs = Date.now() + ms;
349
+ await db.query(
350
+ `INSERT INTO run_steps (run_id, name, occurrence, status, attempts, result)
351
+ VALUES ($1,'$sleep',$2,'running',1,$3::jsonb)`,
352
+ [runId, occurrence, JSON.stringify(encodeJournal({ wakeAt: new Date(wakeAtMs).toISOString() }))],
353
+ );
354
+ }
355
+ if (wakeAtMs <= Date.now()) {
356
+ await db.query(
357
+ `UPDATE run_steps SET status='completed', finished_at=now() WHERE run_id=$1 AND name='$sleep' AND occurrence=$2`,
358
+ [runId, occurrence],
359
+ );
360
+ journal.set(`$sleep#${occurrence}`, { name: "$sleep", occurrence, status: "completed", attempts: 1, result: null, error: null });
361
+ return;
362
+ }
363
+ return suspendForRetry(wakeAtMs - Date.now(), `sleep ${duration}`) as Promise<never>;
364
+ },
365
+
366
+ async waitForSignal<T>(name: string, opts: { schema: Schema<T>; timeout?: string }): Promise<T | null> {
367
+ if (active.getStore()) {
368
+ throw new TerminalError(`ctx.waitForSignal cannot be called inside step "${active.getStore()!.name}"`);
369
+ }
370
+ const stepName = `$signal:${name}`;
371
+ const occurrence = nextOcc(stepName);
372
+ const row = journal.get(`${stepName}#${occurrence}`);
373
+ if (row?.status === "completed") {
374
+ const stored = decodeJournal(row.result as JsonValue) as { value?: unknown; timedOut?: boolean };
375
+ return stored.timedOut === true ? null : (stored.value as T);
376
+ }
377
+
378
+ let deadlineMs: number | undefined;
379
+ if (row) {
380
+ const stored = decodeJournal(row.result as JsonValue) as { deadline?: string };
381
+ deadlineMs = stored.deadline !== undefined ? Date.parse(stored.deadline) : undefined;
382
+ } else if (opts.timeout !== undefined) {
383
+ deadlineMs = Date.now() + parseDuration(opts.timeout, "waitForSignal timeout");
384
+ }
385
+
386
+ // Try to consume a delivered signal (exactly-once: consumption + checkpoint in one tx).
387
+ const consumed = await db.tx(async (c) => {
388
+ const { rows } = await c.query<{ id: string; payload: JsonValue }>(
389
+ `SELECT id, payload FROM signals
390
+ WHERE run_id=$1 AND name=$2 AND NOT consumed
391
+ ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`,
392
+ [runId, name],
393
+ );
394
+ const sig = rows[0];
395
+ if (!sig) return undefined;
396
+ await c.query(`UPDATE signals SET consumed=true WHERE id=$1`, [sig.id]);
397
+ const resultJson = JSON.stringify(encodeJournal({ value: decodeJournal(sig.payload ?? null) }));
398
+ if (row) {
399
+ await c.query(
400
+ `UPDATE run_steps SET status='completed', result=$4::jsonb, finished_at=now()
401
+ WHERE run_id=$1 AND name=$2 AND occurrence=$3`,
402
+ [runId, stepName, occurrence, resultJson],
403
+ );
404
+ } else {
405
+ await c.query(
406
+ `INSERT INTO run_steps (run_id, name, occurrence, status, attempts, result, finished_at)
407
+ VALUES ($1,$2,$3,'completed',1,$4::jsonb,now())`,
408
+ [runId, stepName, occurrence, resultJson],
409
+ );
410
+ }
411
+ return sig.payload ?? null;
412
+ });
413
+ if (consumed !== undefined) {
414
+ const value = decodeJournal(consumed);
415
+ journal.set(`${stepName}#${occurrence}`, {
416
+ name: stepName,
417
+ occurrence,
418
+ status: "completed",
419
+ attempts: 1,
420
+ result: encodeJournal({ value }),
421
+ error: null,
422
+ });
423
+ return validateSchema(opts.schema, value, `signal "${name}" payload`);
424
+ }
425
+
426
+ if (deadlineMs !== undefined && deadlineMs <= Date.now()) {
427
+ const resultJson = JSON.stringify(encodeJournal({ timedOut: true }));
428
+ await db.query(
429
+ row
430
+ ? `UPDATE run_steps SET status='completed', result=$4::jsonb, finished_at=now() WHERE run_id=$1 AND name=$2 AND occurrence=$3`
431
+ : `INSERT INTO run_steps (run_id, name, occurrence, status, attempts, result, finished_at) VALUES ($1,$2,$3,'completed',1,$4::jsonb,now())`,
432
+ [runId, stepName, occurrence, resultJson],
433
+ );
434
+ journal.set(`${stepName}#${occurrence}`, { name: stepName, occurrence, status: "completed", attempts: 1, result: { timedOut: true } as never, error: null });
435
+ return null;
436
+ }
437
+
438
+ if (!row) {
439
+ await db.query(
440
+ `INSERT INTO run_steps (run_id, name, occurrence, status, attempts, result)
441
+ VALUES ($1,$2,$3,'running',1,$4::jsonb)`,
442
+ [
443
+ runId,
444
+ stepName,
445
+ occurrence,
446
+ JSON.stringify(
447
+ encodeJournal(deadlineMs !== undefined ? { deadline: new Date(deadlineMs).toISOString() } : {}),
448
+ ),
449
+ ],
450
+ );
451
+ }
452
+ await db.tx(async (c) => {
453
+ await c.query(`UPDATE runs SET status='suspended', waiting_signal=$2, wake_at=$3 WHERE id=$1`, [
454
+ runId,
455
+ name,
456
+ deadlineMs !== undefined ? new Date(deadlineMs).toISOString() : null,
457
+ ]);
458
+ if (deadlineMs !== undefined) {
459
+ await enqueue(c, { kind: "run", payload: { runId }, runAtMs: deadlineMs, dedupeKey: `run:${runId}` });
460
+ }
461
+ });
462
+ throw new RunSuspended(`waitForSignal ${name}`);
463
+ },
464
+
465
+ async emit<T>(event: EventDefinition<T>, payload: T): Promise<void> {
466
+ const state = active.getStore();
467
+ if (!state) {
468
+ throw new TerminalError(
469
+ `ctx.emit("${event.name}") outside ctx.step — emitting is a side effect; wrap it (ADR-0002)`,
470
+ );
471
+ }
472
+ const validated = await validateSchema(event.schema, payload, `event "${event.name}" payload`);
473
+ state.events.push({ name: event.name, payload: encodeJournal(validated as unknown) });
474
+ },
475
+
476
+ ...(run.trigger["request"] !== undefined
477
+ ? {
478
+ request: (() => {
479
+ const r = run.trigger["request"] as { headers: Record<string, string>; query: Record<string, string>; rawBodyB64: string };
480
+ return {
481
+ headers: r.headers ?? {},
482
+ query: r.query ?? {},
483
+ rawBody: new Uint8Array(Buffer.from(r.rawBodyB64 ?? "", "base64")),
484
+ };
485
+ })(),
486
+ }
487
+ : {}),
488
+
489
+ logger,
490
+ run: { id: runId, attempt, automationId: run.automation_id },
491
+ };
492
+
493
+ (ctx as { operators: unknown }).operators = buildOperators(def, ctx, async (info) => {
494
+ if (!deps.callModel) throw new TerminalError(`operator.${info.operatorKey}: no model adapter configured`);
495
+ return deps.callModel(run, def, hooks, info);
496
+ });
497
+ (ctx as { harnesses: unknown }).harnesses = buildHarnesses(def, ctx, async (info) => {
498
+ if (!deps.callHarness) throw new TerminalError(`harness.${info.harnessKey}: no Harness adapter configured`);
499
+ return deps.callHarness(run, def, hooks, info);
500
+ });
501
+
502
+ try {
503
+ let output: unknown = await def.run(input as never, ctx as never);
504
+ if (def.output) {
505
+ output = await validateSchema(def.output, output, `automation "${def.id}" output`);
506
+ }
507
+ await db.query(`UPDATE runs SET status='completed', output=$2::jsonb, finished_at=now() WHERE id=$1`, [
508
+ runId,
509
+ JSON.stringify(encodeJournal(output)),
510
+ ]);
511
+ return "completed";
512
+ } catch (err) {
513
+ if (err instanceof RunSuspended) return "suspended";
514
+
515
+ // Terminal failure → undo completed steps in reverse, best-effort + durable bookkeeping.
516
+ for (const item of [...undoStack].reverse()) {
517
+ try {
518
+ await item.undo();
519
+ await db.query(`UPDATE run_steps SET undone=true WHERE run_id=$1 AND name=$2 AND occurrence=$3`, [
520
+ runId,
521
+ item.name,
522
+ item.occurrence,
523
+ ]);
524
+ log("info", `undo: reversed step "${item.name}"`, undefined, item.name);
525
+ } catch (undoErr) {
526
+ log("error", `undo for step "${item.name}" failed: ${(undoErr as Error).message}`, undefined, item.name);
527
+ }
528
+ }
529
+ await db.query(`UPDATE runs SET status='failed', error=$2::jsonb, finished_at=now() WHERE id=$1`, [
530
+ runId,
531
+ JSON.stringify(serializeError(err)),
532
+ ]);
533
+ log("error", `run failed: ${(err as Error).message}`);
534
+ return "failed";
535
+ }
536
+ }
@@ -0,0 +1,83 @@
1
+ // Run lifecycle entry points: create-and-enqueue (one tx), signal delivery (wakes the
2
+ // waiting run), cancel.
3
+
4
+ import type { JsonValue } from "@devosurf/tesser-sdk/internal";
5
+ import { encodeJournal } from "@devosurf/tesser-sdk/internal";
6
+ import type { Db, DbClient } from "../db/db.js";
7
+ import { enqueue } from "../queue/queue.js";
8
+
9
+ export interface CreateRunOpts {
10
+ projectId: string;
11
+ automationId: string;
12
+ versionId: string;
13
+ env?: string;
14
+ trigger: { kind: string; [k: string]: unknown };
15
+ input?: unknown;
16
+ /** Cross-trigger dedup (e.g. webhook delivery id): same key → one run, ever. */
17
+ dedupeKey?: string;
18
+ }
19
+
20
+ export async function createRun(dbOrClient: Db | DbClient, opts: CreateRunOpts): Promise<string | null> {
21
+ const exec = async (c: DbClient): Promise<string | null> => {
22
+ const { rows } = await c.query<{ id: string }>(
23
+ `INSERT INTO runs (project_id, automation_id, version_id, env, status, trigger, input)
24
+ VALUES ($1,$2,$3,$4,'queued',$5::jsonb,$6::jsonb)
25
+ RETURNING id`,
26
+ [
27
+ opts.projectId,
28
+ opts.automationId,
29
+ opts.versionId,
30
+ opts.env ?? "production",
31
+ JSON.stringify(opts.trigger),
32
+ opts.input === undefined ? null : JSON.stringify(encodeJournal(opts.input)),
33
+ ],
34
+ );
35
+ const runId = rows[0]!.id;
36
+ await enqueue(c, {
37
+ kind: "run",
38
+ payload: { runId },
39
+ dedupeKey: `run:${runId}`,
40
+ ...(opts.dedupeKey !== undefined ? {} : {}),
41
+ });
42
+ return runId;
43
+ };
44
+ if ("tx" in dbOrClient) return dbOrClient.tx(exec);
45
+ return exec(dbOrClient);
46
+ }
47
+
48
+ export interface SignalDelivery {
49
+ runId: string;
50
+ name: string;
51
+ payload?: unknown;
52
+ }
53
+
54
+ /** Deliver a Signal: exactly-once input for ONE suspended run. Wakes it immediately
55
+ * when it is waiting on this name. Returns false when the run doesn't exist. */
56
+ export async function deliverSignal(db: Db, sig: SignalDelivery): Promise<boolean> {
57
+ return db.tx(async (c) => {
58
+ const { rows } = await c.query<{ id: string; status: string; waiting_signal: string | null }>(
59
+ `SELECT id, status, waiting_signal FROM runs WHERE id = $1 FOR UPDATE`,
60
+ [sig.runId],
61
+ );
62
+ const run = rows[0];
63
+ if (!run) return false;
64
+ await c.query(`INSERT INTO signals (run_id, name, payload) VALUES ($1,$2,$3::jsonb)`, [
65
+ sig.runId,
66
+ sig.name,
67
+ sig.payload === undefined ? null : JSON.stringify(encodeJournal(sig.payload)),
68
+ ]);
69
+ if (run.status === "suspended" && run.waiting_signal === sig.name) {
70
+ await enqueue(c, { kind: "run", payload: { runId: sig.runId }, dedupeKey: `run:${sig.runId}` });
71
+ }
72
+ return true;
73
+ });
74
+ }
75
+
76
+ export async function cancelRun(db: Db, runId: string, reason: string): Promise<boolean> {
77
+ const { rowCount } = await db.query(
78
+ `UPDATE runs SET status='cancelled', error=$2::jsonb, finished_at=now()
79
+ WHERE id=$1 AND status IN ('queued','suspended')`,
80
+ [runId, JSON.stringify({ name: "Cancelled", message: reason } satisfies Record<string, JsonValue>)],
81
+ );
82
+ return rowCount > 0;
83
+ }
@@ -0,0 +1,18 @@
1
+ // Control-flow signal: thrown to unwind the handler when a run hibernates (sleep,
2
+ // waitForSignal, durable retry). Zero idle cost (ADR-0002): nothing stays in memory —
3
+ // a queue job (or an arriving Signal) re-invokes the handler, and completed steps
4
+ // replay from the journal.
5
+ //
6
+ // Authors: never catch this. `catch (e)` blocks that swallow unknown errors instead of
7
+ // rethrowing will break suspension — the same caveat every durable runtime carries.
8
+
9
+ export class RunSuspended extends Error {
10
+ constructor(readonly reason: string) {
11
+ super(`tesser: run suspended (${reason}) — do not catch this`);
12
+ this.name = "TesserRunSuspended";
13
+ }
14
+ }
15
+
16
+ export function isRunSuspended(err: unknown): err is RunSuspended {
17
+ return err instanceof RunSuspended || (err as Error)?.name === "TesserRunSuspended";
18
+ }
@@ -0,0 +1,53 @@
1
+ import type { AutomationDef } from "@devosurf/tesser-sdk";
2
+ import type { HarnessCallInfo, JsonValue, ModelCallInfo } from "@devosurf/tesser-sdk/internal";
3
+ import type { Db } from "../db/db.js";
4
+
5
+ export interface RunRow {
6
+ id: string;
7
+ project_id: string;
8
+ automation_id: string;
9
+ version_id: string | null;
10
+ env: string;
11
+ status: string;
12
+ trigger: { kind: string; [k: string]: unknown };
13
+ input: JsonValue | null;
14
+ attempt: number;
15
+ concurrency_key: string | null;
16
+ waiting_signal: string | null;
17
+ }
18
+
19
+ /** Hooks the broker layer uses to attribute connector calls to the active step. */
20
+ export interface ActiveStepHooks {
21
+ current():
22
+ | { name: string; occurrence: number; idempotencyKey: string; markUnsafeWrite(): void }
23
+ | undefined;
24
+ }
25
+
26
+ /** Engine dependencies — the seams where the loader (git-sync artifacts) and the
27
+ * credential broker plug in; tests inject in-memory versions. */
28
+ export interface EngineDeps {
29
+ db: Db;
30
+ loadAutomation(versionId: string): Promise<AutomationDef<any, any, any, any, any, any, any>>;
31
+ buildConnections(
32
+ run: RunRow,
33
+ def: AutomationDef<any, any, any, any, any, any, any>,
34
+ hooks: ActiveStepHooks,
35
+ ): Promise<Record<string, unknown>>;
36
+ resolveSecrets(run: RunRow, def: AutomationDef<any, any, any, any, any, any, any>): Promise<Record<string, string>>;
37
+ callModel?(
38
+ run: RunRow,
39
+ def: AutomationDef<any, any, any, any, any, any, any>,
40
+ hooks: ActiveStepHooks,
41
+ info: ModelCallInfo,
42
+ ): Promise<import("@devosurf/tesser-sdk").NormalizedModelResponse>;
43
+ callHarness?(
44
+ run: RunRow,
45
+ def: AutomationDef<any, any, any, any, any, any, any>,
46
+ hooks: ActiveStepHooks,
47
+ info: HarnessCallInfo,
48
+ ): Promise<import("@devosurf/tesser-sdk").HarnessRunResult<unknown>>;
49
+ /** Mask credential material out of any string headed for logs (ADR-0005). */
50
+ mask(text: string): string;
51
+ }
52
+
53
+ export type RunOutcome = "completed" | "failed" | "suspended" | "skipped" | "deferred";