@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,167 @@
|
|
|
1
|
+
// Poll-strategy connector triggers (ADR-0013): the runtime owns the loop — windowed
|
|
2
|
+
// seen-set dedup by the author's dedupeKey, seed-without-firing on first activation,
|
|
3
|
+
// one independent durable run per new item (oldest-first), opt-in cursor mode,
|
|
4
|
+
// self-rescheduling with the author's `every` clamped to the connector floor + stagger.
|
|
5
|
+
|
|
6
|
+
import { encodeJournal, parseDuration, validateSchema, type JsonValue } from "@devosurf/tesser-sdk/internal";
|
|
7
|
+
import type { Db, DbClient } from "../db/db.js";
|
|
8
|
+
import { createRun } from "../engine/runs.js";
|
|
9
|
+
import { enqueue } from "../queue/queue.js";
|
|
10
|
+
import { authDeclFor, makeActionCtx, providerFactsOf } from "../broker/connections.js";
|
|
11
|
+
import {
|
|
12
|
+
isPollDecl,
|
|
13
|
+
loadTriggerState,
|
|
14
|
+
pruneSeen,
|
|
15
|
+
resolveLiveTrigger,
|
|
16
|
+
saveTriggerState,
|
|
17
|
+
type TriggerLayerDeps,
|
|
18
|
+
} from "./shared.js";
|
|
19
|
+
|
|
20
|
+
export interface PollJobPayload {
|
|
21
|
+
projectId: string;
|
|
22
|
+
automationId: string;
|
|
23
|
+
env: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pollDedupeKey(p: PollJobPayload): string {
|
|
27
|
+
return `trigger-poll:${p.projectId}:${p.automationId}:${p.env}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function pollIntervalMs(decl: { interval?: { default: string; floor?: string } }, every?: string): number {
|
|
31
|
+
const defaultMs = parseDuration(decl.interval?.default ?? "2m", "trigger interval");
|
|
32
|
+
const floorMs = parseDuration(decl.interval?.floor ?? "30s", "trigger interval floor");
|
|
33
|
+
if (every === undefined) return Math.max(defaultMs, floorMs);
|
|
34
|
+
return Math.max(parseDuration(every, "trigger `every`"), floorMs);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function schedulePoll(
|
|
38
|
+
client: DbClient,
|
|
39
|
+
payload: PollJobPayload,
|
|
40
|
+
delayMs: number,
|
|
41
|
+
stagger = true,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
// Stagger ±10% so many triggers on one cadence don't thundering-herd a provider.
|
|
44
|
+
const jitter = stagger ? delayMs * (Math.random() * 0.2 - 0.1) : 0;
|
|
45
|
+
await enqueue(client, {
|
|
46
|
+
kind: "trigger-poll",
|
|
47
|
+
payload: payload as never,
|
|
48
|
+
runAtMs: Date.now() + Math.max(1000, delayMs + jitter),
|
|
49
|
+
dedupeKey: pollDedupeKey(payload),
|
|
50
|
+
maxAttempts: 5,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Execute one poll for one (automation, trigger, connection); reschedules itself. */
|
|
55
|
+
export async function runPoll(deps: TriggerLayerDeps, payload: PollJobPayload): Promise<{ fired: number } | null> {
|
|
56
|
+
const resolved = await resolveLiveTrigger(deps, payload.projectId, payload.automationId, payload.env);
|
|
57
|
+
if (!resolved) return null; // undeployed / connection missing → stop rescheduling
|
|
58
|
+
const { decl, manifest } = resolved;
|
|
59
|
+
if (!isPollDecl(decl)) return null;
|
|
60
|
+
|
|
61
|
+
const reschedule = async (db: Db) => {
|
|
62
|
+
await db.tx((c) => schedulePoll(c, payload, pollIntervalMs(decl, manifest.every)));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const stateKey = {
|
|
66
|
+
projectId: payload.projectId,
|
|
67
|
+
automationId: payload.automationId,
|
|
68
|
+
triggerId: manifest.trigger,
|
|
69
|
+
connectionId: resolved.connection.id,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const bundle = await deps.broker.freshCredential(
|
|
74
|
+
resolved.connection.id,
|
|
75
|
+
providerFactsOf(resolved.connector.__connector)?.oauth2,
|
|
76
|
+
);
|
|
77
|
+
const actx = makeActionCtx({
|
|
78
|
+
spec: resolved.connector.__connector,
|
|
79
|
+
decl: authDeclFor(resolved.connector.__connector, resolved.connection.auth_mode),
|
|
80
|
+
conn: resolved.connection,
|
|
81
|
+
facts: providerFactsOf(resolved.connector.__connector),
|
|
82
|
+
fields: bundle.fields,
|
|
83
|
+
broker: deps.broker,
|
|
84
|
+
fetchImpl: deps.fetchImpl,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const state = await loadTriggerState(deps.db, stateKey, "poll");
|
|
88
|
+
const polled = await decl.poll(actx, resolved.params, state.cursor ?? undefined);
|
|
89
|
+
const items: unknown[] = Array.isArray(polled) ? polled : polled.items;
|
|
90
|
+
const nextCursor = Array.isArray(polled) ? undefined : polled.nextCursor;
|
|
91
|
+
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const seen = new Map(pruneSeen(state.seen, now));
|
|
94
|
+
let fired = 0;
|
|
95
|
+
|
|
96
|
+
if (!state.seeded) {
|
|
97
|
+
// First activation: seed without firing — turning a trigger on never floods (ADR-0013).
|
|
98
|
+
for (const item of items) seen.set(decl.dedupeKey(item), now);
|
|
99
|
+
} else {
|
|
100
|
+
const fresh = items.filter((item) => !seen.has(decl.dedupeKey(item)));
|
|
101
|
+
// Fire oldest-first regardless of the provider's natural order.
|
|
102
|
+
const ordered = (decl.order ?? "newest-first") === "newest-first" ? [...fresh].reverse() : fresh;
|
|
103
|
+
for (const item of ordered) {
|
|
104
|
+
const key = decl.dedupeKey(item);
|
|
105
|
+
const mapped = decl.map ? await decl.map(item, resolved.params) : item;
|
|
106
|
+
const validated = await validateSchema(
|
|
107
|
+
decl.output,
|
|
108
|
+
mapped,
|
|
109
|
+
`${manifest.connector}.triggers.${manifest.trigger} payload`,
|
|
110
|
+
);
|
|
111
|
+
await createRun(deps.db, {
|
|
112
|
+
projectId: payload.projectId,
|
|
113
|
+
automationId: payload.automationId,
|
|
114
|
+
versionId: resolved.versionId,
|
|
115
|
+
env: payload.env,
|
|
116
|
+
trigger: {
|
|
117
|
+
kind: "connector",
|
|
118
|
+
connector: manifest.connector,
|
|
119
|
+
trigger: manifest.trigger,
|
|
120
|
+
strategy: "poll",
|
|
121
|
+
itemKey: key,
|
|
122
|
+
connectionId: resolved.connection.id,
|
|
123
|
+
...(resolved.connection.end_user_id !== null ? { endUserId: resolved.connection.end_user_id } : {}),
|
|
124
|
+
},
|
|
125
|
+
input: validated,
|
|
126
|
+
});
|
|
127
|
+
seen.set(key, now);
|
|
128
|
+
fired++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await saveTriggerState(deps.db, stateKey, {
|
|
133
|
+
seen: [...seen.entries()],
|
|
134
|
+
...(nextCursor !== undefined ? { cursor: encodeJournal(nextCursor) as JsonValue } : {}),
|
|
135
|
+
seeded: true,
|
|
136
|
+
touch: "poll",
|
|
137
|
+
});
|
|
138
|
+
return { fired };
|
|
139
|
+
} finally {
|
|
140
|
+
await reschedule(deps.db);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Activate polling for every live connector-poll trigger of a project+env (on promote). */
|
|
145
|
+
export async function activatePolls(
|
|
146
|
+
deps: TriggerLayerDeps,
|
|
147
|
+
projectId: string,
|
|
148
|
+
env: string,
|
|
149
|
+
): Promise<number> {
|
|
150
|
+
if (env !== "production") return 0; // preview is trigger-inert (ADR-0013)
|
|
151
|
+
const { rows } = await deps.db.query<{ automation_id: string; manifest: { trigger?: { kind?: string } } }>(
|
|
152
|
+
`SELECT a.automation_id, v.manifest FROM aliases a JOIN automation_versions v ON v.id=a.version_id
|
|
153
|
+
WHERE a.project_id=$1 AND a.env=$2`,
|
|
154
|
+
[projectId, env],
|
|
155
|
+
);
|
|
156
|
+
let activated = 0;
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
if (row.manifest.trigger?.kind !== "connector") continue;
|
|
159
|
+
const resolved = await resolveLiveTrigger(deps, projectId, row.automation_id, env).catch(() => null);
|
|
160
|
+
if (!resolved || !isPollDecl(resolved.decl)) continue;
|
|
161
|
+
await deps.db.tx((c) =>
|
|
162
|
+
schedulePoll(c, { projectId, automationId: row.automation_id, env }, 1500, false),
|
|
163
|
+
);
|
|
164
|
+
activated++;
|
|
165
|
+
}
|
|
166
|
+
return activated;
|
|
167
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Webhook-strategy registration lifecycle (ADR-0013): auto mode rides the brokered token
|
|
2
|
+
// to create/destroy provider hooks pointing at our stable ingress URL; manual mode
|
|
3
|
+
// surfaces a connect-page step. Config-hash decides re-registration; undeploy
|
|
4
|
+
// unregisters (no orphaned hooks).
|
|
5
|
+
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { decodeJournal, stableStringify } from "@devosurf/tesser-sdk/internal";
|
|
8
|
+
import type { Db } from "../db/db.js";
|
|
9
|
+
import { authDeclFor, makeActionCtx, providerFactsOf } from "../broker/connections.js";
|
|
10
|
+
import type { ManualWebhookRequirement } from "../broker/connect.js";
|
|
11
|
+
import {
|
|
12
|
+
isWebhookDecl,
|
|
13
|
+
resolveLiveTrigger,
|
|
14
|
+
type TriggerLayerDeps,
|
|
15
|
+
} from "./shared.js";
|
|
16
|
+
|
|
17
|
+
function configHash(manifest: { connector: string; trigger: string; params: unknown }, connectionId: string): string {
|
|
18
|
+
return createHash("sha256")
|
|
19
|
+
.update(stableStringify({
|
|
20
|
+
connector: manifest.connector,
|
|
21
|
+
trigger: manifest.trigger,
|
|
22
|
+
params: manifest.params,
|
|
23
|
+
connection: connectionId,
|
|
24
|
+
} as never))
|
|
25
|
+
.digest("hex")
|
|
26
|
+
.slice(0, 24);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RegistrationOutcome {
|
|
30
|
+
automationId: string;
|
|
31
|
+
trigger: string;
|
|
32
|
+
status: "registered" | "manual-pending" | "failed" | "unchanged";
|
|
33
|
+
manualRequirement?: ManualWebhookRequirement;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bring one automation's webhook trigger to its registered state. */
|
|
38
|
+
export async function ensureRegistration(
|
|
39
|
+
deps: TriggerLayerDeps,
|
|
40
|
+
projectId: string,
|
|
41
|
+
automationId: string,
|
|
42
|
+
env: string,
|
|
43
|
+
): Promise<RegistrationOutcome | null> {
|
|
44
|
+
if (env !== "production") return null; // preview is trigger-inert (ADR-0013)
|
|
45
|
+
const resolved = await resolveLiveTrigger(deps, projectId, automationId, env);
|
|
46
|
+
if (!resolved || !isWebhookDecl(resolved.decl)) return null;
|
|
47
|
+
const { decl, manifest, connection } = resolved;
|
|
48
|
+
const hash = configHash(manifest, connection.id);
|
|
49
|
+
|
|
50
|
+
const { rows } = await deps.db.query<{
|
|
51
|
+
id: string;
|
|
52
|
+
config_hash: string;
|
|
53
|
+
status: string;
|
|
54
|
+
ingress_token: string;
|
|
55
|
+
external_id: string | null;
|
|
56
|
+
state: unknown;
|
|
57
|
+
signing_secret_cipher: string | null;
|
|
58
|
+
}>(
|
|
59
|
+
`SELECT id, config_hash, status, ingress_token, external_id, state, signing_secret_cipher
|
|
60
|
+
FROM webhook_registrations
|
|
61
|
+
WHERE project_id=$1 AND automation_id=$2 AND trigger_id=$3 AND connection_id=$4 AND env=$5`,
|
|
62
|
+
[projectId, automationId, manifest.trigger, connection.id, env],
|
|
63
|
+
);
|
|
64
|
+
const existing = rows[0];
|
|
65
|
+
|
|
66
|
+
if (existing && existing.config_hash === hash && ["registered", "manual-pending"].includes(existing.status)) {
|
|
67
|
+
if (existing.status === "registered") {
|
|
68
|
+
return { automationId, trigger: manifest.trigger, status: "unchanged" };
|
|
69
|
+
}
|
|
70
|
+
// still waiting on the human — re-surface the same requirement
|
|
71
|
+
return {
|
|
72
|
+
automationId,
|
|
73
|
+
trigger: manifest.trigger,
|
|
74
|
+
status: "manual-pending",
|
|
75
|
+
manualRequirement: manualRequirementFor(deps, existing.id, existing.ingress_token, resolved, decl),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Config changed → tear down the old provider hook first (auto mode only).
|
|
80
|
+
if (existing && existing.config_hash !== hash) {
|
|
81
|
+
await destroyRegistration(deps, projectId, automationId, env, existing.id).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ingressToken = existing?.config_hash === hash ? existing.ingress_token : `wt_${randomBytes(16).toString("hex")}`;
|
|
85
|
+
const url = `${deps.baseUrl}/ingress/c/${ingressToken}`;
|
|
86
|
+
|
|
87
|
+
if (decl.register.mode === "manual") {
|
|
88
|
+
const { rows: created } = await deps.db.query<{ id: string }>(
|
|
89
|
+
`INSERT INTO webhook_registrations
|
|
90
|
+
(project_id, automation_id, trigger_id, connector_id, connection_id, env, config_hash, ingress_token, status)
|
|
91
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'manual-pending')
|
|
92
|
+
ON CONFLICT (project_id, automation_id, trigger_id, connection_id, env)
|
|
93
|
+
DO UPDATE SET config_hash=EXCLUDED.config_hash, status='manual-pending', updated_at=now()
|
|
94
|
+
RETURNING id`,
|
|
95
|
+
[projectId, automationId, manifest.trigger, manifest.connector, connection.id, env, hash, ingressToken],
|
|
96
|
+
);
|
|
97
|
+
return {
|
|
98
|
+
automationId,
|
|
99
|
+
trigger: manifest.trigger,
|
|
100
|
+
status: "manual-pending",
|
|
101
|
+
manualRequirement: manualRequirementFor(deps, created[0]!.id, ingressToken, resolved, decl),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// auto mode: we generate the signing secret and call the provider's hook API.
|
|
106
|
+
const secret = randomBytes(24).toString("hex");
|
|
107
|
+
try {
|
|
108
|
+
const bundle = await deps.broker.freshCredential(
|
|
109
|
+
connection.id,
|
|
110
|
+
providerFactsOf(resolved.connector.__connector)?.oauth2,
|
|
111
|
+
);
|
|
112
|
+
const actx = makeActionCtx({
|
|
113
|
+
spec: resolved.connector.__connector,
|
|
114
|
+
decl: authDeclFor(resolved.connector.__connector, connection.auth_mode),
|
|
115
|
+
conn: connection,
|
|
116
|
+
facts: providerFactsOf(resolved.connector.__connector),
|
|
117
|
+
fields: bundle.fields,
|
|
118
|
+
broker: deps.broker,
|
|
119
|
+
fetchImpl: deps.fetchImpl,
|
|
120
|
+
});
|
|
121
|
+
const result = await decl.register.create(actx, { url, secret }, resolved.params);
|
|
122
|
+
const cipher = await deps.broker.encryptValue(resolved.workspaceId, secret, "webhook.signing");
|
|
123
|
+
await deps.db.query(
|
|
124
|
+
`INSERT INTO webhook_registrations
|
|
125
|
+
(project_id, automation_id, trigger_id, connector_id, connection_id, env, config_hash, ingress_token,
|
|
126
|
+
signing_secret_cipher, external_id, state, status)
|
|
127
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,'registered')
|
|
128
|
+
ON CONFLICT (project_id, automation_id, trigger_id, connection_id, env)
|
|
129
|
+
DO UPDATE SET config_hash=EXCLUDED.config_hash, ingress_token=EXCLUDED.ingress_token,
|
|
130
|
+
signing_secret_cipher=EXCLUDED.signing_secret_cipher, external_id=EXCLUDED.external_id,
|
|
131
|
+
state=EXCLUDED.state, status='registered', error=NULL, updated_at=now()`,
|
|
132
|
+
[
|
|
133
|
+
projectId,
|
|
134
|
+
automationId,
|
|
135
|
+
manifest.trigger,
|
|
136
|
+
manifest.connector,
|
|
137
|
+
connection.id,
|
|
138
|
+
env,
|
|
139
|
+
hash,
|
|
140
|
+
ingressToken,
|
|
141
|
+
cipher,
|
|
142
|
+
result && typeof result === "object" && "externalId" in result ? ((result as { externalId?: string }).externalId ?? null) : null,
|
|
143
|
+
result && typeof result === "object" && "state" in result ? JSON.stringify((result as { state?: unknown }).state ?? null) : null,
|
|
144
|
+
],
|
|
145
|
+
);
|
|
146
|
+
return { automationId, trigger: manifest.trigger, status: "registered" };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
await deps.db.query(
|
|
149
|
+
`INSERT INTO webhook_registrations
|
|
150
|
+
(project_id, automation_id, trigger_id, connector_id, connection_id, env, config_hash, ingress_token, status, error)
|
|
151
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'failed',$9)
|
|
152
|
+
ON CONFLICT (project_id, automation_id, trigger_id, connection_id, env)
|
|
153
|
+
DO UPDATE SET status='failed', error=EXCLUDED.error, updated_at=now()`,
|
|
154
|
+
[projectId, automationId, manifest.trigger, manifest.connector, connection.id, env, hash, ingressToken, String(err).slice(0, 1000)],
|
|
155
|
+
);
|
|
156
|
+
return { automationId, trigger: manifest.trigger, status: "failed", error: String(err) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function manualRequirementFor(
|
|
161
|
+
deps: TriggerLayerDeps,
|
|
162
|
+
registrationId: string,
|
|
163
|
+
ingressToken: string,
|
|
164
|
+
resolved: NonNullable<Awaited<ReturnType<typeof resolveLiveTrigger>>>,
|
|
165
|
+
decl: import("@devosurf/tesser-sdk/connector").WebhookTriggerDecl<any, any>,
|
|
166
|
+
): ManualWebhookRequirement {
|
|
167
|
+
const url = `${deps.baseUrl}/ingress/c/${ingressToken}`;
|
|
168
|
+
const instructions =
|
|
169
|
+
decl.register.mode === "manual"
|
|
170
|
+
? decl.register.instructions({ url, secret: "(paste your provider's signing secret below)" }, resolved.params)
|
|
171
|
+
: "";
|
|
172
|
+
return {
|
|
173
|
+
type: "webhook-manual",
|
|
174
|
+
registrationId,
|
|
175
|
+
connector: resolved.manifest.connector,
|
|
176
|
+
trigger: resolved.manifest.trigger,
|
|
177
|
+
automation: resolved.automationId,
|
|
178
|
+
instructions,
|
|
179
|
+
url,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Undeploy / config-change teardown: destroy the provider hook, mark removed, GC state. */
|
|
184
|
+
export async function destroyRegistration(
|
|
185
|
+
deps: TriggerLayerDeps,
|
|
186
|
+
projectId: string,
|
|
187
|
+
automationId: string,
|
|
188
|
+
env: string,
|
|
189
|
+
registrationId: string,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
const { rows } = await deps.db.query<{
|
|
192
|
+
id: string;
|
|
193
|
+
trigger_id: string;
|
|
194
|
+
connector_id: string;
|
|
195
|
+
connection_id: string | null;
|
|
196
|
+
external_id: string | null;
|
|
197
|
+
state: unknown;
|
|
198
|
+
status: string;
|
|
199
|
+
}>(`SELECT id, trigger_id, connector_id, connection_id, external_id, state, status FROM webhook_registrations WHERE id=$1`, [
|
|
200
|
+
registrationId,
|
|
201
|
+
]);
|
|
202
|
+
const reg = rows[0];
|
|
203
|
+
if (!reg) return;
|
|
204
|
+
|
|
205
|
+
if (reg.status === "registered" && reg.connection_id) {
|
|
206
|
+
const resolved = await resolveLiveTrigger(deps, projectId, automationId, env).catch(() => null);
|
|
207
|
+
if (resolved && isWebhookDecl(resolved.decl) && resolved.decl.register.mode === "auto" && resolved.decl.register.destroy) {
|
|
208
|
+
try {
|
|
209
|
+
const bundle = await deps.broker.freshCredential(
|
|
210
|
+
resolved.connection.id,
|
|
211
|
+
providerFactsOf(resolved.connector.__connector)?.oauth2,
|
|
212
|
+
);
|
|
213
|
+
const actx = makeActionCtx({
|
|
214
|
+
spec: resolved.connector.__connector,
|
|
215
|
+
decl: authDeclFor(resolved.connector.__connector, resolved.connection.auth_mode),
|
|
216
|
+
conn: resolved.connection,
|
|
217
|
+
facts: providerFactsOf(resolved.connector.__connector),
|
|
218
|
+
fields: bundle.fields,
|
|
219
|
+
broker: deps.broker,
|
|
220
|
+
fetchImpl: deps.fetchImpl,
|
|
221
|
+
});
|
|
222
|
+
await resolved.decl.register.destroy(
|
|
223
|
+
actx,
|
|
224
|
+
{ ...(reg.external_id !== null ? { externalId: reg.external_id } : {}), state: decodeJournal(reg.state as never) },
|
|
225
|
+
resolved.params,
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
// best-effort: the provider hook may already be gone
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await deps.db.query(`UPDATE webhook_registrations SET status='removed', updated_at=now() WHERE id=$1`, [reg.id]);
|
|
233
|
+
if (reg.connection_id) {
|
|
234
|
+
await deps.db.query(
|
|
235
|
+
`DELETE FROM trigger_state WHERE project_id=$1 AND automation_id=$2 AND trigger_id=$3 AND connection_id=$4`,
|
|
236
|
+
[projectId, automationId, reg.trigger_id, reg.connection_id],
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Reconcile all webhook registrations for a project+env after promote. Returns the
|
|
242
|
+
* manual steps that still need a human (they join the connect link). */
|
|
243
|
+
export async function ensureRegistrations(
|
|
244
|
+
deps: TriggerLayerDeps,
|
|
245
|
+
projectId: string,
|
|
246
|
+
env: string,
|
|
247
|
+
): Promise<RegistrationOutcome[]> {
|
|
248
|
+
if (env !== "production") return [];
|
|
249
|
+
const { rows } = await deps.db.query<{ automation_id: string; manifest: { trigger?: { kind?: string } } }>(
|
|
250
|
+
`SELECT a.automation_id, v.manifest FROM aliases a JOIN automation_versions v ON v.id=a.version_id
|
|
251
|
+
WHERE a.project_id=$1 AND a.env=$2`,
|
|
252
|
+
[projectId, env],
|
|
253
|
+
);
|
|
254
|
+
const outcomes: RegistrationOutcome[] = [];
|
|
255
|
+
const liveAutomationIds = new Set<string>();
|
|
256
|
+
for (const row of rows) {
|
|
257
|
+
liveAutomationIds.add(row.automation_id);
|
|
258
|
+
if (row.manifest.trigger?.kind !== "connector") continue;
|
|
259
|
+
const outcome = await ensureRegistration(deps, projectId, row.automation_id, env);
|
|
260
|
+
if (outcome) outcomes.push(outcome);
|
|
261
|
+
}
|
|
262
|
+
// Undeployed automations: tear down their registrations.
|
|
263
|
+
const stale = await deps.db.query<{ id: string; automation_id: string }>(
|
|
264
|
+
`SELECT id, automation_id FROM webhook_registrations
|
|
265
|
+
WHERE project_id=$1 AND env=$2 AND status IN ('registered','manual-pending','failed')`,
|
|
266
|
+
[projectId, env],
|
|
267
|
+
);
|
|
268
|
+
for (const reg of stale.rows) {
|
|
269
|
+
if (!liveAutomationIds.has(reg.automation_id)) {
|
|
270
|
+
await destroyRegistration(deps, projectId, reg.automation_id, env, reg.id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return outcomes;
|
|
274
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Shared plumbing for the connector-trigger layer (ADR-0013): resolve a live automation
|
|
2
|
+
// version to its def + connector instance + trigger declaration, and manage the
|
|
3
|
+
// version-independent trigger state (windowed seen-set keyed by automation+trigger+connection).
|
|
4
|
+
|
|
5
|
+
import { TerminalError, type AutomationDef } from "@devosurf/tesser-sdk";
|
|
6
|
+
import type {
|
|
7
|
+
AnyTriggerDecl,
|
|
8
|
+
ConnectorInstance,
|
|
9
|
+
PollTriggerDecl,
|
|
10
|
+
WebhookTriggerDecl,
|
|
11
|
+
} from "@devosurf/tesser-sdk/connector";
|
|
12
|
+
import { decodeJournal, validateSchema, type JsonValue } from "@devosurf/tesser-sdk/internal";
|
|
13
|
+
import type { Db } from "../db/db.js";
|
|
14
|
+
import type { Broker, ConnectionRow } from "../broker/broker.js";
|
|
15
|
+
|
|
16
|
+
export interface TriggerLayerDeps {
|
|
17
|
+
db: Db;
|
|
18
|
+
broker: Broker;
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
loadAutomation(versionId: string): Promise<AutomationDef<any, any, any, any, any, any, any>>;
|
|
21
|
+
fetchImpl?: typeof fetch | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConnectorTriggerManifest {
|
|
25
|
+
kind: "connector";
|
|
26
|
+
connector: string;
|
|
27
|
+
trigger: string;
|
|
28
|
+
params: JsonValue;
|
|
29
|
+
connection: string;
|
|
30
|
+
every?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolvedTrigger {
|
|
34
|
+
projectId: string;
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
automationId: string;
|
|
37
|
+
env: string;
|
|
38
|
+
versionId: string;
|
|
39
|
+
def: AutomationDef<any, any, any, any, any, any, any>;
|
|
40
|
+
manifest: ConnectorTriggerManifest;
|
|
41
|
+
connector: ConnectorInstance<any, any>;
|
|
42
|
+
decl: AnyTriggerDecl;
|
|
43
|
+
/** Validated trigger params (decl.input). */
|
|
44
|
+
params: unknown;
|
|
45
|
+
connection: ConnectionRow;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isPollDecl(d: AnyTriggerDecl): d is PollTriggerDecl<any, any, any> {
|
|
49
|
+
return d.__trigger === "poll";
|
|
50
|
+
}
|
|
51
|
+
export function isWebhookDecl(d: AnyTriggerDecl): d is WebhookTriggerDecl<any, any> {
|
|
52
|
+
return d.__trigger === "webhook";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve everything a poll/delivery needs from the CURRENT live alias — the stable
|
|
56
|
+
* registration outlives versions; the version is resolved at fire time (ADR-0013). */
|
|
57
|
+
export async function resolveLiveTrigger(
|
|
58
|
+
deps: TriggerLayerDeps,
|
|
59
|
+
projectId: string,
|
|
60
|
+
automationId: string,
|
|
61
|
+
env: string,
|
|
62
|
+
): Promise<ResolvedTrigger | null> {
|
|
63
|
+
const { rows } = await deps.db.query<{
|
|
64
|
+
version_id: string;
|
|
65
|
+
manifest: { trigger?: ConnectorTriggerManifest };
|
|
66
|
+
workspace_id: string;
|
|
67
|
+
}>(
|
|
68
|
+
`SELECT a.version_id, v.manifest, p.workspace_id
|
|
69
|
+
FROM aliases a
|
|
70
|
+
JOIN automation_versions v ON v.id = a.version_id
|
|
71
|
+
JOIN projects p ON p.id = a.project_id
|
|
72
|
+
WHERE a.project_id=$1 AND a.automation_id=$2 AND a.env=$3`,
|
|
73
|
+
[projectId, automationId, env],
|
|
74
|
+
);
|
|
75
|
+
const row = rows[0];
|
|
76
|
+
if (!row || row.manifest.trigger?.kind !== "connector") return null;
|
|
77
|
+
const manifest = row.manifest.trigger;
|
|
78
|
+
|
|
79
|
+
const def = await deps.loadAutomation(row.version_id);
|
|
80
|
+
const connector = (def.connections as Record<string, ConnectorInstance<any, any>> | undefined)?.[
|
|
81
|
+
manifest.connection
|
|
82
|
+
];
|
|
83
|
+
if (!connector || connector.id !== manifest.connector) {
|
|
84
|
+
throw new TerminalError(
|
|
85
|
+
`automation ${automationId}: trigger connection "${manifest.connection}" missing from the loaded bundle`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const decl = connector.__connector.triggers?.[manifest.trigger];
|
|
89
|
+
if (!decl) {
|
|
90
|
+
throw new TerminalError(`connector ${manifest.connector} has no trigger "${manifest.trigger}"`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const conn = await deps.broker.resolveBinding({
|
|
94
|
+
workspaceId: row.workspace_id,
|
|
95
|
+
projectId,
|
|
96
|
+
automationId,
|
|
97
|
+
env,
|
|
98
|
+
reqKey: manifest.connection,
|
|
99
|
+
connectorId: manifest.connector,
|
|
100
|
+
scope: connector.scope ?? "workspace",
|
|
101
|
+
});
|
|
102
|
+
if (!conn) return null; // connection went away → trigger dormant until reconnected
|
|
103
|
+
|
|
104
|
+
const params = await validateSchema(
|
|
105
|
+
decl.input,
|
|
106
|
+
decodeJournal(manifest.params),
|
|
107
|
+
`${manifest.connector}.triggers.${manifest.trigger} params`,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
projectId,
|
|
112
|
+
workspaceId: row.workspace_id,
|
|
113
|
+
automationId,
|
|
114
|
+
env,
|
|
115
|
+
versionId: row.version_id,
|
|
116
|
+
def,
|
|
117
|
+
manifest,
|
|
118
|
+
connector,
|
|
119
|
+
decl,
|
|
120
|
+
params,
|
|
121
|
+
connection: conn,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- windowed seen-set (version-independent dedup, ADR-0013) ----
|
|
126
|
+
|
|
127
|
+
const SEEN_WINDOW_MS = 14 * 24 * 3600 * 1000;
|
|
128
|
+
const SEEN_MAX = 2000;
|
|
129
|
+
|
|
130
|
+
export interface TriggerState {
|
|
131
|
+
seen: Array<[string, number]>;
|
|
132
|
+
cursor: JsonValue | null;
|
|
133
|
+
seeded: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function loadTriggerState(
|
|
137
|
+
db: Db,
|
|
138
|
+
key: { projectId: string; automationId: string; triggerId: string; connectionId: string },
|
|
139
|
+
kind: "poll" | "webhook",
|
|
140
|
+
): Promise<TriggerState> {
|
|
141
|
+
const { rows } = await db.query<{ seen: Array<[string, number]>; cursor: JsonValue | null; seeded: boolean }>(
|
|
142
|
+
`SELECT seen, cursor, seeded FROM trigger_state
|
|
143
|
+
WHERE project_id=$1 AND automation_id=$2 AND trigger_id=$3 AND connection_id=$4`,
|
|
144
|
+
[key.projectId, key.automationId, key.triggerId, key.connectionId],
|
|
145
|
+
);
|
|
146
|
+
if (rows[0]) return rows[0];
|
|
147
|
+
await db.query(
|
|
148
|
+
`INSERT INTO trigger_state (project_id, automation_id, trigger_id, connection_id, kind)
|
|
149
|
+
VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
|
|
150
|
+
[key.projectId, key.automationId, key.triggerId, key.connectionId, kind],
|
|
151
|
+
);
|
|
152
|
+
return { seen: [], cursor: null, seeded: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function pruneSeen(seen: Array<[string, number]>, nowMs: number): Array<[string, number]> {
|
|
156
|
+
const cutoff = nowMs - SEEN_WINDOW_MS;
|
|
157
|
+
const pruned = seen.filter(([, ts]) => ts >= cutoff);
|
|
158
|
+
return pruned.length > SEEN_MAX ? pruned.slice(pruned.length - SEEN_MAX) : pruned;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function saveTriggerState(
|
|
162
|
+
db: Db,
|
|
163
|
+
key: { projectId: string; automationId: string; triggerId: string; connectionId: string },
|
|
164
|
+
state: { seen?: Array<[string, number]>; cursor?: JsonValue | null; seeded?: boolean; touch?: "poll" | "delivery" },
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const sets: string[] = [];
|
|
167
|
+
const params: unknown[] = [key.projectId, key.automationId, key.triggerId, key.connectionId];
|
|
168
|
+
if (state.seen !== undefined) {
|
|
169
|
+
params.push(JSON.stringify(state.seen));
|
|
170
|
+
sets.push(`seen = $${params.length}::jsonb`);
|
|
171
|
+
}
|
|
172
|
+
if (state.cursor !== undefined) {
|
|
173
|
+
params.push(state.cursor === null ? null : JSON.stringify(state.cursor));
|
|
174
|
+
sets.push(`cursor = $${params.length}::jsonb`);
|
|
175
|
+
}
|
|
176
|
+
if (state.seeded !== undefined) {
|
|
177
|
+
params.push(state.seeded);
|
|
178
|
+
sets.push(`seeded = $${params.length}`);
|
|
179
|
+
}
|
|
180
|
+
if (state.touch === "poll") sets.push(`last_poll_at = now()`);
|
|
181
|
+
if (state.touch === "delivery") sets.push(`last_delivery_at = now()`);
|
|
182
|
+
if (sets.length === 0) return;
|
|
183
|
+
await db.query(
|
|
184
|
+
`UPDATE trigger_state SET ${sets.join(", ")}
|
|
185
|
+
WHERE project_id=$1 AND automation_id=$2 AND trigger_id=$3 AND connection_id=$4`,
|
|
186
|
+
params,
|
|
187
|
+
);
|
|
188
|
+
}
|