@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,204 @@
|
|
|
1
|
+
// Inbound trigger ingress. Generic webhooks: stable route per project+automation, latest
|
|
2
|
+
// alias resolved at receive time (ADR-0013 spirit), version-pinned via @v. Connector
|
|
3
|
+
// webhooks land on /ingress/c/:token and are demuxed by the trigger layer. Git pushes
|
|
4
|
+
// land on /hooks/git/… and enqueue a reconcile.
|
|
5
|
+
|
|
6
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Db } from "../db/db.js";
|
|
9
|
+
import type { Broker } from "../broker/broker.js";
|
|
10
|
+
import { createRun } from "../engine/runs.js";
|
|
11
|
+
import { enqueue } from "../queue/queue.js";
|
|
12
|
+
|
|
13
|
+
export interface IngressDeps {
|
|
14
|
+
db: Db;
|
|
15
|
+
broker: Broker;
|
|
16
|
+
/** Connector-trigger demux (trigger layer); 404 until a registration exists. */
|
|
17
|
+
connectorIngress?: (
|
|
18
|
+
ingressToken: string,
|
|
19
|
+
req: { headers: Record<string, string>; query: Record<string, string>; rawBody: Uint8Array },
|
|
20
|
+
) => Promise<{ status: number; body: string; contentType?: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function headersToRecord(h: Headers): Record<string, string> {
|
|
24
|
+
const out: Record<string, string> = {};
|
|
25
|
+
h.forEach((v, k) => {
|
|
26
|
+
out[k.toLowerCase()] = v;
|
|
27
|
+
});
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeEqualString(a: string, b: string): boolean {
|
|
32
|
+
const ab = Buffer.from(a);
|
|
33
|
+
const bb = Buffer.from(b);
|
|
34
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function verifyGitWebhookSignature(headers: Headers, rawBody: Uint8Array, secret: string): boolean {
|
|
38
|
+
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
39
|
+
const github = headers.get("x-hub-signature-256");
|
|
40
|
+
if (github?.startsWith("sha256=") && safeEqualString(github.slice("sha256=".length), expected)) return true;
|
|
41
|
+
const tesser = headers.get("x-tesser-signature");
|
|
42
|
+
if (tesser?.startsWith("sha256=") && safeEqualString(tesser.slice("sha256=".length), expected)) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createIngress(deps: IngressDeps): Hono {
|
|
47
|
+
const app = new Hono();
|
|
48
|
+
|
|
49
|
+
// Generic inbound webhook trigger (onWebhook). Public by design: verification is the
|
|
50
|
+
// author's contract via ctx.request + a signing secret (ADR-0010 escape hatch).
|
|
51
|
+
app.post("/hooks/:project/:automation{[a-z0-9-]+(@v[0-9]+)?}", async (c) => {
|
|
52
|
+
const projectName = c.req.param("project");
|
|
53
|
+
const autoParam = c.req.param("automation");
|
|
54
|
+
const pinMatch = /^(?<id>[a-z0-9-]+?)(?:@v(?<v>\d+))?$/.exec(autoParam);
|
|
55
|
+
const automationId = pinMatch?.groups?.["id"] ?? autoParam;
|
|
56
|
+
const pinned = pinMatch?.groups?.["v"];
|
|
57
|
+
|
|
58
|
+
const { rows: projects } = await deps.db.query<{ id: string }>(
|
|
59
|
+
`SELECT id FROM projects WHERE name=$1`,
|
|
60
|
+
[projectName],
|
|
61
|
+
);
|
|
62
|
+
const project = projects[0];
|
|
63
|
+
if (!project) return c.json({ error: { code: "not-found", message: "unknown project" } }, 404);
|
|
64
|
+
|
|
65
|
+
let versionId: string | undefined;
|
|
66
|
+
let manifest: { trigger?: { kind?: string; respond?: string } } = {};
|
|
67
|
+
if (pinned !== undefined) {
|
|
68
|
+
const { rows } = await deps.db.query<{ id: string; manifest: typeof manifest }>(
|
|
69
|
+
`SELECT id, manifest FROM automation_versions WHERE project_id=$1 AND automation_id=$2 AND version=$3`,
|
|
70
|
+
[project.id, automationId, Number(pinned)],
|
|
71
|
+
);
|
|
72
|
+
versionId = rows[0]?.id;
|
|
73
|
+
manifest = rows[0]?.manifest ?? {};
|
|
74
|
+
} else {
|
|
75
|
+
const { rows } = await deps.db.query<{ version_id: string; manifest: typeof manifest }>(
|
|
76
|
+
`SELECT a.version_id, v.manifest FROM aliases a JOIN automation_versions v ON v.id=a.version_id
|
|
77
|
+
WHERE a.project_id=$1 AND a.automation_id=$2 AND a.env='production'`,
|
|
78
|
+
[project.id, automationId],
|
|
79
|
+
);
|
|
80
|
+
versionId = rows[0]?.version_id;
|
|
81
|
+
manifest = rows[0]?.manifest ?? {};
|
|
82
|
+
}
|
|
83
|
+
if (!versionId) return c.json({ error: { code: "not-found", message: "automation not deployed" } }, 404);
|
|
84
|
+
if (manifest.trigger?.kind !== undefined && manifest.trigger.kind !== "webhook") {
|
|
85
|
+
return c.json({ error: { code: "wrong-trigger", message: "automation is not webhook-triggered" } }, 404);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rawBody = new Uint8Array(await c.req.arrayBuffer());
|
|
89
|
+
let input: unknown;
|
|
90
|
+
const text = new TextDecoder().decode(rawBody);
|
|
91
|
+
try {
|
|
92
|
+
input = text.length > 0 ? JSON.parse(text) : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
input = undefined; // non-JSON bodies stay reachable via ctx.request.rawBody
|
|
95
|
+
}
|
|
96
|
+
const query: Record<string, string> = {};
|
|
97
|
+
for (const [k, v] of new URL(c.req.url).searchParams) query[k] = v;
|
|
98
|
+
|
|
99
|
+
const runId = await createRun(deps.db, {
|
|
100
|
+
projectId: project.id,
|
|
101
|
+
automationId,
|
|
102
|
+
versionId,
|
|
103
|
+
trigger: {
|
|
104
|
+
kind: "webhook",
|
|
105
|
+
request: {
|
|
106
|
+
headers: headersToRecord(c.req.raw.headers),
|
|
107
|
+
query,
|
|
108
|
+
rawBodyB64: Buffer.from(rawBody).toString("base64"),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
...(input !== undefined ? { input } : {}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (manifest.trigger?.respond === "sync") {
|
|
115
|
+
const deadline = Date.now() + 30_000;
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
const { rows } = await deps.db.query<{ status: string; output: unknown; error: unknown }>(
|
|
118
|
+
`SELECT status, output, error FROM runs WHERE id=$1`,
|
|
119
|
+
[runId!],
|
|
120
|
+
);
|
|
121
|
+
const run = rows[0]!;
|
|
122
|
+
if (run.status === "completed") return c.json({ runId, output: run.output });
|
|
123
|
+
if (run.status === "failed" || run.status === "cancelled") {
|
|
124
|
+
return c.json({ runId, error: run.error }, 502);
|
|
125
|
+
}
|
|
126
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return c.json({ runId }, 202);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Connector-trigger ingress (ADR-0013): stable per-registration URL.
|
|
133
|
+
app.post("/ingress/c/:token", async (c) => {
|
|
134
|
+
if (!deps.connectorIngress) {
|
|
135
|
+
return c.json({ error: { code: "not-ready", message: "trigger ingress not configured" } }, 503);
|
|
136
|
+
}
|
|
137
|
+
const rawBody = new Uint8Array(await c.req.arrayBuffer());
|
|
138
|
+
const query: Record<string, string> = {};
|
|
139
|
+
for (const [k, v] of new URL(c.req.url).searchParams) query[k] = v;
|
|
140
|
+
const result = await deps.connectorIngress(c.req.param("token"), {
|
|
141
|
+
headers: headersToRecord(c.req.raw.headers),
|
|
142
|
+
query,
|
|
143
|
+
rawBody,
|
|
144
|
+
});
|
|
145
|
+
return c.newResponse(result.body, result.status as never, {
|
|
146
|
+
"content-type": result.contentType ?? "application/json",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Git push webhook (ADR-0006 default sync path) — project id in path, HMAC over the
|
|
151
|
+
// raw body with encrypted per-Project signing material. The secret never appears in
|
|
152
|
+
// CLI/API JSON or URL paths.
|
|
153
|
+
const enqueueReconcile = async (projectId: string): Promise<void> => {
|
|
154
|
+
await deps.db.tx((t) =>
|
|
155
|
+
enqueue(t, {
|
|
156
|
+
kind: "reconcile",
|
|
157
|
+
payload: { projectId },
|
|
158
|
+
dedupeKey: `reconcile:${projectId}:default`,
|
|
159
|
+
maxAttempts: 1,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
app.post("/hooks/git/:projectId/push", async (c) => {
|
|
165
|
+
const { rows } = await deps.db.query<{
|
|
166
|
+
id: string;
|
|
167
|
+
workspace_id: string;
|
|
168
|
+
push_webhook_secret_cipher: string | null;
|
|
169
|
+
}>(`SELECT id, workspace_id, push_webhook_secret_cipher FROM projects WHERE id=$1`, [c.req.param("projectId")]);
|
|
170
|
+
const project = rows[0];
|
|
171
|
+
if (!project?.push_webhook_secret_cipher) {
|
|
172
|
+
return c.json({ error: { code: "not-found", message: "unknown hook" } }, 404);
|
|
173
|
+
}
|
|
174
|
+
const rawBody = new Uint8Array(await c.req.arrayBuffer());
|
|
175
|
+
const secret = await deps.broker.decryptValue(
|
|
176
|
+
project.workspace_id,
|
|
177
|
+
project.push_webhook_secret_cipher,
|
|
178
|
+
"project.webhook.signing",
|
|
179
|
+
);
|
|
180
|
+
if (!verifyGitWebhookSignature(c.req.raw.headers, rawBody, secret)) {
|
|
181
|
+
return c.json({ error: { code: "unauthorized", message: "invalid hook signature" } }, 401);
|
|
182
|
+
}
|
|
183
|
+
await enqueueReconcile(project.id);
|
|
184
|
+
return c.json({ queued: true }, 202);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Legacy v0 bootstrap route. Kept so Instances upgraded from the path-token webhook
|
|
188
|
+
// can keep receiving pushes until the self-host administrator re-runs `tesser link`
|
|
189
|
+
// and moves the git host to the HMAC `/push` route.
|
|
190
|
+
app.post("/hooks/git/:projectId/:secret", async (c) => {
|
|
191
|
+
const { rows } = await deps.db.query<{ id: string; push_webhook_secret: string | null }>(
|
|
192
|
+
`SELECT id, push_webhook_secret FROM projects WHERE id=$1`,
|
|
193
|
+
[c.req.param("projectId")],
|
|
194
|
+
);
|
|
195
|
+
const project = rows[0];
|
|
196
|
+
if (!project?.push_webhook_secret || !safeEqualString(project.push_webhook_secret, c.req.param("secret"))) {
|
|
197
|
+
return c.json({ error: { code: "not-found", message: "unknown hook" } }, 404);
|
|
198
|
+
}
|
|
199
|
+
await enqueueReconcile(project.id);
|
|
200
|
+
return c.json({ queued: true }, 202);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return app;
|
|
204
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Db } from "../db/db.js";
|
|
5
|
+
import { migrations } from "../db/migrations.js";
|
|
6
|
+
import type { ServerConfig } from "../config.js";
|
|
7
|
+
|
|
8
|
+
export const RUNTIME_VERSION = readRuntimeVersion();
|
|
9
|
+
|
|
10
|
+
function readRuntimeVersion(): string {
|
|
11
|
+
for (const rel of ["../package.json", "../../package.json"]) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf8")) as { version?: unknown };
|
|
14
|
+
if (typeof pkg.version === "string") return pkg.version;
|
|
15
|
+
} catch {
|
|
16
|
+
// Try the next runtime layout: source TS vs bundled dist.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RuntimeStatusDeps {
|
|
23
|
+
db: Db;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
dataDir: string;
|
|
26
|
+
roles: ServerConfig["roles"];
|
|
27
|
+
version?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReadinessStatus {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
service: "tesser";
|
|
33
|
+
version: string;
|
|
34
|
+
checks: {
|
|
35
|
+
database: { ok: boolean; kind: Db["kind"]; reachable: boolean; message?: string };
|
|
36
|
+
migrations: { ok: boolean; current: boolean; applied: string[]; pending: string[]; message?: string };
|
|
37
|
+
dataDir: { ok: boolean; writable: boolean; message?: string };
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readinessStatus(deps: RuntimeStatusDeps): Promise<ReadinessStatus> {
|
|
42
|
+
const database: ReadinessStatus["checks"]["database"] = { ok: false, kind: deps.db.kind, reachable: false };
|
|
43
|
+
const migrationsCheck: ReadinessStatus["checks"]["migrations"] = {
|
|
44
|
+
ok: false,
|
|
45
|
+
current: false,
|
|
46
|
+
applied: [],
|
|
47
|
+
pending: migrations.map((m) => m.id),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await deps.db.query(`SELECT 1 AS ok`);
|
|
52
|
+
database.ok = true;
|
|
53
|
+
database.reachable = true;
|
|
54
|
+
} catch {
|
|
55
|
+
database.message = "database query failed";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (database.reachable) {
|
|
59
|
+
try {
|
|
60
|
+
const { rows } = await deps.db.query<{ id: string }>(`SELECT id FROM schema_migrations ORDER BY id`);
|
|
61
|
+
const applied = rows.map((r) => r.id);
|
|
62
|
+
const appliedSet = new Set(applied);
|
|
63
|
+
const pending = migrations.map((m) => m.id).filter((id) => !appliedSet.has(id));
|
|
64
|
+
migrationsCheck.applied = applied;
|
|
65
|
+
migrationsCheck.pending = pending;
|
|
66
|
+
migrationsCheck.current = pending.length === 0;
|
|
67
|
+
migrationsCheck.ok = migrationsCheck.current;
|
|
68
|
+
} catch {
|
|
69
|
+
migrationsCheck.message = "schema_migrations check failed";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const dataDir = checkWritableDir(deps.dataDir);
|
|
74
|
+
return {
|
|
75
|
+
ok: database.ok && migrationsCheck.ok && dataDir.ok,
|
|
76
|
+
service: "tesser",
|
|
77
|
+
version: deps.version ?? RUNTIME_VERSION,
|
|
78
|
+
checks: { database, migrations: migrationsCheck, dataDir },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runtimeStatus(deps: RuntimeStatusDeps, workspaceId: string): Promise<Record<string, unknown>> {
|
|
83
|
+
const readiness = await readinessStatus(deps);
|
|
84
|
+
const queue = await queueStatus(deps.db);
|
|
85
|
+
const deployments = await deploymentStatus(deps.db, workspaceId);
|
|
86
|
+
const haltedCredentials = deployments.projects.filter((p) => p.status === "halted-credentials");
|
|
87
|
+
const degraded = queue.dead > 0 || (deployments.byStatus["failed"] ?? 0) > 0 || haltedCredentials.length > 0;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
ok: readiness.ok,
|
|
91
|
+
degraded,
|
|
92
|
+
service: "tesser",
|
|
93
|
+
version: deps.version ?? RUNTIME_VERSION,
|
|
94
|
+
booted: true,
|
|
95
|
+
baseUrl: deps.baseUrl,
|
|
96
|
+
roles: deps.roles,
|
|
97
|
+
readiness,
|
|
98
|
+
queue,
|
|
99
|
+
deployments,
|
|
100
|
+
haltedCredentials: {
|
|
101
|
+
count: haltedCredentials.length,
|
|
102
|
+
projects: haltedCredentials.map((p) => ({
|
|
103
|
+
name: p.name,
|
|
104
|
+
requirements: p.requirements,
|
|
105
|
+
lastSyncedAt: p.lastSyncedAt,
|
|
106
|
+
})),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkWritableDir(dataDir: string): { ok: boolean; writable: boolean; message?: string } {
|
|
112
|
+
const probe = join(dataDir, `.tesser-write-probe-${randomUUID()}`);
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(probe, "ok", { flag: "wx" });
|
|
115
|
+
rmSync(probe, { force: true });
|
|
116
|
+
return { ok: true, writable: true };
|
|
117
|
+
} catch {
|
|
118
|
+
return { ok: false, writable: false, message: "data directory is not writable" };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function queueStatus(db: Db): Promise<{ ready: number; leased: number; dead: number }> {
|
|
123
|
+
const { rows } = await db.query<{ ready: string; leased: string; dead: string }>(
|
|
124
|
+
`SELECT
|
|
125
|
+
SUM(CASE WHEN status = 'ready' AND lease_until IS NULL THEN 1 ELSE 0 END)::text AS ready,
|
|
126
|
+
SUM(CASE WHEN status = 'ready' AND lease_until IS NOT NULL THEN 1 ELSE 0 END)::text AS leased,
|
|
127
|
+
SUM(CASE WHEN status = 'dead' THEN 1 ELSE 0 END)::text AS dead
|
|
128
|
+
FROM queue_jobs`,
|
|
129
|
+
);
|
|
130
|
+
const row = rows[0] ?? { ready: "0", leased: "0", dead: "0" };
|
|
131
|
+
return { ready: Number(row.ready), leased: Number(row.leased), dead: Number(row.dead) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function deploymentStatus(
|
|
135
|
+
db: Db,
|
|
136
|
+
workspaceId: string,
|
|
137
|
+
): Promise<{ byStatus: Record<string, number>; projects: Array<{ name: string; status: string; lastSha: string | null; lastSyncedAt: string | null; requirements: number }> }> {
|
|
138
|
+
const { rows } = await db.query<{
|
|
139
|
+
name: string;
|
|
140
|
+
status: string | null;
|
|
141
|
+
last_sha: string | null;
|
|
142
|
+
last_synced_at: string | null;
|
|
143
|
+
report: unknown;
|
|
144
|
+
}>(
|
|
145
|
+
`SELECT p.name, r.status, r.last_sha, r.last_synced_at, r.report
|
|
146
|
+
FROM projects p LEFT JOIN repo_state r ON r.project_id = p.id
|
|
147
|
+
WHERE p.workspace_id = $1
|
|
148
|
+
ORDER BY p.name`,
|
|
149
|
+
[workspaceId],
|
|
150
|
+
);
|
|
151
|
+
const byStatus: Record<string, number> = {};
|
|
152
|
+
const projects = rows.map((row) => {
|
|
153
|
+
const status = row.status ?? "unlinked";
|
|
154
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1;
|
|
155
|
+
return {
|
|
156
|
+
name: row.name,
|
|
157
|
+
status,
|
|
158
|
+
lastSha: row.last_sha,
|
|
159
|
+
lastSyncedAt: row.last_synced_at,
|
|
160
|
+
requirements: requirementCount(row.report),
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
return { byStatus, projects };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function requirementCount(report: unknown): number {
|
|
167
|
+
if (report && typeof report === "object" && Array.isArray((report as { requirements?: unknown }).requirements)) {
|
|
168
|
+
return (report as { requirements: unknown[] }).requirements.length;
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// API tokens: sha256-at-rest, shown once at mint. The first boot prints an admin token;
|
|
2
|
+
// further tokens are minted via the API (CLI: `tesser login --token` / TESSER_TOKEN env).
|
|
3
|
+
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import type { Db } from "../db/db.js";
|
|
6
|
+
|
|
7
|
+
export function hashToken(token: string): string {
|
|
8
|
+
return createHash("sha256").update(token).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function mintToken(db: Db, workspaceId: string, name: string): Promise<string> {
|
|
12
|
+
const token = `tsk_${randomBytes(24).toString("hex")}`;
|
|
13
|
+
await db.query(`INSERT INTO api_tokens (workspace_id, name, token_hash) VALUES ($1,$2,$3)`, [
|
|
14
|
+
workspaceId,
|
|
15
|
+
name,
|
|
16
|
+
hashToken(token),
|
|
17
|
+
]);
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function verifyToken(db: Db, token: string): Promise<{ workspaceId: string } | null> {
|
|
22
|
+
if (!token.startsWith("tsk_")) return null;
|
|
23
|
+
const { rows } = await db.query<{ workspace_id: string; id: string }>(
|
|
24
|
+
`SELECT id, workspace_id FROM api_tokens WHERE token_hash = $1`,
|
|
25
|
+
[hashToken(token)],
|
|
26
|
+
);
|
|
27
|
+
if (!rows[0]) return null;
|
|
28
|
+
void db.query(`UPDATE api_tokens SET last_used_at = now() WHERE id = $1`, [rows[0].id]).catch(() => {});
|
|
29
|
+
return { workspaceId: rows[0].workspace_id };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function ensureAdminToken(db: Db, workspaceId: string): Promise<string | null> {
|
|
33
|
+
const { rows } = await db.query(`SELECT 1 FROM api_tokens WHERE workspace_id = $1 LIMIT 1`, [workspaceId]);
|
|
34
|
+
if (rows.length > 0) return null;
|
|
35
|
+
// `tesser dev` hands its spawned instance a known token (process boundary — the
|
|
36
|
+
// Apache CLI never links this AGPL code).
|
|
37
|
+
const preset = process.env["TESSER_BOOTSTRAP_TOKEN"];
|
|
38
|
+
if (preset && preset.startsWith("tsk_")) {
|
|
39
|
+
await db.query(`INSERT INTO api_tokens (workspace_id, name, token_hash) VALUES ($1,'bootstrap',$2)`, [
|
|
40
|
+
workspaceId,
|
|
41
|
+
hashToken(preset),
|
|
42
|
+
]);
|
|
43
|
+
return preset;
|
|
44
|
+
}
|
|
45
|
+
return mintToken(db, workspaceId, "admin (first boot)");
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @devosurf/tesser-server — AGPL-3.0. Programmatic surface (the binary is bin/tesser-server.mjs).
|
|
2
|
+
|
|
3
|
+
export { createTesserServer, type TesserServer } from "./server.js";
|
|
4
|
+
export { loadConfig, type ServerConfig } from "./config.js";
|
|
5
|
+
export { createDb, type Db, type DbClient } from "./db/db.js";
|
|
6
|
+
export { migrate } from "./db/migrate.js";
|
|
7
|
+
export { executeRun } from "./engine/executor.js";
|
|
8
|
+
export { createRun, deliverSignal, cancelRun } from "./engine/runs.js";
|
|
9
|
+
export type { EngineDeps, RunOutcome } from "./engine/types.js";
|
|
10
|
+
export { Worker } from "./queue/worker.js";
|
|
11
|
+
export { enqueue, claim } from "./queue/queue.js";
|
|
12
|
+
export { schedulerTick, syncSchedules, startScheduler } from "./scheduler/cron.js";
|
|
13
|
+
export { fanoutEvent, syncSubscriptions } from "./events/fanout.js";
|
|
14
|
+
export { Broker } from "./broker/broker.js";
|
|
15
|
+
export { Masker } from "./broker/masking.js";
|
|
16
|
+
export { makeEngineBindings } from "./broker/connections.js";
|
|
17
|
+
export { reconcileProject, deployStatus } from "./gitsync/reconciler.js";
|
|
18
|
+
export { ArtifactLoader } from "./registry/loader.js";
|
|
19
|
+
export { mintToken, verifyToken } from "./http/tokens.js";
|
|
20
|
+
export { createApp } from "./http/app.js";
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// tesser-server entrypoint.
|
|
2
|
+
|
|
3
|
+
import { createTesserServer } from "./server.js";
|
|
4
|
+
|
|
5
|
+
const server = await createTesserServer();
|
|
6
|
+
await server.start();
|
|
7
|
+
|
|
8
|
+
console.error(`[tesser] instance up on ${server.config.baseUrl} (db: ${server.db.kind})`);
|
|
9
|
+
console.error(
|
|
10
|
+
`[tesser] roles: ${Object.entries(server.config.roles)
|
|
11
|
+
.filter(([, on]) => on)
|
|
12
|
+
.map(([r]) => r)
|
|
13
|
+
.join(", ")}`,
|
|
14
|
+
);
|
|
15
|
+
if (server.adminToken) {
|
|
16
|
+
console.error(`[tesser] FIRST BOOT — admin API token (store it now, it is not shown again):`);
|
|
17
|
+
console.error(`[tesser] ${server.adminToken}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const shutdown = async (signal: string) => {
|
|
21
|
+
console.error(`[tesser] ${signal} — draining`);
|
|
22
|
+
await server.stop();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
};
|
|
25
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
26
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Postgres-as-queue (ADR-0003): SELECT … FOR UPDATE SKIP LOCKED claims, leases with
|
|
2
|
+
// heartbeat, durable re-enqueue, dedupe-keyed upserts (one pending wake job per run).
|
|
3
|
+
|
|
4
|
+
import type { Db, DbClient } from "../db/db.js";
|
|
5
|
+
|
|
6
|
+
export interface EnqueueOpts {
|
|
7
|
+
kind: string;
|
|
8
|
+
payload: Record<string, unknown>;
|
|
9
|
+
runAtMs?: number | undefined;
|
|
10
|
+
priority?: number | undefined;
|
|
11
|
+
tag?: string | undefined;
|
|
12
|
+
/** Upsert key: an existing ready job with this key has its run_at moved EARLIER. */
|
|
13
|
+
dedupeKey?: string | undefined;
|
|
14
|
+
maxAttempts?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function enqueue(client: DbClient, opts: EnqueueOpts): Promise<void> {
|
|
18
|
+
const runAt = opts.runAtMs !== undefined ? new Date(opts.runAtMs).toISOString() : new Date().toISOString();
|
|
19
|
+
if (opts.dedupeKey !== undefined) {
|
|
20
|
+
await client.query(
|
|
21
|
+
`INSERT INTO queue_jobs (kind, payload, run_at, priority, tag, dedupe_key, max_attempts)
|
|
22
|
+
VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7)
|
|
23
|
+
ON CONFLICT (dedupe_key) WHERE dedupe_key IS NOT NULL DO UPDATE
|
|
24
|
+
SET run_at = LEAST(queue_jobs.run_at, EXCLUDED.run_at),
|
|
25
|
+
status = 'ready',
|
|
26
|
+
payload = EXCLUDED.payload`,
|
|
27
|
+
[
|
|
28
|
+
opts.kind,
|
|
29
|
+
JSON.stringify(opts.payload),
|
|
30
|
+
runAt,
|
|
31
|
+
opts.priority ?? 0,
|
|
32
|
+
opts.tag ?? null,
|
|
33
|
+
opts.dedupeKey,
|
|
34
|
+
opts.maxAttempts ?? 10,
|
|
35
|
+
],
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
await client.query(
|
|
39
|
+
`INSERT INTO queue_jobs (kind, payload, run_at, priority, tag, max_attempts)
|
|
40
|
+
VALUES ($1, $2::jsonb, $3, $4, $5, $6)`,
|
|
41
|
+
[opts.kind, JSON.stringify(opts.payload), runAt, opts.priority ?? 0, opts.tag ?? null, opts.maxAttempts ?? 10],
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ClaimedJob {
|
|
47
|
+
id: string;
|
|
48
|
+
kind: string;
|
|
49
|
+
payload: Record<string, unknown>;
|
|
50
|
+
attempts: number;
|
|
51
|
+
maxAttempts: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LEASE_SECONDS = 60;
|
|
55
|
+
|
|
56
|
+
/** Claim one due job (tag/kind-routed when filters are given). Expired leases are reclaimable. */
|
|
57
|
+
export async function claim(
|
|
58
|
+
db: Db,
|
|
59
|
+
tags?: string[] | undefined,
|
|
60
|
+
kinds?: string[] | undefined,
|
|
61
|
+
): Promise<ClaimedJob | null> {
|
|
62
|
+
return db.tx(async (c) => {
|
|
63
|
+
const params: unknown[] = [];
|
|
64
|
+
const tagFilter = tags && tags.length > 0 ? `AND (tag IS NULL OR tag = ANY($${params.push(tags)}))` : "";
|
|
65
|
+
const kindFilter = kinds && kinds.length > 0 ? `AND kind = ANY($${params.push(kinds)})` : "";
|
|
66
|
+
const { rows } = await c.query<{
|
|
67
|
+
id: string;
|
|
68
|
+
kind: string;
|
|
69
|
+
payload: Record<string, unknown>;
|
|
70
|
+
attempts: number;
|
|
71
|
+
max_attempts: number;
|
|
72
|
+
}>(
|
|
73
|
+
`SELECT id, kind, payload, attempts, max_attempts FROM queue_jobs
|
|
74
|
+
WHERE status = 'ready'
|
|
75
|
+
AND run_at <= now()
|
|
76
|
+
AND (lease_until IS NULL OR lease_until < now())
|
|
77
|
+
${tagFilter}
|
|
78
|
+
${kindFilter}
|
|
79
|
+
ORDER BY priority DESC, run_at
|
|
80
|
+
FOR UPDATE SKIP LOCKED
|
|
81
|
+
LIMIT 1`,
|
|
82
|
+
params,
|
|
83
|
+
);
|
|
84
|
+
const job = rows[0];
|
|
85
|
+
if (!job) return null;
|
|
86
|
+
// dedupe_key is cleared on claim: a suspension that re-arms the same logical wake
|
|
87
|
+
// must insert a FRESH row, not collide with the job currently being executed.
|
|
88
|
+
await c.query(
|
|
89
|
+
`UPDATE queue_jobs
|
|
90
|
+
SET lease_until = now() + interval '${LEASE_SECONDS} seconds',
|
|
91
|
+
attempts = attempts + 1,
|
|
92
|
+
dedupe_key = NULL
|
|
93
|
+
WHERE id = $1`,
|
|
94
|
+
[job.id],
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
id: job.id,
|
|
98
|
+
kind: job.kind,
|
|
99
|
+
payload: job.payload,
|
|
100
|
+
attempts: job.attempts + 1,
|
|
101
|
+
maxAttempts: job.max_attempts,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function heartbeat(db: Db, jobId: string): Promise<void> {
|
|
107
|
+
await db.query(
|
|
108
|
+
`UPDATE queue_jobs SET lease_until = now() + interval '${LEASE_SECONDS} seconds' WHERE id = $1`,
|
|
109
|
+
[jobId],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function complete(db: Db, jobId: string): Promise<void> {
|
|
114
|
+
await db.query(`DELETE FROM queue_jobs WHERE id = $1`, [jobId]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Release after failure: retry with backoff until attempts exhaust → dead. */
|
|
118
|
+
export async function fail(db: Db, job: ClaimedJob, error: string): Promise<void> {
|
|
119
|
+
if (job.attempts >= job.maxAttempts) {
|
|
120
|
+
await db.query(
|
|
121
|
+
`UPDATE queue_jobs SET status = 'dead', lease_until = NULL, last_error = $2 WHERE id = $1`,
|
|
122
|
+
[job.id, error.slice(0, 2000)],
|
|
123
|
+
);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const delaySeconds = Math.min(60 * 15, 2 ** job.attempts);
|
|
127
|
+
await db.query(
|
|
128
|
+
`UPDATE queue_jobs
|
|
129
|
+
SET lease_until = NULL, last_error = $2, run_at = now() + ($3 || ' seconds')::interval
|
|
130
|
+
WHERE id = $1`,
|
|
131
|
+
[job.id, error.slice(0, 2000), String(delaySeconds)],
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The worker loop: long-poll claim → dispatch by job kind → complete/fail. Leases are
|
|
2
|
+
// heartbeat-extended; a crashed worker's jobs are reclaimed on lease expiry (the
|
|
3
|
+
// journal-of-results executor makes re-execution safe).
|
|
4
|
+
|
|
5
|
+
import type { Db } from "../db/db.js";
|
|
6
|
+
import { claim, complete, fail, heartbeat, type ClaimedJob } from "./queue.js";
|
|
7
|
+
|
|
8
|
+
export type JobHandler = (job: ClaimedJob) => Promise<void>;
|
|
9
|
+
|
|
10
|
+
export interface WorkerOpts {
|
|
11
|
+
tags?: string[] | undefined;
|
|
12
|
+
/** Job kinds this worker duty is allowed to claim. Omitted means all kinds. */
|
|
13
|
+
kinds?: string[] | undefined;
|
|
14
|
+
pollIntervalMs?: number;
|
|
15
|
+
onError?: (err: unknown, job?: ClaimedJob) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Worker {
|
|
19
|
+
private running = false;
|
|
20
|
+
private loopPromise: Promise<void> | null = null;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly db: Db,
|
|
24
|
+
private readonly handlers: Record<string, JobHandler>,
|
|
25
|
+
private readonly opts: WorkerOpts = {},
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
start(): void {
|
|
29
|
+
if (this.running) return;
|
|
30
|
+
this.running = true;
|
|
31
|
+
this.loopPromise = this.loop();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async stop(): Promise<void> {
|
|
35
|
+
this.running = false;
|
|
36
|
+
await this.loopPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Process every due job right now (test + reconcile-now convenience). */
|
|
40
|
+
async drain(maxJobs = 1000): Promise<number> {
|
|
41
|
+
let n = 0;
|
|
42
|
+
while (n < maxJobs) {
|
|
43
|
+
const job = await claim(this.db, this.opts.tags, this.opts.kinds);
|
|
44
|
+
if (!job) break;
|
|
45
|
+
await this.dispatch(job);
|
|
46
|
+
n++;
|
|
47
|
+
}
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async loop(): Promise<void> {
|
|
52
|
+
const pollMs = this.opts.pollIntervalMs ?? 250;
|
|
53
|
+
while (this.running) {
|
|
54
|
+
let job: ClaimedJob | null = null;
|
|
55
|
+
try {
|
|
56
|
+
job = await claim(this.db, this.opts.tags, this.opts.kinds);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
this.opts.onError?.(err);
|
|
59
|
+
}
|
|
60
|
+
if (!job) {
|
|
61
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
await this.dispatch(job);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async dispatch(job: ClaimedJob): Promise<void> {
|
|
69
|
+
const handler = this.handlers[job.kind];
|
|
70
|
+
const beat = setInterval(() => {
|
|
71
|
+
void heartbeat(this.db, job.id).catch(() => {});
|
|
72
|
+
}, 20_000);
|
|
73
|
+
beat.unref?.();
|
|
74
|
+
try {
|
|
75
|
+
if (!handler) throw new Error(`no handler for job kind "${job.kind}"`);
|
|
76
|
+
await handler(job);
|
|
77
|
+
await complete(this.db, job.id);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
this.opts.onError?.(err, job);
|
|
80
|
+
await fail(this.db, job, (err as Error)?.stack ?? String(err)).catch(() => {});
|
|
81
|
+
} finally {
|
|
82
|
+
clearInterval(beat);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|