@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,73 @@
|
|
|
1
|
+
// Event fan-out (ADR-0011): an emitted Project-scoped event creates one independent
|
|
2
|
+
// durable run per subscriber. At-least-once delivery; duplicate fan-out job executions
|
|
3
|
+
// are absorbed by a per-(event, automation) existence check.
|
|
4
|
+
|
|
5
|
+
import { decodeJournal, type JsonValue } from "@devosurf/tesser-sdk/internal";
|
|
6
|
+
import type { Db } from "../db/db.js";
|
|
7
|
+
import { createRun } from "../engine/runs.js";
|
|
8
|
+
|
|
9
|
+
export async function fanoutEvent(db: Db, eventId: string): Promise<string[]> {
|
|
10
|
+
const { rows } = await db.query<{
|
|
11
|
+
id: string;
|
|
12
|
+
project_id: string;
|
|
13
|
+
env: string;
|
|
14
|
+
name: string;
|
|
15
|
+
payload: JsonValue | null;
|
|
16
|
+
}>(`SELECT id, project_id, env, name, payload FROM events WHERE id=$1`, [eventId]);
|
|
17
|
+
const event = rows[0];
|
|
18
|
+
if (!event) return [];
|
|
19
|
+
|
|
20
|
+
const subs = await db.query<{ automation_id: string }>(
|
|
21
|
+
`SELECT automation_id FROM event_subscriptions WHERE project_id=$1 AND env=$2 AND event_name=$3`,
|
|
22
|
+
[event.project_id, event.env, event.name],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const created: string[] = [];
|
|
26
|
+
for (const sub of subs.rows) {
|
|
27
|
+
const runId = await db.tx(async (c) => {
|
|
28
|
+
const existing = await c.query(
|
|
29
|
+
`SELECT 1 FROM runs WHERE automation_id=$1 AND project_id=$2 AND trigger->>'eventId' = $3`,
|
|
30
|
+
[sub.automation_id, event.project_id, event.id],
|
|
31
|
+
);
|
|
32
|
+
if (existing.rows.length > 0) return null;
|
|
33
|
+
|
|
34
|
+
const alias = await c.query<{ version_id: string }>(
|
|
35
|
+
`SELECT version_id FROM aliases WHERE project_id=$1 AND automation_id=$2 AND env=$3`,
|
|
36
|
+
[event.project_id, sub.automation_id, event.env],
|
|
37
|
+
);
|
|
38
|
+
const versionId = alias.rows[0]?.version_id;
|
|
39
|
+
if (!versionId) return null;
|
|
40
|
+
|
|
41
|
+
return createRun(c, {
|
|
42
|
+
projectId: event.project_id,
|
|
43
|
+
automationId: sub.automation_id,
|
|
44
|
+
versionId,
|
|
45
|
+
env: event.env,
|
|
46
|
+
trigger: { kind: "event", event: event.name, eventId: event.id },
|
|
47
|
+
// stored payload is journal-encoded; createRun re-encodes, so decode here
|
|
48
|
+
input: event.payload === null ? undefined : decodeJournal(event.payload),
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
if (runId) created.push(runId);
|
|
52
|
+
}
|
|
53
|
+
return created;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sync a project+env's event subscriptions from promoted manifests. */
|
|
57
|
+
export async function syncSubscriptions(
|
|
58
|
+
db: Db,
|
|
59
|
+
projectId: string,
|
|
60
|
+
env: string,
|
|
61
|
+
subs: Array<{ eventName: string; automationId: string }>,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
await db.tx(async (c) => {
|
|
64
|
+
await c.query(`DELETE FROM event_subscriptions WHERE project_id=$1 AND env=$2`, [projectId, env]);
|
|
65
|
+
for (const s of subs) {
|
|
66
|
+
await c.query(
|
|
67
|
+
`INSERT INTO event_subscriptions (project_id, env, event_name, automation_id)
|
|
68
|
+
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
|
|
69
|
+
[projectId, env, s.eventName, s.automationId],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Server-side build (ADR-0006): bundle each changed automation into a self-contained
|
|
2
|
+
// immutable artifact (SDK + connectors + deps compiled in) and statically extract its
|
|
3
|
+
// manifest — module evaluation only, the handler never runs (ADR-0010).
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { build } from "esbuild";
|
|
10
|
+
import type { AutomationDef } from "@devosurf/tesser-sdk";
|
|
11
|
+
import type { ConnectorInstance } from "@devosurf/tesser-sdk/connector";
|
|
12
|
+
import {
|
|
13
|
+
extractAutomationManifest,
|
|
14
|
+
extractConnectorManifest,
|
|
15
|
+
type AutomationManifest,
|
|
16
|
+
type ConnectorManifest,
|
|
17
|
+
} from "@devosurf/tesser-sdk/internal";
|
|
18
|
+
|
|
19
|
+
export interface DiscoveredAutomation {
|
|
20
|
+
automationId: string;
|
|
21
|
+
dir: string;
|
|
22
|
+
entry: string;
|
|
23
|
+
contentHash: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "package.json", "tesser.json"];
|
|
27
|
+
|
|
28
|
+
export function discoverAutomations(repoDir: string): DiscoveredAutomation[] {
|
|
29
|
+
const root = join(repoDir, "automations");
|
|
30
|
+
if (!existsSync(root)) return [];
|
|
31
|
+
const lockHash = createHash("sha256");
|
|
32
|
+
for (const f of LOCKFILES) {
|
|
33
|
+
const p = join(repoDir, f);
|
|
34
|
+
if (existsSync(p)) lockHash.update(f).update(readFileSync(p));
|
|
35
|
+
}
|
|
36
|
+
const lock = lockHash.digest("hex");
|
|
37
|
+
|
|
38
|
+
const out: DiscoveredAutomation[] = [];
|
|
39
|
+
for (const name of readdirSync(root).sort()) {
|
|
40
|
+
const dir = join(root, name);
|
|
41
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
42
|
+
const entry = join(dir, "index.ts");
|
|
43
|
+
if (!existsSync(entry)) continue;
|
|
44
|
+
out.push({ automationId: name, dir, entry, contentHash: hashDir(dir, lock) });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hashDir(dir: string, seed: string): string {
|
|
50
|
+
const h = createHash("sha256").update(seed);
|
|
51
|
+
const walk = (d: string) => {
|
|
52
|
+
for (const entry of readdirSync(d).sort()) {
|
|
53
|
+
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
|
54
|
+
const p = join(d, entry);
|
|
55
|
+
const st = statSync(p);
|
|
56
|
+
if (st.isDirectory()) walk(p);
|
|
57
|
+
else h.update(relative(dir, p)).update(readFileSync(p));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(dir);
|
|
61
|
+
return h.digest("hex").slice(0, 24);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function buildAutomationBundle(entry: string, outFile: string): Promise<void> {
|
|
65
|
+
mkdirSync(join(outFile, ".."), { recursive: true });
|
|
66
|
+
await build({
|
|
67
|
+
entryPoints: [entry],
|
|
68
|
+
outfile: outFile,
|
|
69
|
+
bundle: true,
|
|
70
|
+
platform: "node",
|
|
71
|
+
format: "esm",
|
|
72
|
+
target: "node20",
|
|
73
|
+
sourcemap: "inline",
|
|
74
|
+
// Self-contained: everything (sdk, connectors, zod) is compiled in; only node
|
|
75
|
+
// builtins stay external. The artifact runs with no node_modules next to it.
|
|
76
|
+
packages: "bundle",
|
|
77
|
+
logLevel: "silent",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ExtractedBundle {
|
|
82
|
+
manifest: AutomationManifest & { connectors: Record<string, ConnectorManifest> };
|
|
83
|
+
def: AutomationDef<any, any, any, any, any, any, any>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Import the built artifact and statically extract its manifest (module evaluation
|
|
87
|
+
* only; `run` is never called). The cache-busting query keeps re-extraction honest. */
|
|
88
|
+
export async function extractBundle(bundlePath: string): Promise<ExtractedBundle> {
|
|
89
|
+
const mod = (await import(`${pathToFileURL(bundlePath).href}?t=${Date.now()}`)) as {
|
|
90
|
+
default?: AutomationDef<any, any, any, any, any, any, any>;
|
|
91
|
+
};
|
|
92
|
+
const def = mod.default;
|
|
93
|
+
if (!def || typeof def !== "object" || typeof def.run !== "function") {
|
|
94
|
+
throw new Error(`bundle has no default export from defineAutomation: ${bundlePath}`);
|
|
95
|
+
}
|
|
96
|
+
const manifest = extractAutomationManifest(def);
|
|
97
|
+
const connectors: Record<string, ConnectorManifest> = {};
|
|
98
|
+
for (const conn of Object.values((def.connections ?? {}) as Record<string, ConnectorInstance<any, any>>)) {
|
|
99
|
+
connectors[conn.id] = extractConnectorManifest(conn);
|
|
100
|
+
}
|
|
101
|
+
return { manifest: { ...manifest, connectors }, def };
|
|
102
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface DeployKeyPair {
|
|
4
|
+
publicKey: string;
|
|
5
|
+
privateKey: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface RsaPublicJwk {
|
|
9
|
+
kty?: string;
|
|
10
|
+
n?: string;
|
|
11
|
+
e?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sshWireString(value: string | Buffer): Buffer {
|
|
15
|
+
const bytes = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
16
|
+
const len = Buffer.alloc(4);
|
|
17
|
+
len.writeUInt32BE(bytes.length, 0);
|
|
18
|
+
return Buffer.concat([len, bytes]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sshMpint(value: Buffer): Buffer {
|
|
22
|
+
let bytes = value;
|
|
23
|
+
while (bytes.length > 1 && bytes[0] === 0) bytes = bytes.subarray(1);
|
|
24
|
+
if ((bytes[0]! & 0x80) !== 0) bytes = Buffer.concat([Buffer.from([0]), bytes]);
|
|
25
|
+
return sshWireString(bytes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rsaPublicKeyFromJwk(jwk: RsaPublicJwk): string {
|
|
29
|
+
if (jwk.kty !== "RSA" || typeof jwk.n !== "string" || typeof jwk.e !== "string") {
|
|
30
|
+
throw new Error("unexpected rsa public key encoding");
|
|
31
|
+
}
|
|
32
|
+
const algorithm = "ssh-rsa";
|
|
33
|
+
const exponent = Buffer.from(jwk.e, "base64url");
|
|
34
|
+
const modulus = Buffer.from(jwk.n, "base64url");
|
|
35
|
+
const wire = Buffer.concat([sshWireString(algorithm), sshMpint(exponent), sshMpint(modulus)]).toString("base64");
|
|
36
|
+
return `${algorithm} ${wire}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function generateDeployKeyPair(): DeployKeyPair {
|
|
40
|
+
// OpenSSH accepts PKCS#1 RSA private keys for `GIT_SSH_COMMAND=ssh -i ...`.
|
|
41
|
+
// Keep generation pure-Node so the server does not need `ssh-keygen` at link time.
|
|
42
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 3072, publicExponent: 0x10001 });
|
|
43
|
+
return {
|
|
44
|
+
publicKey: rsaPublicKeyFromJwk(publicKey.export({ format: "jwk" })),
|
|
45
|
+
privateKey: privateKey.export({ type: "pkcs1", format: "pem" }).toString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function mintWebhookSigningSecret(): string {
|
|
50
|
+
return `whsec_${randomBytes(32).toString("base64url")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mintWebhookSetupToken(): string {
|
|
54
|
+
return `wst_${randomBytes(24).toString("base64url")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hashWebhookSetupToken(token: string): string {
|
|
58
|
+
return createHash("sha256").update(token).digest("hex");
|
|
59
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// The reconciler (ADR-0006): git → instance, one-way. Clone/fetch the commit, content-
|
|
2
|
+
// hash-diff each automation directory, build only the changed ones into immutable staged
|
|
3
|
+
// versions, halt on missing credentials (connect link), gate on tests (auto smoke), and
|
|
4
|
+
// only on green swap aliases — red leaves production untouched. Then wire schedules,
|
|
5
|
+
// event subscriptions, webhook registrations, and polls. Undeployed automations are
|
|
6
|
+
// unaliased and their provider hooks destroyed.
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { mkdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { smokeTest } from "@devosurf/tesser-testing";
|
|
13
|
+
import type { Db } from "../db/db.js";
|
|
14
|
+
import type { Broker } from "../broker/broker.js";
|
|
15
|
+
import {
|
|
16
|
+
computeMissingRequirements,
|
|
17
|
+
mintConnectLink,
|
|
18
|
+
type AutomationRequirementSource,
|
|
19
|
+
type Requirement,
|
|
20
|
+
} from "../broker/connect.js";
|
|
21
|
+
import { syncSchedules, type ScheduleSpec } from "../scheduler/cron.js";
|
|
22
|
+
import { syncSubscriptions } from "../events/fanout.js";
|
|
23
|
+
import { ensureRegistrations } from "../triggers/registrar.js";
|
|
24
|
+
import { activatePolls } from "../triggers/poll.js";
|
|
25
|
+
import type { TriggerLayerDeps } from "../triggers/shared.js";
|
|
26
|
+
import type { ArtifactLoader } from "../registry/loader.js";
|
|
27
|
+
import { buildAutomationBundle, discoverAutomations, extractBundle } from "./build.js";
|
|
28
|
+
|
|
29
|
+
const exec = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
export interface ReconcilerDeps {
|
|
32
|
+
db: Db;
|
|
33
|
+
broker: Broker;
|
|
34
|
+
loader: ArtifactLoader;
|
|
35
|
+
dataDir: string;
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
triggerDeps: TriggerLayerDeps;
|
|
38
|
+
/** Run colocated project tests as the deploy gate (in addition to auto smoke). */
|
|
39
|
+
runProjectTests?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DeployReport {
|
|
43
|
+
sha: string;
|
|
44
|
+
ref: string;
|
|
45
|
+
env: string;
|
|
46
|
+
built: string[];
|
|
47
|
+
unchanged: string[];
|
|
48
|
+
failed: Array<{ automation: string; stage: "build" | "test"; reason: string }>;
|
|
49
|
+
removed: string[];
|
|
50
|
+
connectUrl?: string;
|
|
51
|
+
requirements?: Requirement[];
|
|
52
|
+
manual?: Array<{ automation: string; trigger: string }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function git(args: string[], cwd?: string, env?: Record<string, string>): Promise<string> {
|
|
56
|
+
const { stdout } = await exec("git", args, {
|
|
57
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
58
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
|
59
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
60
|
+
});
|
|
61
|
+
return stdout.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function syncWorkdir(opts: {
|
|
65
|
+
dataDir: string;
|
|
66
|
+
projectId: string;
|
|
67
|
+
repoUrl: string;
|
|
68
|
+
ref: string;
|
|
69
|
+
sshKeyPath?: string | undefined;
|
|
70
|
+
}): Promise<{ dir: string; sha: string }> {
|
|
71
|
+
const dir = join(opts.dataDir, "repos", opts.projectId);
|
|
72
|
+
const env: Record<string, string> = {};
|
|
73
|
+
if (opts.sshKeyPath !== undefined) {
|
|
74
|
+
env["GIT_SSH_COMMAND"] = `ssh -i ${opts.sshKeyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`;
|
|
75
|
+
}
|
|
76
|
+
const localRepoPath = localPathForGitSafeDirectory(opts.repoUrl);
|
|
77
|
+
if (localRepoPath !== undefined) {
|
|
78
|
+
const gitConfigDir = join(opts.dataDir, "git-config");
|
|
79
|
+
mkdirSync(gitConfigDir, { recursive: true, mode: 0o700 });
|
|
80
|
+
const gitConfigPath = join(gitConfigDir, `${opts.projectId}.gitconfig`);
|
|
81
|
+
writeFileSync(gitConfigPath, `[safe]\n\tdirectory = ${localRepoPath}\n`, { mode: 0o600 });
|
|
82
|
+
env["GIT_CONFIG_GLOBAL"] = gitConfigPath;
|
|
83
|
+
}
|
|
84
|
+
if (!existsSync(join(dir, ".git"))) {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
await git(["clone", "--no-single-branch", opts.repoUrl, dir], undefined, env);
|
|
88
|
+
} else {
|
|
89
|
+
await git(["fetch", "origin", "--prune"], dir, env);
|
|
90
|
+
}
|
|
91
|
+
await git(["checkout", "--force", opts.ref], dir, env).catch(async () => {
|
|
92
|
+
await git(["checkout", "--force", `origin/${opts.ref}`], dir, env);
|
|
93
|
+
});
|
|
94
|
+
// fast-forward to remote when the ref is a branch
|
|
95
|
+
await git(["reset", "--hard", `origin/${opts.ref}`], dir, env).catch(() => {});
|
|
96
|
+
const sha = await git(["rev-parse", "HEAD"], dir);
|
|
97
|
+
return { dir, sha };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function localPathForGitSafeDirectory(repoUrl: string): string | undefined {
|
|
101
|
+
if (repoUrl.startsWith("/")) return repoUrl;
|
|
102
|
+
if (repoUrl.startsWith("file://")) {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(repoUrl).pathname;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function installDeps(dir: string): Promise<void> {
|
|
113
|
+
// Install only when a lockfile pins the dependency graph (reproducible builds,
|
|
114
|
+
// ADR-0006). Lockfile-less trees resolve from their surroundings (dev/dogfood lane).
|
|
115
|
+
const runner = existsSync(join(dir, "pnpm-lock.yaml"))
|
|
116
|
+
? ["pnpm", ["install", "--ignore-scripts", "--prefer-offline", "--frozen-lockfile"]]
|
|
117
|
+
: existsSync(join(dir, "package-lock.json"))
|
|
118
|
+
? ["npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]]
|
|
119
|
+
: null;
|
|
120
|
+
if (!runner) return;
|
|
121
|
+
await exec(runner[0] as string, runner[1] as string[], {
|
|
122
|
+
cwd: dir,
|
|
123
|
+
env: { ...process.env, CI: "1" },
|
|
124
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function reconcileProject(
|
|
129
|
+
deps: ReconcilerDeps,
|
|
130
|
+
projectId: string,
|
|
131
|
+
opts: { ref?: string | undefined; localPath?: string | undefined } = {},
|
|
132
|
+
): Promise<DeployReport> {
|
|
133
|
+
const { rows: projects } = await deps.db.query<{
|
|
134
|
+
id: string;
|
|
135
|
+
workspace_id: string;
|
|
136
|
+
name: string;
|
|
137
|
+
repo_url: string | null;
|
|
138
|
+
prod_branch: string;
|
|
139
|
+
deploy_key_private_cipher: string | null;
|
|
140
|
+
}>(`SELECT * FROM projects WHERE id=$1`, [projectId]);
|
|
141
|
+
const project = projects[0];
|
|
142
|
+
if (!project) throw new Error(`no project ${projectId}`);
|
|
143
|
+
|
|
144
|
+
await deps.db.query(
|
|
145
|
+
`INSERT INTO repo_state (project_id, status) VALUES ($1,'syncing')
|
|
146
|
+
ON CONFLICT (project_id) DO UPDATE SET status='syncing', error=NULL`,
|
|
147
|
+
[projectId],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const finish = async (status: string, report: DeployReport): Promise<DeployReport> => {
|
|
151
|
+
await deps.db.query(
|
|
152
|
+
`UPDATE repo_state SET status=$2, report=$3::jsonb, last_sha=$4, branch=$5, last_synced_at=now() WHERE project_id=$1`,
|
|
153
|
+
[projectId, status, JSON.stringify(report), report.sha, report.ref],
|
|
154
|
+
);
|
|
155
|
+
return report;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const ref = opts.ref ?? project.prod_branch;
|
|
159
|
+
const env = ref === project.prod_branch ? "production" : `preview:${ref}`;
|
|
160
|
+
const report: DeployReport = { sha: "", ref, env, built: [], unchanged: [], failed: [], removed: [] };
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// ---- 1. working copy at the requested ref ----
|
|
164
|
+
let workdir: string;
|
|
165
|
+
if (opts.localPath !== undefined) {
|
|
166
|
+
workdir = opts.localPath; // tesser dev / tests: build straight from a local tree
|
|
167
|
+
report.sha = await git(["rev-parse", "HEAD"], workdir).catch(() => "local");
|
|
168
|
+
} else {
|
|
169
|
+
const repoUrl = project.repo_url;
|
|
170
|
+
if (!repoUrl) throw new Error("project has no repo_url and no localPath was given");
|
|
171
|
+
let sshKeyPath: string | undefined;
|
|
172
|
+
if (project.deploy_key_private_cipher) {
|
|
173
|
+
const key = await deps.broker.decryptValue(project.workspace_id, project.deploy_key_private_cipher, "deploykey");
|
|
174
|
+
const keyDir = join(deps.dataDir, "keys");
|
|
175
|
+
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
176
|
+
sshKeyPath = join(keyDir, `${projectId}.key`);
|
|
177
|
+
writeFileSync(sshKeyPath, key + "\n", { mode: 0o600 });
|
|
178
|
+
}
|
|
179
|
+
const synced = await syncWorkdir({ dataDir: deps.dataDir, projectId, repoUrl, ref, sshKeyPath });
|
|
180
|
+
workdir = synced.dir;
|
|
181
|
+
report.sha = synced.sha;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await installDeps(workdir);
|
|
185
|
+
|
|
186
|
+
// ---- 2. discover + diff + build changed only ----
|
|
187
|
+
const discovered = discoverAutomations(workdir);
|
|
188
|
+
const sources: AutomationRequirementSource[] = [];
|
|
189
|
+
const schedules: ScheduleSpec[] = [];
|
|
190
|
+
const subscriptions: Array<{ eventName: string; automationId: string }> = [];
|
|
191
|
+
const promote: Array<{ automationId: string; versionId: string }> = [];
|
|
192
|
+
const connectorManifests: Record<string, never> = {};
|
|
193
|
+
|
|
194
|
+
for (const auto of discovered) {
|
|
195
|
+
const { rows: latest } = await deps.db.query<{
|
|
196
|
+
id: string;
|
|
197
|
+
version: number;
|
|
198
|
+
content_hash: string;
|
|
199
|
+
status: string;
|
|
200
|
+
manifest: never;
|
|
201
|
+
}>(
|
|
202
|
+
`SELECT id, version, content_hash, status, manifest FROM automation_versions
|
|
203
|
+
WHERE project_id=$1 AND automation_id=$2 ORDER BY version DESC LIMIT 1`,
|
|
204
|
+
[projectId, auto.automationId],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
let versionId: string;
|
|
208
|
+
let manifest: import("./build.js").ExtractedBundle["manifest"];
|
|
209
|
+
|
|
210
|
+
if (latest[0] && latest[0].content_hash === auto.contentHash && latest[0].status !== "failed") {
|
|
211
|
+
versionId = latest[0].id;
|
|
212
|
+
manifest = latest[0].manifest;
|
|
213
|
+
report.unchanged.push(auto.automationId);
|
|
214
|
+
} else {
|
|
215
|
+
const version = (latest[0]?.version ?? 0) + 1;
|
|
216
|
+
const bundlePath = join(
|
|
217
|
+
deps.dataDir,
|
|
218
|
+
"artifacts",
|
|
219
|
+
projectId,
|
|
220
|
+
auto.automationId,
|
|
221
|
+
`v${version}`,
|
|
222
|
+
"bundle.mjs",
|
|
223
|
+
);
|
|
224
|
+
try {
|
|
225
|
+
await buildAutomationBundle(auto.entry, bundlePath);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
report.failed.push({ automation: auto.automationId, stage: "build", reason: String(err).slice(0, 2000) });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let extracted: import("./build.js").ExtractedBundle;
|
|
231
|
+
try {
|
|
232
|
+
extracted = await extractBundle(bundlePath);
|
|
233
|
+
if (extracted.manifest.id !== auto.automationId) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`automation id "${extracted.manifest.id}" must match its directory "automations/${auto.automationId}"`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
report.failed.push({ automation: auto.automationId, stage: "build", reason: String(err).slice(0, 2000) });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
manifest = extracted.manifest;
|
|
243
|
+
|
|
244
|
+
// ---- 3. test gate (ADR-0008): auto smoke against the REAL bundle ----
|
|
245
|
+
const smoke = await smokeTest(extracted.def);
|
|
246
|
+
const { rows: inserted } = await deps.db.query<{ id: string }>(
|
|
247
|
+
`INSERT INTO automation_versions
|
|
248
|
+
(project_id, automation_id, version, content_hash, bundle_path, manifest, git_sha, branch, status, test_report)
|
|
249
|
+
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10::jsonb)
|
|
250
|
+
RETURNING id`,
|
|
251
|
+
[
|
|
252
|
+
projectId,
|
|
253
|
+
auto.automationId,
|
|
254
|
+
version,
|
|
255
|
+
auto.contentHash,
|
|
256
|
+
bundlePath,
|
|
257
|
+
JSON.stringify(manifest),
|
|
258
|
+
report.sha,
|
|
259
|
+
ref,
|
|
260
|
+
smoke.passed ? "staged" : "failed",
|
|
261
|
+
JSON.stringify({
|
|
262
|
+
smoke: { passed: smoke.passed, ...(smoke.reason !== undefined ? { reason: smoke.reason } : {}) },
|
|
263
|
+
}),
|
|
264
|
+
],
|
|
265
|
+
);
|
|
266
|
+
versionId = inserted[0]!.id;
|
|
267
|
+
deps.loader.inject(versionId, extracted.def);
|
|
268
|
+
if (!smoke.passed) {
|
|
269
|
+
report.failed.push({
|
|
270
|
+
automation: auto.automationId,
|
|
271
|
+
stage: "test",
|
|
272
|
+
reason: smoke.reason ?? "smoke test failed",
|
|
273
|
+
});
|
|
274
|
+
continue; // red never promotes (ADR-0006)
|
|
275
|
+
}
|
|
276
|
+
report.built.push(auto.automationId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
Object.assign(connectorManifests, manifest.connectors ?? {});
|
|
280
|
+
sources.push({
|
|
281
|
+
automationId: auto.automationId,
|
|
282
|
+
connections: (manifest.connections ?? {}) as never,
|
|
283
|
+
secrets: (manifest.secrets ?? {}) as never,
|
|
284
|
+
});
|
|
285
|
+
if (manifest.trigger.kind === "schedule") {
|
|
286
|
+
schedules.push({
|
|
287
|
+
automationId: auto.automationId,
|
|
288
|
+
cron: manifest.trigger.cron,
|
|
289
|
+
tz: (manifest.trigger as { tz?: string }).tz,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (manifest.trigger.kind === "event") {
|
|
293
|
+
subscriptions.push({ eventName: (manifest.trigger as { event: string }).event, automationId: auto.automationId });
|
|
294
|
+
}
|
|
295
|
+
promote.push({ automationId: auto.automationId, versionId });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- 4. credential gate: halt BEFORE promotion (ADR-0005) ----
|
|
299
|
+
const missing = await computeMissingRequirements({
|
|
300
|
+
db: deps.db,
|
|
301
|
+
broker: deps.broker,
|
|
302
|
+
workspaceId: project.workspace_id,
|
|
303
|
+
projectId,
|
|
304
|
+
env,
|
|
305
|
+
automations: sources,
|
|
306
|
+
connectorManifests: connectorManifests as never,
|
|
307
|
+
});
|
|
308
|
+
if (missing.length > 0) {
|
|
309
|
+
const token = await mintConnectLink({
|
|
310
|
+
db: deps.db,
|
|
311
|
+
workspaceId: project.workspace_id,
|
|
312
|
+
projectId,
|
|
313
|
+
requirements: missing,
|
|
314
|
+
});
|
|
315
|
+
report.connectUrl = `${deps.baseUrl}/connect/${token}`;
|
|
316
|
+
report.requirements = missing;
|
|
317
|
+
return finish("halted-credentials", report);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- 5. promote green versions: alias swap, instant + atomic per automation ----
|
|
321
|
+
for (const p of promote) {
|
|
322
|
+
await deps.db.tx(async (c) => {
|
|
323
|
+
await c.query(
|
|
324
|
+
`INSERT INTO aliases (project_id, automation_id, env, version_id) VALUES ($1,$2,$3,$4)
|
|
325
|
+
ON CONFLICT (project_id, automation_id, env)
|
|
326
|
+
DO UPDATE SET version_id=EXCLUDED.version_id, updated_at=now()`,
|
|
327
|
+
[projectId, p.automationId, env, p.versionId],
|
|
328
|
+
);
|
|
329
|
+
await c.query(
|
|
330
|
+
`UPDATE automation_versions SET status='superseded'
|
|
331
|
+
WHERE project_id=$1 AND automation_id=$2 AND id <> $3 AND status='live'`,
|
|
332
|
+
[projectId, p.automationId, p.versionId],
|
|
333
|
+
);
|
|
334
|
+
await c.query(`UPDATE automation_versions SET status='live' WHERE id=$1`, [p.versionId]);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- 6. undeployed automations: unalias + teardown ----
|
|
339
|
+
const liveIds = new Set(discovered.map((d) => d.automationId));
|
|
340
|
+
const { rows: aliased } = await deps.db.query<{ automation_id: string }>(
|
|
341
|
+
`SELECT automation_id FROM aliases WHERE project_id=$1 AND env=$2`,
|
|
342
|
+
[projectId, env],
|
|
343
|
+
);
|
|
344
|
+
for (const row of aliased) {
|
|
345
|
+
if (!liveIds.has(row.automation_id)) {
|
|
346
|
+
await deps.db.query(`DELETE FROM aliases WHERE project_id=$1 AND automation_id=$2 AND env=$3`, [
|
|
347
|
+
projectId,
|
|
348
|
+
row.automation_id,
|
|
349
|
+
env,
|
|
350
|
+
]);
|
|
351
|
+
report.removed.push(row.automation_id);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- 7. wire triggers (production only; previews are trigger-inert, ADR-0013) ----
|
|
356
|
+
if (env === "production") {
|
|
357
|
+
await syncSchedules(deps.db, projectId, env, schedules);
|
|
358
|
+
await syncSubscriptions(deps.db, projectId, env, subscriptions);
|
|
359
|
+
const regs = await ensureRegistrations(deps.triggerDeps, projectId, env);
|
|
360
|
+
const manualReqs = regs.flatMap((r) => (r.manualRequirement !== undefined ? [r.manualRequirement] : []));
|
|
361
|
+
report.manual = regs
|
|
362
|
+
.filter((r) => r.status === "manual-pending")
|
|
363
|
+
.map((r) => ({ automation: r.automationId, trigger: r.trigger }));
|
|
364
|
+
await activatePolls(deps.triggerDeps, projectId, env);
|
|
365
|
+
if (manualReqs.length > 0) {
|
|
366
|
+
const token = await mintConnectLink({
|
|
367
|
+
db: deps.db,
|
|
368
|
+
workspaceId: project.workspace_id,
|
|
369
|
+
projectId,
|
|
370
|
+
requirements: manualReqs,
|
|
371
|
+
});
|
|
372
|
+
report.connectUrl = `${deps.baseUrl}/connect/${token}`;
|
|
373
|
+
report.requirements = manualReqs;
|
|
374
|
+
return finish("halted-credentials", report);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await gcArtifacts(deps.db, projectId);
|
|
379
|
+
return finish(report.failed.length > 0 ? "failed" : "synced", report);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
report.failed.push({ automation: "*", stage: "build", reason: String(err).slice(0, 2000) });
|
|
382
|
+
await deps.db.query(
|
|
383
|
+
`UPDATE repo_state SET status='failed', error=$2, report=$3::jsonb, last_synced_at=now() WHERE project_id=$1`,
|
|
384
|
+
[projectId, String(err).slice(0, 2000), JSON.stringify(report)],
|
|
385
|
+
);
|
|
386
|
+
return report;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Keep the last 10 versions per automation, plus aliased or active-run versions. */
|
|
391
|
+
export async function gcArtifacts(db: Db, projectId: string): Promise<void> {
|
|
392
|
+
await db.query(
|
|
393
|
+
`UPDATE automation_versions v SET status='superseded'
|
|
394
|
+
WHERE v.project_id=$1 AND v.status='staged'
|
|
395
|
+
AND v.created_at < now() - interval '1 day'`,
|
|
396
|
+
[projectId],
|
|
397
|
+
);
|
|
398
|
+
// row GC only; artifact files are cheap and removed with the data dir
|
|
399
|
+
await db.query(
|
|
400
|
+
`DELETE FROM automation_versions v
|
|
401
|
+
WHERE v.project_id=$1
|
|
402
|
+
AND NOT EXISTS (
|
|
403
|
+
SELECT 1 FROM aliases a
|
|
404
|
+
WHERE a.project_id=$1 AND a.version_id=v.id
|
|
405
|
+
)
|
|
406
|
+
AND NOT EXISTS (
|
|
407
|
+
SELECT 1 FROM runs r
|
|
408
|
+
WHERE r.project_id=$1
|
|
409
|
+
AND r.version_id=v.id
|
|
410
|
+
AND r.status IN ('queued','running','suspended')
|
|
411
|
+
)
|
|
412
|
+
AND v.version < COALESCE((
|
|
413
|
+
SELECT max(v2.version) - 9 FROM automation_versions v2
|
|
414
|
+
WHERE v2.project_id=v.project_id AND v2.automation_id=v.automation_id
|
|
415
|
+
), 0)`,
|
|
416
|
+
[projectId],
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function deployStatus(db: Db, projectId: string, env: string): Promise<unknown> {
|
|
421
|
+
const { rows: state } = await db.query(`SELECT status, error, report, last_sha, branch, last_synced_at FROM repo_state WHERE project_id=$1`, [projectId]);
|
|
422
|
+
const { rows: versions } = await db.query(
|
|
423
|
+
`SELECT a.automation_id, a.env, v.version, v.status, v.test_report
|
|
424
|
+
FROM aliases a JOIN automation_versions v ON v.id=a.version_id
|
|
425
|
+
WHERE a.project_id=$1 AND a.env=$2`,
|
|
426
|
+
[projectId, env],
|
|
427
|
+
);
|
|
428
|
+
return { repo: state[0] ?? null, live: versions };
|
|
429
|
+
}
|