@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.
- package/LICENSE +661 -0
- package/README.md +18 -0
- package/bin/tesser-server.mjs +2 -0
- package/dist/main.js +6296 -0
- package/dist/main.js.map +7 -0
- package/package.json +42 -0
- package/src/broker/broker.ts +332 -0
- package/src/broker/connect.ts +224 -0
- package/src/broker/connections.ts +278 -0
- package/src/broker/crypto.ts +39 -0
- package/src/broker/masking.ts +32 -0
- package/src/broker/oauth.ts +170 -0
- package/src/config.ts +128 -0
- package/src/db/db.ts +114 -0
- package/src/db/migrate.ts +35 -0
- package/src/db/migrations.ts +302 -0
- package/src/engine/executor.ts +536 -0
- package/src/engine/runs.ts +83 -0
- package/src/engine/signals.ts +18 -0
- package/src/engine/types.ts +53 -0
- package/src/events/fanout.ts +73 -0
- package/src/gitsync/build.ts +102 -0
- package/src/gitsync/deploy-keys.ts +59 -0
- package/src/gitsync/reconciler.ts +429 -0
- package/src/http/api.ts +425 -0
- package/src/http/app.ts +33 -0
- package/src/http/connect-view.ts +290 -0
- package/src/http/connect.ts +351 -0
- package/src/http/ingress.ts +204 -0
- package/src/http/status.ts +171 -0
- package/src/http/tokens.ts +46 -0
- package/src/index.ts +20 -0
- package/src/main.ts +26 -0
- package/src/queue/queue.ts +133 -0
- package/src/queue/worker.ts +85 -0
- package/src/registry/loader.ts +41 -0
- package/src/scheduler/cron.ts +115 -0
- package/src/scheduler/reaper.ts +105 -0
- package/src/server.ts +162 -0
- package/src/triggers/ingress.ts +154 -0
- package/src/triggers/poll.ts +167 -0
- package/src/triggers/registrar.ts +274 -0
- package/src/triggers/shared.ts +188 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Artifact loader: versionId → the automation def from its immutable bundle. Bundles are
|
|
2
|
+
// content-addressed and immutable, so the cache never invalidates. Test seam included.
|
|
3
|
+
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type { AutomationDef } from "@devosurf/tesser-sdk";
|
|
6
|
+
import type { Db } from "../db/db.js";
|
|
7
|
+
|
|
8
|
+
export class ArtifactLoader {
|
|
9
|
+
private cache = new Map<string, Promise<AutomationDef<any, any, any, any, any, any, any>>>();
|
|
10
|
+
private injected = new Map<string, AutomationDef<any, any, any, any, any, any, any>>();
|
|
11
|
+
|
|
12
|
+
constructor(private readonly db: Db) {}
|
|
13
|
+
|
|
14
|
+
/** Tests (and the reconciler, right after extraction) hand defs over directly. */
|
|
15
|
+
inject(versionId: string, def: AutomationDef<any, any, any, any, any, any, any>): void {
|
|
16
|
+
this.injected.set(versionId, def);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
load = async (versionId: string): Promise<AutomationDef<any, any, any, any, any, any, any>> => {
|
|
20
|
+
const injected = this.injected.get(versionId);
|
|
21
|
+
if (injected) return injected;
|
|
22
|
+
let p = this.cache.get(versionId);
|
|
23
|
+
if (!p) {
|
|
24
|
+
p = (async () => {
|
|
25
|
+
const { rows } = await this.db.query<{ bundle_path: string }>(
|
|
26
|
+
`SELECT bundle_path FROM automation_versions WHERE id=$1`,
|
|
27
|
+
[versionId],
|
|
28
|
+
);
|
|
29
|
+
if (!rows[0]) throw new Error(`no automation version ${versionId}`);
|
|
30
|
+
const mod = (await import(pathToFileURL(rows[0].bundle_path).href)) as {
|
|
31
|
+
default?: AutomationDef<any, any, any, any, any, any, any>;
|
|
32
|
+
};
|
|
33
|
+
if (!mod.default) throw new Error(`bundle missing default export: ${rows[0].bundle_path}`);
|
|
34
|
+
return mod.default;
|
|
35
|
+
})();
|
|
36
|
+
this.cache.set(versionId, p);
|
|
37
|
+
p.catch(() => this.cache.delete(versionId));
|
|
38
|
+
}
|
|
39
|
+
return p;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Cron scheduler: schedules are synced from manifests on promote; a tick fires due ones
|
|
2
|
+
// by creating runs (overlap policy is the automation's own `concurrency` — the engine
|
|
3
|
+
// gates it). Cron parsing/tz via an internal dependency, never in the public surface.
|
|
4
|
+
|
|
5
|
+
import { Cron } from "croner";
|
|
6
|
+
import type { Db } from "../db/db.js";
|
|
7
|
+
import { createRun } from "../engine/runs.js";
|
|
8
|
+
|
|
9
|
+
export interface ScheduleSpec {
|
|
10
|
+
automationId: string;
|
|
11
|
+
cron: string;
|
|
12
|
+
tz?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function nextFire(cron: string, tz: string | undefined, after = new Date()): Date | null {
|
|
16
|
+
const c = new Cron(cron, { timezone: tz ?? "UTC" });
|
|
17
|
+
return c.nextRun(after);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reconcile a project+env's schedules to exactly `specs` (called on promote). */
|
|
21
|
+
export async function syncSchedules(
|
|
22
|
+
db: Db,
|
|
23
|
+
projectId: string,
|
|
24
|
+
env: string,
|
|
25
|
+
specs: ScheduleSpec[],
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
await db.tx(async (c) => {
|
|
28
|
+
const ids = specs.map((s) => s.automationId);
|
|
29
|
+
if (ids.length === 0) {
|
|
30
|
+
await c.query(`DELETE FROM schedules WHERE project_id=$1 AND env=$2`, [projectId, env]);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await c.query(
|
|
34
|
+
`DELETE FROM schedules WHERE project_id=$1 AND env=$2 AND NOT (automation_id = ANY($3))`,
|
|
35
|
+
[projectId, env, ids],
|
|
36
|
+
);
|
|
37
|
+
for (const spec of specs) {
|
|
38
|
+
const fire = nextFire(spec.cron, spec.tz);
|
|
39
|
+
await c.query(
|
|
40
|
+
`INSERT INTO schedules (project_id, automation_id, env, cron, tz, enabled, next_fire)
|
|
41
|
+
VALUES ($1,$2,$3,$4,$5,true,$6)
|
|
42
|
+
ON CONFLICT (project_id, automation_id, env) DO UPDATE
|
|
43
|
+
SET cron = EXCLUDED.cron,
|
|
44
|
+
tz = EXCLUDED.tz,
|
|
45
|
+
enabled = true,
|
|
46
|
+
-- recompute only when the cron/tz changed; otherwise keep the watermark
|
|
47
|
+
next_fire = CASE
|
|
48
|
+
WHEN schedules.cron <> EXCLUDED.cron OR schedules.tz IS DISTINCT FROM EXCLUDED.tz
|
|
49
|
+
THEN EXCLUDED.next_fire
|
|
50
|
+
ELSE schedules.next_fire
|
|
51
|
+
END`,
|
|
52
|
+
[projectId, spec.automationId, env, spec.cron, spec.tz ?? null, fire?.toISOString() ?? null],
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fire everything due. Returns created run ids. Safe under concurrent tickers
|
|
59
|
+
* (FOR UPDATE SKIP LOCKED on the schedule rows). */
|
|
60
|
+
export async function schedulerTick(db: Db, now = new Date()): Promise<string[]> {
|
|
61
|
+
const created: string[] = [];
|
|
62
|
+
// Claim due schedules one at a time — each fire commits independently.
|
|
63
|
+
for (;;) {
|
|
64
|
+
const fired = await db.tx(async (c) => {
|
|
65
|
+
const { rows } = await c.query<{
|
|
66
|
+
project_id: string;
|
|
67
|
+
automation_id: string;
|
|
68
|
+
env: string;
|
|
69
|
+
cron: string;
|
|
70
|
+
tz: string | null;
|
|
71
|
+
next_fire: string;
|
|
72
|
+
}>(
|
|
73
|
+
`SELECT project_id, automation_id, env, cron, tz, next_fire FROM schedules
|
|
74
|
+
WHERE enabled AND next_fire IS NOT NULL AND next_fire <= $1
|
|
75
|
+
FOR UPDATE SKIP LOCKED LIMIT 1`,
|
|
76
|
+
[now.toISOString()],
|
|
77
|
+
);
|
|
78
|
+
const due = rows[0];
|
|
79
|
+
if (!due) return null;
|
|
80
|
+
|
|
81
|
+
const next = nextFire(due.cron, due.tz ?? undefined, now);
|
|
82
|
+
await c.query(
|
|
83
|
+
`UPDATE schedules SET next_fire=$4 WHERE project_id=$1 AND automation_id=$2 AND env=$3`,
|
|
84
|
+
[due.project_id, due.automation_id, due.env, next?.toISOString() ?? null],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const alias = await c.query<{ version_id: string }>(
|
|
88
|
+
`SELECT version_id FROM aliases WHERE project_id=$1 AND automation_id=$2 AND env=$3`,
|
|
89
|
+
[due.project_id, due.automation_id, due.env],
|
|
90
|
+
);
|
|
91
|
+
const versionId = alias.rows[0]?.version_id;
|
|
92
|
+
if (!versionId) return "no-alias";
|
|
93
|
+
|
|
94
|
+
const runId = await createRun(c, {
|
|
95
|
+
projectId: due.project_id,
|
|
96
|
+
automationId: due.automation_id,
|
|
97
|
+
versionId,
|
|
98
|
+
env: due.env,
|
|
99
|
+
trigger: { kind: "schedule", cron: due.cron, scheduledFor: due.next_fire },
|
|
100
|
+
});
|
|
101
|
+
return runId;
|
|
102
|
+
});
|
|
103
|
+
if (fired === null) break;
|
|
104
|
+
if (typeof fired === "string" && fired !== "no-alias") created.push(fired);
|
|
105
|
+
}
|
|
106
|
+
return created;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function startScheduler(db: Db, opts: { intervalMs?: number; onError?: (e: unknown) => void } = {}): () => void {
|
|
110
|
+
const interval = setInterval(() => {
|
|
111
|
+
schedulerTick(db).catch((e) => opts.onError?.(e));
|
|
112
|
+
}, opts.intervalMs ?? 5_000);
|
|
113
|
+
interval.unref?.();
|
|
114
|
+
return () => clearInterval(interval);
|
|
115
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Dead-work reaper: repairs queue/run states that cannot make progress, while leaving
|
|
2
|
+
// intentionally suspended runs alone (Signal waits, wake times, durable waits).
|
|
3
|
+
|
|
4
|
+
import type { Db } from "../db/db.js";
|
|
5
|
+
|
|
6
|
+
export interface DeadWorkRecoveryOpts {
|
|
7
|
+
now?: Date | undefined;
|
|
8
|
+
/** How old a queued/running run must be before missing run jobs are repaired. */
|
|
9
|
+
leaseGraceMs?: number | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DeadWorkRecoveryReport {
|
|
13
|
+
deadRunJobsFailed: number;
|
|
14
|
+
orphanedRunsReenqueued: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_LEASE_GRACE_MS = 2 * 60 * 1000;
|
|
18
|
+
const DEFAULT_REAPER_INTERVAL_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
export async function recoverDeadWork(db: Db, opts: DeadWorkRecoveryOpts = {}): Promise<DeadWorkRecoveryReport> {
|
|
21
|
+
const now = opts.now ?? new Date();
|
|
22
|
+
const staleBefore = new Date(now.getTime() - (opts.leaseGraceMs ?? DEFAULT_LEASE_GRACE_MS));
|
|
23
|
+
|
|
24
|
+
const failed = await db.query(
|
|
25
|
+
`WITH dead_run_jobs AS (
|
|
26
|
+
SELECT q.id, q.kind, q.payload->>'runId' AS run_id, q.last_error
|
|
27
|
+
FROM queue_jobs q
|
|
28
|
+
JOIN runs r ON r.id::text = q.payload->>'runId'
|
|
29
|
+
WHERE q.kind = 'run'
|
|
30
|
+
AND q.status = 'dead'
|
|
31
|
+
AND r.status IN ('queued','running')
|
|
32
|
+
)
|
|
33
|
+
UPDATE runs r
|
|
34
|
+
SET status = 'failed',
|
|
35
|
+
error = jsonb_build_object(
|
|
36
|
+
'name', 'DeadRunJob',
|
|
37
|
+
'code', 'dead_run_job',
|
|
38
|
+
'message', 'run job attempts exhausted',
|
|
39
|
+
'jobId', dead_run_jobs.id::text,
|
|
40
|
+
'jobKind', dead_run_jobs.kind,
|
|
41
|
+
'lastError', dead_run_jobs.last_error
|
|
42
|
+
),
|
|
43
|
+
finished_at = $1
|
|
44
|
+
FROM dead_run_jobs
|
|
45
|
+
WHERE r.id::text = dead_run_jobs.run_id`,
|
|
46
|
+
[now.toISOString()],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const reenqueue = await db.query<{ count: string }>(
|
|
50
|
+
`WITH stale_runs AS (
|
|
51
|
+
SELECT r.id
|
|
52
|
+
FROM runs r
|
|
53
|
+
WHERE r.status IN ('queued','running')
|
|
54
|
+
AND COALESCE(r.started_at, r.created_at) <= $1
|
|
55
|
+
AND NOT EXISTS (
|
|
56
|
+
SELECT 1 FROM queue_jobs q
|
|
57
|
+
WHERE q.kind = 'run'
|
|
58
|
+
AND q.payload->>'runId' = r.id::text
|
|
59
|
+
AND q.status = 'ready'
|
|
60
|
+
)
|
|
61
|
+
AND NOT EXISTS (
|
|
62
|
+
SELECT 1 FROM queue_jobs q
|
|
63
|
+
WHERE q.kind = 'run'
|
|
64
|
+
AND q.payload->>'runId' = r.id::text
|
|
65
|
+
AND q.status = 'dead'
|
|
66
|
+
)
|
|
67
|
+
), inserted AS (
|
|
68
|
+
INSERT INTO queue_jobs (kind, payload, dedupe_key, run_at)
|
|
69
|
+
SELECT 'run', jsonb_build_object('runId', id::text), 'run:' || id::text, $2
|
|
70
|
+
FROM stale_runs
|
|
71
|
+
ON CONFLICT (dedupe_key) WHERE dedupe_key IS NOT NULL DO UPDATE
|
|
72
|
+
SET run_at = LEAST(queue_jobs.run_at, EXCLUDED.run_at),
|
|
73
|
+
status = 'ready',
|
|
74
|
+
payload = EXCLUDED.payload
|
|
75
|
+
RETURNING 1
|
|
76
|
+
)
|
|
77
|
+
SELECT count(*)::text AS count FROM inserted`,
|
|
78
|
+
[staleBefore.toISOString(), now.toISOString()],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
deadRunJobsFailed: failed.rowCount,
|
|
83
|
+
orphanedRunsReenqueued: Number(reenqueue.rows[0]?.count ?? 0),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function startReaper(
|
|
88
|
+
db: Db,
|
|
89
|
+
opts: { intervalMs?: number | undefined; leaseGraceMs?: number | undefined; onError?: (e: unknown) => void } = {},
|
|
90
|
+
): () => void {
|
|
91
|
+
let inFlight = false;
|
|
92
|
+
const tick = () => {
|
|
93
|
+
if (inFlight) return;
|
|
94
|
+
inFlight = true;
|
|
95
|
+
void recoverDeadWork(db, { leaseGraceMs: opts.leaseGraceMs })
|
|
96
|
+
.catch((e) => opts.onError?.(e))
|
|
97
|
+
.finally(() => {
|
|
98
|
+
inFlight = false;
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const interval = setInterval(tick, opts.intervalMs ?? DEFAULT_REAPER_INTERVAL_MS);
|
|
102
|
+
interval.unref?.();
|
|
103
|
+
tick();
|
|
104
|
+
return () => clearInterval(interval);
|
|
105
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Instance composition: one process hosts api + worker + scheduler + reconciler by
|
|
2
|
+
// default (TESSER_ROLES splits them; ~3 services in prod: this, workers, Postgres).
|
|
3
|
+
|
|
4
|
+
import { serve, type ServerType } from "@hono/node-server";
|
|
5
|
+
import type { Hono } from "hono";
|
|
6
|
+
import { loadConfig, type ServerConfig } from "./config.js";
|
|
7
|
+
import { createDb, type Db } from "./db/db.js";
|
|
8
|
+
import { migrate } from "./db/migrate.js";
|
|
9
|
+
import { Broker } from "./broker/broker.js";
|
|
10
|
+
import { Masker } from "./broker/masking.js";
|
|
11
|
+
import { makeEngineBindings } from "./broker/connections.js";
|
|
12
|
+
import { executeRun } from "./engine/executor.js";
|
|
13
|
+
import type { EngineDeps } from "./engine/types.js";
|
|
14
|
+
import { fanoutEvent } from "./events/fanout.js";
|
|
15
|
+
import { createApp } from "./http/app.js";
|
|
16
|
+
import { ensureAdminToken } from "./http/tokens.js";
|
|
17
|
+
import { Worker } from "./queue/worker.js";
|
|
18
|
+
import type { ClaimedJob } from "./queue/queue.js";
|
|
19
|
+
import { startScheduler } from "./scheduler/cron.js";
|
|
20
|
+
import { startReaper } from "./scheduler/reaper.js";
|
|
21
|
+
import { ArtifactLoader } from "./registry/loader.js";
|
|
22
|
+
import { createConnectorIngress } from "./triggers/ingress.js";
|
|
23
|
+
import { runPoll, type PollJobPayload } from "./triggers/poll.js";
|
|
24
|
+
import type { TriggerLayerDeps } from "./triggers/shared.js";
|
|
25
|
+
import { deployStatus, reconcileProject, type ReconcilerDeps } from "./gitsync/reconciler.js";
|
|
26
|
+
|
|
27
|
+
export interface TesserServer {
|
|
28
|
+
config: ServerConfig;
|
|
29
|
+
db: Db;
|
|
30
|
+
broker: Broker;
|
|
31
|
+
masker: Masker;
|
|
32
|
+
loader: ArtifactLoader;
|
|
33
|
+
engineDeps: EngineDeps;
|
|
34
|
+
triggerDeps: TriggerLayerDeps;
|
|
35
|
+
reconcilerDeps: ReconcilerDeps;
|
|
36
|
+
worker: Worker;
|
|
37
|
+
app: Hono;
|
|
38
|
+
/** Set when roles.api and started via listen(). */
|
|
39
|
+
httpServer?: ServerType;
|
|
40
|
+
adminToken?: string;
|
|
41
|
+
start(): Promise<void>;
|
|
42
|
+
stop(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function createTesserServer(
|
|
46
|
+
overrides: Partial<ServerConfig> = {},
|
|
47
|
+
): Promise<TesserServer> {
|
|
48
|
+
const config = { ...loadConfig(), ...overrides };
|
|
49
|
+
const db = await createDb({
|
|
50
|
+
databaseUrl: config.databaseUrl,
|
|
51
|
+
dataDir: config.databaseUrl ? undefined : `${config.dataDir}/pglite`,
|
|
52
|
+
});
|
|
53
|
+
await migrate(db);
|
|
54
|
+
|
|
55
|
+
const masker = new Masker();
|
|
56
|
+
const broker = new Broker(db, config.masterKey, masker);
|
|
57
|
+
const workspaceId = await broker.ensureDefaultWorkspace();
|
|
58
|
+
const adminToken = await ensureAdminToken(db, workspaceId);
|
|
59
|
+
|
|
60
|
+
const loader = new ArtifactLoader(db);
|
|
61
|
+
const bindings = makeEngineBindings({ db, broker });
|
|
62
|
+
const engineDeps: EngineDeps = {
|
|
63
|
+
db,
|
|
64
|
+
loadAutomation: loader.load,
|
|
65
|
+
buildConnections: bindings.buildConnections,
|
|
66
|
+
resolveSecrets: bindings.resolveSecrets,
|
|
67
|
+
...(bindings.callModel !== undefined ? { callModel: bindings.callModel } : {}),
|
|
68
|
+
...(bindings.callHarness !== undefined ? { callHarness: bindings.callHarness } : {}),
|
|
69
|
+
mask: (s) => masker.mask(s),
|
|
70
|
+
};
|
|
71
|
+
const triggerDeps: TriggerLayerDeps = {
|
|
72
|
+
db,
|
|
73
|
+
broker,
|
|
74
|
+
baseUrl: config.baseUrl,
|
|
75
|
+
loadAutomation: loader.load,
|
|
76
|
+
};
|
|
77
|
+
const reconcilerDeps: ReconcilerDeps = {
|
|
78
|
+
db,
|
|
79
|
+
broker,
|
|
80
|
+
loader,
|
|
81
|
+
dataDir: config.dataDir,
|
|
82
|
+
baseUrl: config.baseUrl,
|
|
83
|
+
triggerDeps,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const workerHandlers = {
|
|
87
|
+
run: async (job: ClaimedJob) => {
|
|
88
|
+
await executeRun(engineDeps, (job.payload as { runId: string }).runId);
|
|
89
|
+
},
|
|
90
|
+
"event-fanout": async (job: ClaimedJob) => {
|
|
91
|
+
await fanoutEvent(db, (job.payload as { eventId: string }).eventId);
|
|
92
|
+
},
|
|
93
|
+
"trigger-poll": async (job: ClaimedJob) => {
|
|
94
|
+
await runPoll(triggerDeps, job.payload as never as PollJobPayload);
|
|
95
|
+
},
|
|
96
|
+
reconcile: async (job: ClaimedJob) => {
|
|
97
|
+
const p = job.payload as { projectId: string; ref?: string; localPath?: string };
|
|
98
|
+
await reconcileProject(reconcilerDeps, p.projectId, { ref: p.ref, localPath: p.localPath });
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const workerKinds = [
|
|
102
|
+
...(config.roles.worker ? (["run", "event-fanout", "trigger-poll"] as const) : []),
|
|
103
|
+
...(config.roles.reconciler ? (["reconcile"] as const) : []),
|
|
104
|
+
];
|
|
105
|
+
const worker = new Worker(
|
|
106
|
+
db,
|
|
107
|
+
Object.fromEntries(workerKinds.map((kind) => [kind, workerHandlers[kind]])),
|
|
108
|
+
{
|
|
109
|
+
tags: config.workerTags,
|
|
110
|
+
kinds: workerKinds,
|
|
111
|
+
pollIntervalMs: config.pollIntervalMs,
|
|
112
|
+
onError: (e) => console.error("[worker]", e),
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const app = createApp({
|
|
117
|
+
db,
|
|
118
|
+
broker,
|
|
119
|
+
baseUrl: config.baseUrl,
|
|
120
|
+
dataDir: config.dataDir,
|
|
121
|
+
roles: config.roles,
|
|
122
|
+
connectorIngress: createConnectorIngress(triggerDeps),
|
|
123
|
+
deployStatus: (projectId, env) => deployStatus(db, projectId, env),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let stopScheduler: (() => void) | undefined;
|
|
127
|
+
let stopReaper: (() => void) | undefined;
|
|
128
|
+
let httpServer: ServerType | undefined;
|
|
129
|
+
|
|
130
|
+
const server: TesserServer = {
|
|
131
|
+
config,
|
|
132
|
+
db,
|
|
133
|
+
broker,
|
|
134
|
+
masker,
|
|
135
|
+
loader,
|
|
136
|
+
engineDeps,
|
|
137
|
+
triggerDeps,
|
|
138
|
+
reconcilerDeps,
|
|
139
|
+
worker,
|
|
140
|
+
app,
|
|
141
|
+
...(adminToken !== null ? { adminToken } : {}),
|
|
142
|
+
async start() {
|
|
143
|
+
if (config.roles.api) {
|
|
144
|
+
httpServer = serve({ fetch: app.fetch, port: config.port, hostname: "0.0.0.0" });
|
|
145
|
+
server.httpServer = httpServer;
|
|
146
|
+
}
|
|
147
|
+
if (config.roles.worker || config.roles.reconciler) worker.start();
|
|
148
|
+
if (config.roles.scheduler) {
|
|
149
|
+
stopScheduler = startScheduler(db, { onError: (e) => console.error("[scheduler]", e) });
|
|
150
|
+
stopReaper = startReaper(db, { onError: (e) => console.error("[reaper]", e) });
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
async stop() {
|
|
154
|
+
stopScheduler?.();
|
|
155
|
+
stopReaper?.();
|
|
156
|
+
await worker.stop();
|
|
157
|
+
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
158
|
+
await db.close();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return server;
|
|
162
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Connector-webhook ingress demux (ADR-0013): verify by the connector's declared scheme,
|
|
2
|
+
// answer endpoint challenges, identify the event, dedup by delivery id, fan out across
|
|
3
|
+
// every registration sharing the connector+connection (app-level URLs like Slack's serve
|
|
4
|
+
// several triggers), map → typed payload → one durable run each.
|
|
5
|
+
|
|
6
|
+
import type { InboundWebhookEvent } from "@devosurf/tesser-sdk/connector";
|
|
7
|
+
import { validateSchema, verifyInboundEvent } from "@devosurf/tesser-sdk/internal";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { createRun } from "../engine/runs.js";
|
|
10
|
+
import {
|
|
11
|
+
isWebhookDecl,
|
|
12
|
+
loadTriggerState,
|
|
13
|
+
pruneSeen,
|
|
14
|
+
resolveLiveTrigger,
|
|
15
|
+
saveTriggerState,
|
|
16
|
+
type TriggerLayerDeps,
|
|
17
|
+
} from "./shared.js";
|
|
18
|
+
|
|
19
|
+
interface IngressResult {
|
|
20
|
+
status: number;
|
|
21
|
+
body: string;
|
|
22
|
+
contentType?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const json = (status: number, body: unknown): IngressResult => ({ status, body: JSON.stringify(body) });
|
|
26
|
+
|
|
27
|
+
export function createConnectorIngress(deps: TriggerLayerDeps) {
|
|
28
|
+
return async function connectorIngress(
|
|
29
|
+
ingressToken: string,
|
|
30
|
+
req: { headers: Record<string, string>; query: Record<string, string>; rawBody: Uint8Array },
|
|
31
|
+
): Promise<IngressResult> {
|
|
32
|
+
const { rows } = await deps.db.query<{
|
|
33
|
+
id: string;
|
|
34
|
+
project_id: string;
|
|
35
|
+
automation_id: string;
|
|
36
|
+
trigger_id: string;
|
|
37
|
+
connector_id: string;
|
|
38
|
+
connection_id: string;
|
|
39
|
+
env: string;
|
|
40
|
+
status: string;
|
|
41
|
+
signing_secret_cipher: string | null;
|
|
42
|
+
}>(`SELECT * FROM webhook_registrations WHERE ingress_token=$1`, [ingressToken]);
|
|
43
|
+
const reg = rows[0];
|
|
44
|
+
if (!reg || !["registered", "manual-pending"].includes(reg.status)) {
|
|
45
|
+
return json(404, { error: "unknown ingress" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const resolved = await resolveLiveTrigger(deps, reg.project_id, reg.automation_id, reg.env).catch(() => null);
|
|
49
|
+
if (!resolved || !isWebhookDecl(resolved.decl)) return json(404, { error: "trigger not live" });
|
|
50
|
+
const webhook = resolved.connector.__connector.webhook;
|
|
51
|
+
if (!webhook) return json(500, { error: "connector has no webhook config" });
|
|
52
|
+
|
|
53
|
+
let body: unknown;
|
|
54
|
+
try {
|
|
55
|
+
body = JSON.parse(new TextDecoder().decode(req.rawBody));
|
|
56
|
+
} catch {
|
|
57
|
+
body = undefined;
|
|
58
|
+
}
|
|
59
|
+
const event: InboundWebhookEvent = { headers: req.headers, query: req.query, rawBody: req.rawBody, json: body };
|
|
60
|
+
|
|
61
|
+
// Endpoint verification handshakes (Slack url_verification) answer BEFORE signature
|
|
62
|
+
// checks — Slack signs them, but a manual-pending registration has no secret yet.
|
|
63
|
+
const challenge = webhook.challenge?.(event);
|
|
64
|
+
if (challenge !== null && challenge !== undefined) {
|
|
65
|
+
return { status: 200, body: challenge, contentType: "text/plain" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (reg.status !== "registered" || reg.signing_secret_cipher === null) {
|
|
69
|
+
return json(409, { error: "registration incomplete — signing secret not configured" });
|
|
70
|
+
}
|
|
71
|
+
const { rows: wsRows } = await deps.db.query<{ workspace_id: string }>(
|
|
72
|
+
`SELECT workspace_id FROM projects WHERE id=$1`,
|
|
73
|
+
[reg.project_id],
|
|
74
|
+
);
|
|
75
|
+
const secret = await deps.broker.decryptValue(wsRows[0]!.workspace_id, reg.signing_secret_cipher, "webhook.signing");
|
|
76
|
+
if (!(await verifyInboundEvent(webhook.verify, event, secret))) {
|
|
77
|
+
return json(401, { error: "signature verification failed" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const identified = webhook.identify(event);
|
|
81
|
+
if (!identified) return json(400, { error: "unrecognized event" });
|
|
82
|
+
const deliveryId =
|
|
83
|
+
identified.deliveryId ?? createHash("sha256").update(req.rawBody).digest("hex").slice(0, 24);
|
|
84
|
+
|
|
85
|
+
// Fan out across all live registrations sharing connector+connection (one app-level
|
|
86
|
+
// URL can serve several triggers) — the receiving registration's siblings included.
|
|
87
|
+
const { rows: siblings } = await deps.db.query<{
|
|
88
|
+
id: string;
|
|
89
|
+
automation_id: string;
|
|
90
|
+
trigger_id: string;
|
|
91
|
+
connection_id: string;
|
|
92
|
+
status: string;
|
|
93
|
+
}>(
|
|
94
|
+
`SELECT id, automation_id, trigger_id, connection_id, status FROM webhook_registrations
|
|
95
|
+
WHERE project_id=$1 AND env=$2 AND connector_id=$3 AND connection_id=$4 AND status='registered'`,
|
|
96
|
+
[reg.project_id, reg.env, reg.connector_id, reg.connection_id],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
let fired = 0;
|
|
100
|
+
for (const sib of siblings) {
|
|
101
|
+
const sibResolved =
|
|
102
|
+
sib.id === reg.id
|
|
103
|
+
? resolved
|
|
104
|
+
: await resolveLiveTrigger(deps, reg.project_id, sib.automation_id, reg.env).catch(() => null);
|
|
105
|
+
if (!sibResolved || !isWebhookDecl(sibResolved.decl)) continue;
|
|
106
|
+
if (sibResolved.decl.event !== identified.event) continue;
|
|
107
|
+
|
|
108
|
+
const stateKey = {
|
|
109
|
+
projectId: reg.project_id,
|
|
110
|
+
automationId: sib.automation_id,
|
|
111
|
+
triggerId: sib.trigger_id,
|
|
112
|
+
connectionId: sib.connection_id,
|
|
113
|
+
};
|
|
114
|
+
const state = await loadTriggerState(deps.db, stateKey, "webhook");
|
|
115
|
+
const seen = new Map(pruneSeen(state.seen, Date.now()));
|
|
116
|
+
if (seen.has(deliveryId)) continue; // provider redelivery → drop
|
|
117
|
+
|
|
118
|
+
const mapped = await sibResolved.decl.map(identified.payload ?? body, sibResolved.params);
|
|
119
|
+
if (mapped === null) {
|
|
120
|
+
seen.set(deliveryId, Date.now());
|
|
121
|
+
await saveTriggerState(deps.db, stateKey, { seen: [...seen.entries()], seeded: true });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const validated = await validateSchema(
|
|
125
|
+
sibResolved.decl.output,
|
|
126
|
+
mapped,
|
|
127
|
+
`${reg.connector_id}.triggers.${sib.trigger_id} payload`,
|
|
128
|
+
);
|
|
129
|
+
await createRun(deps.db, {
|
|
130
|
+
projectId: reg.project_id,
|
|
131
|
+
automationId: sib.automation_id,
|
|
132
|
+
versionId: sibResolved.versionId,
|
|
133
|
+
env: reg.env,
|
|
134
|
+
trigger: {
|
|
135
|
+
kind: "connector",
|
|
136
|
+
connector: reg.connector_id,
|
|
137
|
+
trigger: sib.trigger_id,
|
|
138
|
+
strategy: "webhook",
|
|
139
|
+
deliveryId,
|
|
140
|
+
event: identified.event,
|
|
141
|
+
connectionId: sib.connection_id,
|
|
142
|
+
...(sibResolved.connection.end_user_id !== null
|
|
143
|
+
? { endUserId: sibResolved.connection.end_user_id }
|
|
144
|
+
: {}),
|
|
145
|
+
},
|
|
146
|
+
input: validated,
|
|
147
|
+
});
|
|
148
|
+
seen.set(deliveryId, Date.now());
|
|
149
|
+
await saveTriggerState(deps.db, stateKey, { seen: [...seen.entries()], seeded: true, touch: "delivery" });
|
|
150
|
+
fired++;
|
|
151
|
+
}
|
|
152
|
+
return json(202, { ok: true, fired });
|
|
153
|
+
};
|
|
154
|
+
}
|