Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitabfa9adunknown_key

feat(workflow-engine-v2): partial sprint 1 landing — schema, secrets, matrix/if, actions, artifacts, live logs

feat(workflow-engine-v2): partial sprint 1 landing — schema, secrets, matrix/if, actions, artifacts, live logs

Sprint 1 of the self-driving-repository roadmap. Parallel agent fanout;
7 of 10 landed so far — committing current progress per stop-hook.

Schema (Agent 1): drizzle/0037_workflow_engine_v2.sql adds
workflow_secrets, workflow_dispatch_inputs, workflow_run_cache,
workflow_runner_pool. Additive — existing 0008 shipped schema untouched.

Secrets (Agent 2): AES-256-GCM envelope encryption, substituteSecrets
for ${{ secrets.NAME }} expansion, repo-scoped CRUD. No plaintext
ever logged.

Matrix + conditionals (Agent 4): pure-function expandMatrix with
include/exclude, if-expression evaluator (recursive-descent parser,
restricted grammar, success/failure/always/cancelled, contains,
startsWith/endsWith, format).

Action registry (Agent 8): gluecron/{checkout,gatetest,cache,
upload-artifact,download-artifact}@v1. resolveAction(uses) called
by runner for steps with uses:.

Artifacts (Agent 6): REST API at /api/v1/runs/:id/artifacts +
helper lib for in-process use by upload/download actions.

Secrets UI (Agent 7): /:owner/:repo/settings/secrets — admin-gated,
write-only values, audit-logged create/delete.

Live log streaming (Agent 9): liveLogTailScript + LogTail component,
wired into run-detail. Subscribes to workflow-run-\${runId} SSE
topic and renders step-log chunks with auto-scroll.

Tests (Agent 10 partial): matrix + secrets-crypto pass suites.
Conditionals/parser-ext/registry tests pending.

Still in flight: Agent 3 (parser-ext), Agent 5 (runner v2 — stream
timed out), remaining tests. Will ship in follow-up commit.

App.tsx wiring and full integration test deferred to follow-up.
Claude committed on April 21, 2026Parent: 04f6b7f
20 files changed+35217abfa9adbbf5b7a7c7dbaa9ff7384d2908f9a3cb2
20 changed files+3521−7
Addeddrizzle/0037_workflow_engine_v2.sql+92−0View fileUnifiedSplit
1-- Workflow engine v2 — Sprint 1 storage additions.
2--
3-- Strictly additive to Block C1 (drizzle/0008_workflows.sql is LOCKED).
4-- The four tables below back new capabilities:
5--
6-- workflow_secrets encrypted per-repo secrets (AES-256-GCM, base64
7-- payload = iv || authTag || ciphertext). The
8-- crypto lib lives in src/lib/workflow-crypto.ts;
9-- the DB only stores opaque bytes.
10-- workflow_dispatch_inputs parameter schema for the `workflow_dispatch`
11-- trigger — one row per input on a workflow.
12-- workflow_run_cache content-addressable cache, keyed by user-chosen
13-- cache_key within a scope (repo / branch / tag).
14-- Backs the `gluecron/cache@v1` action.
15-- workflow_runner_pool warm-runner worker registry used by the job
16-- scheduler to avoid cold-start per run.
17--
18-- `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` throughout so
19-- reruns are idempotent. Size/format validation (secret name regex, 100MB
20-- cache cap) is enforced at the write-site, not in the DB.
21
22--> statement-breakpoint
23CREATE TABLE IF NOT EXISTS "workflow_secrets" (
24 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
25 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
26 "name" text NOT NULL,
27 "encrypted_value" text NOT NULL,
28 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
29 "created_at" timestamptz NOT NULL DEFAULT now(),
30 "updated_at" timestamptz NOT NULL DEFAULT now()
31);
32
33--> statement-breakpoint
34CREATE UNIQUE INDEX IF NOT EXISTS "workflow_secrets_repo_name_uq"
35 ON "workflow_secrets" ("repository_id", "name");
36
37--> statement-breakpoint
38CREATE INDEX IF NOT EXISTS "workflow_secrets_repo_idx"
39 ON "workflow_secrets" ("repository_id");
40
41--> statement-breakpoint
42CREATE TABLE IF NOT EXISTS "workflow_dispatch_inputs" (
43 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
44 "workflow_id" uuid NOT NULL REFERENCES "workflows"("id") ON DELETE CASCADE,
45 "name" text NOT NULL,
46 "type" text NOT NULL CHECK (type IN ('string', 'boolean', 'choice', 'number')),
47 "required" boolean NOT NULL DEFAULT false,
48 "default_value" text,
49 "options" jsonb,
50 "description" text
51);
52
53--> statement-breakpoint
54CREATE UNIQUE INDEX IF NOT EXISTS "workflow_dispatch_inputs_wf_name_uq"
55 ON "workflow_dispatch_inputs" ("workflow_id", "name");
56
57--> statement-breakpoint
58CREATE TABLE IF NOT EXISTS "workflow_run_cache" (
59 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
60 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
61 "cache_key" text NOT NULL,
62 "scope" text NOT NULL DEFAULT 'repo',
63 "scope_ref" text,
64 "content_hash" text NOT NULL,
65 "content" bytea NOT NULL,
66 "size_bytes" bigint NOT NULL,
67 "created_at" timestamptz NOT NULL DEFAULT now(),
68 "last_accessed_at" timestamptz NOT NULL DEFAULT now()
69);
70
71--> statement-breakpoint
72CREATE UNIQUE INDEX IF NOT EXISTS "workflow_run_cache_repo_key_scope_uq"
73 ON "workflow_run_cache" ("repository_id", "cache_key", "scope", "scope_ref");
74
75--> statement-breakpoint
76CREATE INDEX IF NOT EXISTS "workflow_run_cache_repo_lru_idx"
77 ON "workflow_run_cache" ("repository_id", "last_accessed_at");
78
79--> statement-breakpoint
80CREATE TABLE IF NOT EXISTS "workflow_runner_pool" (
81 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
82 "worker_id" text NOT NULL UNIQUE,
83 "status" text NOT NULL CHECK (status IN ('idle', 'busy', 'draining', 'dead')),
84 "current_run_id" uuid REFERENCES "workflow_runs"("id") ON DELETE SET NULL,
85 "warmed_at" timestamptz NOT NULL DEFAULT now(),
86 "last_heartbeat_at" timestamptz NOT NULL DEFAULT now(),
87 "capacity" integer NOT NULL DEFAULT 1
88);
89
90--> statement-breakpoint
91CREATE INDEX IF NOT EXISTS "workflow_runner_pool_status_idx"
92 ON "workflow_runner_pool" ("status");
Addedsrc/__tests__/workflow-matrix.test.ts+109−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/workflow-matrix.ts (Agent 4, Sprint 1).
3 *
4 * Pure-function coverage: cartesian expansion, include/exclude semantics,
5 * validator guardrails. No DB, no I/O — just data transformations.
6 */
7
8import { describe, it, expect } from "bun:test";
9import { expandMatrix, validateMatrix } from "../lib/workflow-matrix";
10
11describe("workflow-matrix — expandMatrix", () => {
12 it("empty axes {} with no include returns []", () => {
13 const combos = expandMatrix({ axes: {} });
14 expect(combos).toEqual([]);
15 });
16
17 it("single axis expands to one combo per value in alpha-key order", () => {
18 const combos = expandMatrix({ axes: { os: ["a", "b", "c"] } });
19 expect(combos).toHaveLength(3);
20 expect(combos[0]).toEqual({ os: "a" });
21 expect(combos[1]).toEqual({ os: "b" });
22 expect(combos[2]).toEqual({ os: "c" });
23 });
24
25 it("two axes produce the cartesian product with both keys", () => {
26 const combos = expandMatrix({
27 axes: { os: ["ubuntu", "mac"], node: [16, 18] },
28 });
29 expect(combos).toHaveLength(4);
30 // Every combo must contain both keys.
31 for (const c of combos) {
32 expect(Object.keys(c).sort()).toEqual(["node", "os"]);
33 }
34 // Verify all four combinations are present.
35 const serialized = combos.map((c) => JSON.stringify(c)).sort();
36 expect(serialized).toEqual(
37 [
38 { node: 16, os: "ubuntu" },
39 { node: 16, os: "mac" },
40 { node: 18, os: "ubuntu" },
41 { node: 18, os: "mac" },
42 ]
43 .map((c) => JSON.stringify(c))
44 .sort()
45 );
46 });
47
48 it("exclude removes matching combos", () => {
49 const combos = expandMatrix({
50 axes: { os: ["a", "b"], node: [16, 18] },
51 exclude: [{ os: "a", node: 16 }],
52 });
53 expect(combos).toHaveLength(3);
54 expect(combos.find((c) => c.os === "a" && c.node === 16)).toBeUndefined();
55 });
56
57 it("include adds a standalone combo when it does not match any cartesian entry", () => {
58 const combos = expandMatrix({
59 axes: { os: ["a"] },
60 include: [{ os: "windows", extra: "bonus" }],
61 });
62 // One from the axes + one standalone include.
63 expect(combos).toHaveLength(2);
64 expect(combos.find((c) => c.os === "windows" && c.extra === "bonus")).toBeDefined();
65 });
66
67 it("include extends an existing combo with extra keys when axis keys match", () => {
68 const combos = expandMatrix({
69 axes: { os: ["a", "b"] },
70 include: [{ os: "a", env: "prod" }],
71 });
72 expect(combos).toHaveLength(2);
73 const aCombo = combos.find((c) => c.os === "a");
74 const bCombo = combos.find((c) => c.os === "b");
75 expect(aCombo).toEqual({ os: "a", env: "prod" });
76 expect(bCombo).toEqual({ os: "b" });
77 });
78
79 it("empty axis value [] yields no combos", () => {
80 const combos = expandMatrix({ axes: { os: [] } });
81 expect(combos).toEqual([]);
82 });
83
84 it("validateMatrix rejects non-object input and non-array axis values", () => {
85 expect(validateMatrix(null).ok).toBe(false);
86 expect(validateMatrix(undefined).ok).toBe(false);
87 expect(validateMatrix("not an object").ok).toBe(false);
88 expect(validateMatrix([]).ok).toBe(false);
89 const badAxis = validateMatrix({ axes: { os: "not-an-array" } });
90 expect(badAxis.ok).toBe(false);
91 if (!badAxis.ok) expect(badAxis.error).toMatch(/array/i);
92 });
93
94 it("validateMatrix accepts a well-formed spec", () => {
95 const good = validateMatrix({
96 axes: { os: ["a", "b"] },
97 include: [{ os: "a", env: "x" }],
98 exclude: [{ os: "b" }],
99 failFast: true,
100 maxParallel: 4,
101 });
102 expect(good.ok).toBe(true);
103 if (good.ok) {
104 expect(good.spec.axes.os).toEqual(["a", "b"]);
105 expect(good.spec.failFast).toBe(true);
106 expect(good.spec.maxParallel).toBe(4);
107 }
108 });
109});
Addedsrc/__tests__/workflow-secrets-crypto.test.ts+136−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/workflow-secrets-crypto.ts (Agent 2, Sprint 1).
3 *
4 * Pure-function coverage: crypto roundtrip, IV randomisation, tamper
5 * detection, and `${{ secrets.X }}` template substitution rules.
6 *
7 * Env management: each test may mutate WORKFLOW_SECRETS_KEY, so we snapshot
8 * the original value once and restore it in afterEach. The module reads
9 * `process.env.WORKFLOW_SECRETS_KEY` at call time (see `getMasterKey`), so
10 * no module-cache juggling is required.
11 */
12
13import { describe, it, expect, afterEach, afterAll } from "bun:test";
14import {
15 encryptSecret,
16 decryptSecret,
17 substituteSecrets,
18 getMasterKey,
19} from "../lib/workflow-secrets-crypto";
20
21const TEST_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
22const originalKey = process.env.WORKFLOW_SECRETS_KEY;
23
24function restoreKey() {
25 if (originalKey === undefined) delete process.env.WORKFLOW_SECRETS_KEY;
26 else process.env.WORKFLOW_SECRETS_KEY = originalKey;
27}
28
29afterEach(() => {
30 restoreKey();
31});
32
33afterAll(() => {
34 restoreKey();
35});
36
37describe("workflow-secrets-crypto — encryptSecret / decryptSecret", () => {
38 it("returns ok:false when WORKFLOW_SECRETS_KEY is unset", () => {
39 delete process.env.WORKFLOW_SECRETS_KEY;
40 expect(getMasterKey()).toBeNull();
41 const r = encryptSecret("hello");
42 expect(r.ok).toBe(false);
43 if (!r.ok) expect(r.error).toMatch(/WORKFLOW_SECRETS_KEY/);
44 });
45
46 it("encrypt -> decrypt roundtrip yields the original plaintext", () => {
47 process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
48 const enc = encryptSecret("s3cret-value with spaces & symbols !@#$%");
49 expect(enc.ok).toBe(true);
50 if (!enc.ok) return;
51 expect(typeof enc.ciphertext).toBe("string");
52 expect(enc.ciphertext.length).toBeGreaterThan(0);
53
54 const dec = decryptSecret(enc.ciphertext);
55 expect(dec.ok).toBe(true);
56 if (!dec.ok) return;
57 expect(dec.plaintext).toBe("s3cret-value with spaces & symbols !@#$%");
58 });
59
60 it("produces different ciphertexts on repeat encryption (IV randomisation)", () => {
61 process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
62 const a = encryptSecret("same-input");
63 const b = encryptSecret("same-input");
64 expect(a.ok).toBe(true);
65 expect(b.ok).toBe(true);
66 if (!a.ok || !b.ok) return;
67 expect(a.ciphertext).not.toBe(b.ciphertext);
68 // Both must still round-trip to the same plaintext.
69 const da = decryptSecret(a.ciphertext);
70 const db = decryptSecret(b.ciphertext);
71 expect(da.ok && db.ok).toBe(true);
72 if (da.ok) expect(da.plaintext).toBe("same-input");
73 if (db.ok) expect(db.plaintext).toBe("same-input");
74 });
75
76 it("decryptSecret rejects a tampered ciphertext with ok:false", () => {
77 process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
78 const enc = encryptSecret("tamper-me");
79 expect(enc.ok).toBe(true);
80 if (!enc.ok) return;
81 // Flip the last character (which lives in the ciphertext body) to
82 // invalidate the GCM auth tag / ciphertext pair.
83 const ct = enc.ciphertext;
84 const flipped = ct.slice(0, -2) + (ct.slice(-2) === "AA" ? "BB" : "AA");
85 const dec = decryptSecret(flipped);
86 expect(dec.ok).toBe(false);
87 });
88
89 it("decryptSecret rejects a truncated blob", () => {
90 process.env.WORKFLOW_SECRETS_KEY = TEST_KEY;
91 const enc = encryptSecret("xyz");
92 expect(enc.ok).toBe(true);
93 if (!enc.ok) return;
94 // Cut it down to only the first few base64 bytes — well below IV+tag.
95 const truncated = enc.ciphertext.slice(0, 4);
96 const dec = decryptSecret(truncated);
97 expect(dec.ok).toBe(false);
98 });
99});
100
101describe("workflow-secrets-crypto — substituteSecrets", () => {
102 it("replaces a simple ${{ secrets.FOO }} token with the value", () => {
103 const out = substituteSecrets("curl -H auth:${{ secrets.TOKEN }}", {
104 TOKEN: "abc",
105 });
106 expect(out).toBe("curl -H auth:abc");
107 });
108
109 it("tolerates flexible whitespace inside the token", () => {
110 const out1 = substituteSecrets("${{secrets.FOO}}", { FOO: "1" });
111 const out2 = substituteSecrets("${{ secrets.FOO }}", { FOO: "1" });
112 const out3 = substituteSecrets("${{\tsecrets.FOO\t}}", { FOO: "1" });
113 expect(out1).toBe("1");
114 expect(out2).toBe("1");
115 expect(out3).toBe("1");
116 });
117
118 it("leaves unknown secret names untouched", () => {
119 const out = substituteSecrets(
120 "have=${{ secrets.KNOWN }} miss=${{ secrets.UNKNOWN }}",
121 { KNOWN: "yes" },
122 );
123 expect(out).toBe("have=yes miss=${{ secrets.UNKNOWN }}");
124 });
125
126 it("honours $${{ secrets.X }} as a literal escape", () => {
127 const out = substituteSecrets("$${{ secrets.FOO }}", { FOO: "value" });
128 expect(out).toBe("${{ secrets.FOO }}");
129 // And mixing: one literal + one substituted.
130 const mixed = substituteSecrets(
131 "lit=$${{ secrets.A }} sub=${{ secrets.A }}",
132 { A: "X" },
133 );
134 expect(mixed).toBe("lit=${{ secrets.A }} sub=X");
135 });
136});
Modifiedsrc/db/schema.ts+170−0View fileUnifiedSplit
88 uniqueIndex,
99 index,
1010 serial,
11 bigint,
12 jsonb,
13 customType,
1114} from "drizzle-orm/pg-core";
1215
16// Postgres `bytea` — drizzle-orm doesn't ship a first-class bytea column, so
17// we declare one via customType that maps to Buffer on both read and write.
18// Used by `workflow_run_cache.content` where we really do need raw bytes
19// (unlike `workflow_artifacts.content`, which stayed base64-text for v1).
20const bytea = customType<{ data: Buffer; default: false }>({
21 dataType() {
22 return "bytea";
23 },
24});
25
1326export const users = pgTable("users", {
1427 id: uuid("id").primaryKey().defaultRandom(),
1528 username: text("username").notNull().unique(),
24052418);
24062419
24072420export type RepoCollaborator = typeof repoCollaborators.$inferSelect;
2421
2422// ---------------------------------------------------------------------------
2423// Workflow engine v2 — Sprint 1 additions (drizzle/0037_workflow_engine_v2.sql)
2424//
2425// Strictly additive to Block C1. The original workflow tables defined above
2426// (workflows / workflow_runs / workflow_jobs / workflow_artifacts) are locked
2427// and must not be altered in-place; the four tables below extend the runner
2428// with secrets, workflow_dispatch inputs, a content-addressable cache, and a
2429// warm-runner worker pool.
2430// ---------------------------------------------------------------------------
2431
2432/**
2433 * Per-repo encrypted secrets exposed to workflow jobs as env vars.
2434 *
2435 * `encrypted_value` is base64 of `iv || authTag || ciphertext` produced by
2436 * AES-256-GCM — the crypto lives in `src/lib/workflow-crypto.ts` (Agent 2);
2437 * the DB only stores opaque ciphertext. `name` is validated against
2438 * `[A-Z_][A-Z0-9_]*` at the write-site, not the DB. Unique per (repo, name).
2439 */
2440export const workflowSecrets = pgTable(
2441 "workflow_secrets",
2442 {
2443 id: uuid("id").primaryKey().defaultRandom(),
2444 repositoryId: uuid("repository_id")
2445 .notNull()
2446 .references(() => repositories.id, { onDelete: "cascade" }),
2447 name: text("name").notNull(),
2448 encryptedValue: text("encrypted_value").notNull(),
2449 createdBy: uuid("created_by").references(() => users.id, {
2450 onDelete: "set null",
2451 }),
2452 createdAt: timestamp("created_at").defaultNow().notNull(),
2453 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2454 },
2455 (table) => [
2456 uniqueIndex("workflow_secrets_repo_name_uq").on(
2457 table.repositoryId,
2458 table.name
2459 ),
2460 index("workflow_secrets_repo_idx").on(table.repositoryId),
2461 ]
2462);
2463
2464export type WorkflowSecret = typeof workflowSecrets.$inferSelect;
2465export type NewWorkflowSecret = typeof workflowSecrets.$inferInsert;
2466
2467/**
2468 * Parameter schema for the `workflow_dispatch` trigger. One row per input
2469 * declared in the workflow YAML. `type` is constrained to the four values
2470 * GitHub Actions supports; `options` is only meaningful for type='choice'
2471 * (a JSON array of allowed strings). `default_value` and `description` are
2472 * optional. Unique per (workflow, name) so two inputs on the same workflow
2473 * can't share an identifier.
2474 */
2475export const workflowDispatchInputs = pgTable(
2476 "workflow_dispatch_inputs",
2477 {
2478 id: uuid("id").primaryKey().defaultRandom(),
2479 workflowId: uuid("workflow_id")
2480 .notNull()
2481 .references(() => workflows.id, { onDelete: "cascade" }),
2482 name: text("name").notNull(),
2483 type: text("type", {
2484 enum: ["string", "boolean", "choice", "number"],
2485 }).notNull(),
2486 required: boolean("required").default(false).notNull(),
2487 defaultValue: text("default_value"),
2488 // JSON array of strings; only populated when type = 'choice'.
2489 options: jsonb("options"),
2490 description: text("description"),
2491 },
2492 (table) => [
2493 uniqueIndex("workflow_dispatch_inputs_wf_name_uq").on(
2494 table.workflowId,
2495 table.name
2496 ),
2497 ]
2498);
2499
2500export type WorkflowDispatchInput =
2501 typeof workflowDispatchInputs.$inferSelect;
2502export type NewWorkflowDispatchInput =
2503 typeof workflowDispatchInputs.$inferInsert;
2504
2505/**
2506 * Content-addressable cache backing the `gluecron/cache@v1` action. Rows are
2507 * keyed by user-chosen `cache_key` within a `scope` — 'repo' for repo-wide
2508 * entries, 'branch' or 'tag' when isolation is desired (with `scope_ref`
2509 * holding the branch/tag name). `content_hash` is the sha256 of `content`
2510 * so jobs can short-circuit redundant writes. `content` is real bytea; the
2511 * 100MB payload cap is enforced at the write-site, not by the DB. LRU
2512 * eviction reads (`repository_id`, `last_accessed_at`).
2513 */
2514export const workflowRunCache = pgTable(
2515 "workflow_run_cache",
2516 {
2517 id: uuid("id").primaryKey().defaultRandom(),
2518 repositoryId: uuid("repository_id")
2519 .notNull()
2520 .references(() => repositories.id, { onDelete: "cascade" }),
2521 cacheKey: text("cache_key").notNull(),
2522 // 'repo' | 'branch' | 'tag' — constraint enforced at the write-site.
2523 scope: text("scope").default("repo").notNull(),
2524 scopeRef: text("scope_ref"),
2525 contentHash: text("content_hash").notNull(),
2526 content: bytea("content").notNull(),
2527 sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
2528 createdAt: timestamp("created_at").defaultNow().notNull(),
2529 lastAccessedAt: timestamp("last_accessed_at").defaultNow().notNull(),
2530 },
2531 (table) => [
2532 uniqueIndex("workflow_run_cache_repo_key_scope_uq").on(
2533 table.repositoryId,
2534 table.cacheKey,
2535 table.scope,
2536 table.scopeRef
2537 ),
2538 index("workflow_run_cache_repo_lru_idx").on(
2539 table.repositoryId,
2540 table.lastAccessedAt
2541 ),
2542 ]
2543);
2544
2545export type WorkflowRunCache = typeof workflowRunCache.$inferSelect;
2546export type NewWorkflowRunCache = typeof workflowRunCache.$inferInsert;
2547
2548/**
2549 * Warm-runner worker registry. The scheduler reads idle rows to dispatch
2550 * queued jobs without paying cold-start cost; workers heartbeat here so
2551 * stale entries (>N seconds since `last_heartbeat_at`) can be reaped.
2552 * `worker_id` is a stable string (hostname:pid or similar) and is globally
2553 * unique so a restarted worker naturally replaces its predecessor.
2554 * `current_run_id` is set when status='busy' and cleared on completion.
2555 */
2556export const workflowRunnerPool = pgTable(
2557 "workflow_runner_pool",
2558 {
2559 id: uuid("id").primaryKey().defaultRandom(),
2560 workerId: text("worker_id").notNull().unique(),
2561 status: text("status", {
2562 enum: ["idle", "busy", "draining", "dead"],
2563 }).notNull(),
2564 currentRunId: uuid("current_run_id").references(() => workflowRuns.id, {
2565 onDelete: "set null",
2566 }),
2567 warmedAt: timestamp("warmed_at").defaultNow().notNull(),
2568 lastHeartbeatAt: timestamp("last_heartbeat_at").defaultNow().notNull(),
2569 capacity: integer("capacity").default(1).notNull(),
2570 },
2571 (table) => [index("workflow_runner_pool_status_idx").on(table.status)]
2572);
2573
2574export type WorkflowRunnerPoolEntry =
2575 typeof workflowRunnerPool.$inferSelect;
2576export type NewWorkflowRunnerPoolEntry =
2577 typeof workflowRunnerPool.$inferInsert;
Addedsrc/lib/action-registry.ts+126−0View fileUnifiedSplit
1/**
2 * Built-in action registry for workflow engine v2 (Block C1 / Sprint 1 — Agent 8).
3 *
4 * Workflow YAML steps of the form `uses: gluecron/<name>@<version>` are
5 * resolved here. The registry is in-memory and populated eagerly at module
6 * load by calling `registerAll()` once. Re-registering is idempotent so
7 * repeated imports (e.g. in tests) do not error.
8 *
9 * The runner (Agent 5) is the only expected caller of `resolveAction`. It
10 * constructs an `ActionContext` from the running job's state and awaits
11 * `handler.run(ctx)`. Handlers MUST NOT throw — every built-in wraps its
12 * body in try/catch and returns `{ exitCode: 1, stderr }` on failure so the
13 * runner's control flow stays predictable.
14 */
15
16export type ActionContext = {
17 with: Record<string, unknown>;
18 env: Record<string, string>;
19 workspace: string; // absolute path to checked-out code
20 runId: string;
21 jobId: string;
22 repoId: string;
23 commitSha?: string | null;
24 ref?: string | null;
25};
26
27export type ActionResult = {
28 exitCode: number; // 0 = success
29 outputs?: Record<string, string>;
30 stdout?: string;
31 stderr?: string;
32};
33
34export type ActionHandler = {
35 name: string; // e.g. 'gluecron/gatetest'
36 version: string; // e.g. 'v1'
37 run: (ctx: ActionContext) => Promise<ActionResult>;
38};
39
40// Keyed by `${name}@${version}`. A secondary "latest" pointer per name is
41// maintained so `uses: gluecron/foo` (no version) still resolves.
42const handlers = new Map<string, ActionHandler>();
43const latestByName = new Map<string, string>(); // name -> version
44
45export function registerAction(handler: ActionHandler): void {
46 if (!handler || !handler.name || !handler.version) return;
47 const key = `${handler.name}@${handler.version}`;
48 handlers.set(key, handler);
49 // Simple latest-wins policy: last registration for a given name becomes
50 // the default. Built-ins register in deterministic order (see registerAll).
51 latestByName.set(handler.name, handler.version);
52}
53
54/**
55 * Parse `uses` into (name, version). `version` defaults to `v1` when
56 * omitted. Trailing whitespace tolerated; empty input yields nulls.
57 */
58function parseUses(uses: string): { name: string; version: string | null } | null {
59 if (!uses || typeof uses !== "string") return null;
60 const trimmed = uses.trim();
61 if (!trimmed) return null;
62 const at = trimmed.lastIndexOf("@");
63 if (at === -1) {
64 return { name: trimmed, version: null };
65 }
66 const name = trimmed.slice(0, at).trim();
67 const version = trimmed.slice(at + 1).trim();
68 if (!name) return null;
69 return { name, version: version || null };
70}
71
72export function resolveAction(uses: string): ActionHandler | null {
73 const parsed = parseUses(uses);
74 if (!parsed) return null;
75
76 // Explicit version first.
77 if (parsed.version) {
78 const key = `${parsed.name}@${parsed.version}`;
79 return handlers.get(key) ?? null;
80 }
81
82 // No version: try the latest registered, fall back to v1.
83 const latest = latestByName.get(parsed.name);
84 if (latest) {
85 const key = `${parsed.name}@${latest}`;
86 const h = handlers.get(key);
87 if (h) return h;
88 }
89 return handlers.get(`${parsed.name}@v1`) ?? null;
90}
91
92export function listActions(): { name: string; version: string }[] {
93 return Array.from(handlers.values()).map((h) => ({
94 name: h.name,
95 version: h.version,
96 }));
97}
98
99// -------------------------------------------------------------------------
100// Built-in registration
101// -------------------------------------------------------------------------
102
103import { checkoutAction } from "./actions/checkout-action";
104import { gatetestAction } from "./actions/gatetest-action";
105import { cacheAction } from "./actions/cache-action";
106import { uploadArtifactAction } from "./actions/upload-artifact-action";
107import { downloadArtifactAction } from "./actions/download-artifact-action";
108
109let registered = false;
110
111/**
112 * Register every built-in action. Idempotent: safe to call multiple times.
113 * Called once at module load below, but exported for tests that want to
114 * reset state explicitly.
115 */
116export function registerAll(): void {
117 if (registered) return;
118 registerAction(checkoutAction);
119 registerAction(gatetestAction);
120 registerAction(cacheAction);
121 registerAction(uploadArtifactAction);
122 registerAction(downloadArtifactAction);
123 registered = true;
124}
125
126registerAll();
Addedsrc/lib/actions/cache-action.ts+227−0View fileUnifiedSplit
1/**
2 * `gluecron/cache@v1` — RESTORE-only cache action (v1 scope).
3 *
4 * Looks up `workflow_run_cache` by (repoId, key, scope='repo') and unpacks
5 * the stored tar archive into `ctx.workspace/<path>`. If no exact key hit,
6 * tries each `restoreKeys` entry as a prefix match ordered by most-recently
7 * used. Sets `cache-hit` output to 'true' or 'false'.
8 *
9 * TODO (v2): cache SAVE on job success. The deferred design is for the
10 * runner to honor a `save-cache: true` flag emitted by this action and
11 * call a `saveCache(ctx, key, path)` helper at end-of-job. Implementing
12 * save inline here is error-prone (we'd need a post-hook) and the spec
13 * explicitly endorsed shipping restore-only for v1. Size cap logic is
14 * stubbed below so it's trivial to wire up later.
15 *
16 * Failure tolerance: any error — DB miss, tar failure, unknown scope —
17 * results in `cache-hit: false` with exitCode 0. Caching is an optimization;
18 * losing it must never break a pipeline.
19 */
20
21import { and, eq, isNull, sql } from "drizzle-orm";
22import { mkdir } from "fs/promises";
23import { join } from "path";
24import { tmpdir } from "os";
25import type { ActionHandler, ActionContext } from "../action-registry";
26import { db } from "../../db";
27import { workflowRunCache } from "../../db/schema";
28
29// 100MB cap — reserved for the eventual save path. Kept here so both
30// halves of the action share the constant when v2 lands.
31export const MAX_CACHE_BYTES = 100 * 1024 * 1024;
32
33function parseInputs(ctx: ActionContext): {
34 key: string;
35 path: string;
36 restoreKeys: string[];
37} | null {
38 const w = ctx.with || {};
39 const key = typeof w.key === "string" ? w.key : "";
40 const path = typeof w.path === "string" ? w.path : "";
41 const restoreKeysRaw = w.restoreKeys ?? w["restore-keys"];
42 const restoreKeys = Array.isArray(restoreKeysRaw)
43 ? restoreKeysRaw.filter((k): k is string => typeof k === "string")
44 : [];
45 if (!key || !path) return null;
46 return { key, path, restoreKeys };
47}
48
49/**
50 * Find a cache row for this repo matching either the exact key or any
51 * of the prefix restoreKeys. Returns the first hit (prefix matches are
52 * ordered by `last_accessed_at DESC`).
53 */
54async function lookupCache(
55 repoId: string,
56 key: string,
57 restoreKeys: string[]
58): Promise<
59 | { hit: true; id: string; content: Buffer; matchedKey: string; exact: boolean }
60 | { hit: false }
61> {
62 // Exact match first.
63 const exact = await db
64 .select()
65 .from(workflowRunCache)
66 .where(
67 and(
68 eq(workflowRunCache.repositoryId, repoId),
69 eq(workflowRunCache.cacheKey, key),
70 eq(workflowRunCache.scope, "repo"),
71 isNull(workflowRunCache.scopeRef)
72 )
73 )
74 .limit(1);
75 const exactRow = exact[0];
76 if (exactRow) {
77 return {
78 hit: true,
79 id: exactRow.id,
80 content: normalizeBytea(exactRow.content),
81 matchedKey: exactRow.cacheKey,
82 exact: true,
83 };
84 }
85
86 // Prefix fallbacks (LRU order).
87 for (const prefix of restoreKeys) {
88 if (!prefix) continue;
89 const rows = await db
90 .select()
91 .from(workflowRunCache)
92 .where(
93 and(
94 eq(workflowRunCache.repositoryId, repoId),
95 eq(workflowRunCache.scope, "repo"),
96 isNull(workflowRunCache.scopeRef),
97 sql`${workflowRunCache.cacheKey} LIKE ${prefix + "%"}`
98 )
99 )
100 .orderBy(sql`${workflowRunCache.lastAccessedAt} DESC`)
101 .limit(1);
102 const row = rows[0];
103 if (row) {
104 return {
105 hit: true,
106 id: row.id,
107 content: normalizeBytea(row.content),
108 matchedKey: row.cacheKey,
109 exact: false,
110 };
111 }
112 }
113
114 return { hit: false };
115}
116
117/**
118 * `content` arrives as a Buffer, Uint8Array, or (if the driver ran through
119 * text serialization) a base64/hex string. Normalize to Buffer.
120 */
121function normalizeBytea(raw: unknown): Buffer {
122 if (Buffer.isBuffer(raw)) return raw;
123 if (raw instanceof Uint8Array) return Buffer.from(raw);
124 if (typeof raw === "string") {
125 // Postgres `bytea` text encoding can be `\x…` hex. Handle defensively.
126 if (raw.startsWith("\\x")) return Buffer.from(raw.slice(2), "hex");
127 // Otherwise assume base64 (matches workflow-artifacts convention).
128 try {
129 return Buffer.from(raw, "base64");
130 } catch {
131 return Buffer.alloc(0);
132 }
133 }
134 return Buffer.alloc(0);
135}
136
137async function unpackTar(content: Buffer, destDir: string): Promise<void> {
138 await mkdir(destDir, { recursive: true });
139 // Write the archive to a tmp file then untar. Piping through stdin works
140 // but a tmp file avoids subtle Bun subprocess stdin EAGAIN edge cases.
141 const tmpPath = join(
142 tmpdir(),
143 `gluecron-cache-${Date.now()}-${Math.random().toString(36).slice(2)}.tar`
144 );
145 await Bun.write(tmpPath, content);
146 try {
147 const proc = Bun.spawn(["tar", "-xf", tmpPath, "-C", destDir], {
148 stdout: "pipe",
149 stderr: "pipe",
150 });
151 await proc.exited;
152 } finally {
153 try {
154 await Bun.file(tmpPath).exists();
155 // Best-effort cleanup; ignore errors.
156 await import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => {});
157 } catch {
158 /* noop */
159 }
160 }
161}
162
163async function touchLastAccessed(id: string): Promise<void> {
164 try {
165 await db
166 .update(workflowRunCache)
167 .set({ lastAccessedAt: new Date() })
168 .where(eq(workflowRunCache.id, id));
169 } catch {
170 // Non-fatal — LRU accuracy is best-effort.
171 }
172}
173
174export const cacheAction: ActionHandler = {
175 name: "gluecron/cache",
176 version: "v1",
177 async run(ctx): Promise<import("../action-registry").ActionResult> {
178 // Every failure path below returns exitCode 0 with cache-hit=false so
179 // the pipeline keeps flowing. This is load-bearing behaviour.
180 try {
181 const inputs = parseInputs(ctx);
182 if (!inputs) {
183 return {
184 exitCode: 0,
185 outputs: { "cache-hit": "false" },
186 stderr: "cache: missing required inputs `key` and `path` — skipping",
187 };
188 }
189
190 const result = await lookupCache(
191 ctx.repoId,
192 inputs.key,
193 inputs.restoreKeys
194 );
195
196 if (!result.hit) {
197 return {
198 exitCode: 0,
199 outputs: { "cache-hit": "false" },
200 stdout: `cache miss for key=${inputs.key}`,
201 };
202 }
203
204 const dest = join(ctx.workspace, inputs.path);
205 await unpackTar(result.content, dest);
206 await touchLastAccessed(result.id);
207
208 return {
209 exitCode: 0,
210 outputs: {
211 "cache-hit": result.exact ? "true" : "false",
212 "matched-key": result.matchedKey,
213 },
214 stdout: `cache ${result.exact ? "hit" : "partial hit"} for key=${inputs.key} (matched=${result.matchedKey})`,
215 };
216 } catch (err) {
217 // Fail-open: swallow and report as miss.
218 return {
219 exitCode: 0,
220 outputs: { "cache-hit": "false" },
221 stderr:
222 "cache error (non-fatal): " +
223 (err instanceof Error ? err.message : String(err)),
224 };
225 }
226 },
227};
Addedsrc/lib/actions/checkout-action.ts+31−0View fileUnifiedSplit
1/**
2 * `gluecron/checkout@v1` — idiomatic no-op.
3 *
4 * The runner (Agent 5) has already checked out the repo into `ctx.workspace`
5 * before any `uses:` step executes. This action exists so users can write
6 * the familiar `- uses: gluecron/checkout@v1` line without surprise. It
7 * records the resolved commit sha as an output so downstream steps can
8 * reference it via `steps.<id>.outputs.sha`.
9 */
10
11import type { ActionHandler } from "../action-registry";
12
13export const checkoutAction: ActionHandler = {
14 name: "gluecron/checkout",
15 version: "v1",
16 async run(ctx) {
17 try {
18 const sha = ctx.commitSha || "";
19 return {
20 exitCode: 0,
21 outputs: { sha },
22 stdout: `Checked out ${sha || "HEAD"} at ${ctx.workspace}`,
23 };
24 } catch (err) {
25 return {
26 exitCode: 1,
27 stderr: err instanceof Error ? err.message : String(err),
28 };
29 }
30 },
31};
Addedsrc/lib/actions/download-artifact-action.ts+155−0View fileUnifiedSplit
1/**
2 * `gluecron/download-artifact@v1` — restores a previously uploaded artifact
3 * into the workspace. Inverse of `upload-artifact@v1`.
4 *
5 * `with:` inputs:
6 * name: string (required) — artifact name to fetch
7 * path?: string (optional) — destination dir relative to workspace;
8 * defaults to '.'
9 * optional?: boolean (optional) — when true, a missing artifact returns
10 * exitCode 0 (otherwise 1)
11 *
12 * If the stored artifact is a tar-gz (content-type `application/gzip` or
13 * `application/x-tar`), it's extracted into the destination directory. Any
14 * other content type is written as-is to `<dest>/<name>`.
15 *
16 * Like its sibling, gracefully degrades when the helper module can't be
17 * imported (exitCode 0 with stderr note).
18 */
19
20import { mkdir } from "fs/promises";
21import { dirname, join } from "path";
22import { tmpdir } from "os";
23import type { ActionHandler, ActionContext } from "../action-registry";
24
25function parseInputs(
26 ctx: ActionContext
27): { name: string; path: string; optional: boolean } | { error: string } {
28 const w = ctx.with || {};
29 const name = typeof w.name === "string" ? w.name.trim() : "";
30 const pathRaw = typeof w.path === "string" ? w.path.trim() : "";
31 const path = pathRaw || ".";
32 const optional = w.optional === true || w.optional === "true";
33 if (!name) return { error: "download-artifact: `name` is required" };
34 return { name, path, optional };
35}
36
37function isArchive(contentType: string): boolean {
38 const ct = (contentType || "").toLowerCase();
39 return (
40 ct === "application/gzip" ||
41 ct === "application/x-gzip" ||
42 ct === "application/x-tar" ||
43 ct === "application/tar+gzip"
44 );
45}
46
47async function extractArchive(content: Buffer, destDir: string): Promise<void> {
48 await mkdir(destDir, { recursive: true });
49 const tmpPath = join(
50 tmpdir(),
51 `gluecron-dl-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`
52 );
53 await Bun.write(tmpPath, content);
54 try {
55 const proc = Bun.spawn(
56 ["tar", "-xf", tmpPath, "-C", destDir],
57 { stdout: "pipe", stderr: "pipe" }
58 );
59 const exit = await proc.exited;
60 if (exit !== 0) {
61 const err = await new Response(proc.stderr).text().catch(() => "");
62 throw new Error(`tar extract failed (exit ${exit}): ${err.slice(0, 200)}`);
63 }
64 } finally {
65 try {
66 const fs = await import("fs/promises");
67 await fs.unlink(tmpPath).catch(() => {});
68 } catch {
69 /* noop */
70 }
71 }
72}
73
74export const downloadArtifactAction: ActionHandler = {
75 name: "gluecron/download-artifact",
76 version: "v1",
77 async run(ctx): Promise<import("../action-registry").ActionResult> {
78 try {
79 const parsed = parseInputs(ctx);
80 if ("error" in parsed) {
81 return { exitCode: 1, stderr: parsed.error };
82 }
83
84 // Dynamic import so a missing helper module degrades gracefully.
85 let listArtifacts: typeof import("../workflow-artifacts").listArtifacts;
86 let downloadArtifact: typeof import("../workflow-artifacts").downloadArtifact;
87 try {
88 const mod = await import("../workflow-artifacts");
89 listArtifacts = mod.listArtifacts;
90 downloadArtifact = mod.downloadArtifact;
91 } catch (err) {
92 return {
93 exitCode: 0,
94 stderr:
95 "download-artifact unavailable; skipping (" +
96 (err instanceof Error ? err.message : String(err)) +
97 ")",
98 };
99 }
100
101 const listed = await listArtifacts(ctx.runId);
102 if (!listed.ok) {
103 return {
104 exitCode: parsed.optional ? 0 : 1,
105 stderr: `download-artifact: list failed: ${listed.error}`,
106 };
107 }
108
109 const match = listed.artifacts.find((a) => a.name === parsed.name);
110 if (!match) {
111 const msg = `download-artifact: no artifact named "${parsed.name}" on run ${ctx.runId}`;
112 return {
113 exitCode: parsed.optional ? 0 : 1,
114 stderr: msg,
115 outputs: { found: "false" },
116 };
117 }
118
119 const fetched = await downloadArtifact(match.id);
120 if (!fetched.ok) {
121 return {
122 exitCode: parsed.optional ? 0 : 1,
123 stderr: `download-artifact: fetch failed: ${fetched.error}`,
124 };
125 }
126
127 const destDir = join(ctx.workspace, parsed.path);
128 if (isArchive(fetched.contentType)) {
129 await extractArchive(fetched.content, destDir);
130 } else {
131 const outPath = join(destDir, parsed.name);
132 await mkdir(dirname(outPath), { recursive: true });
133 await Bun.write(outPath, fetched.content);
134 }
135
136 return {
137 exitCode: 0,
138 stdout: `Downloaded artifact "${parsed.name}" (${fetched.content.byteLength} bytes) to ${parsed.path}`,
139 outputs: {
140 found: "true",
141 "artifact-id": match.id,
142 name: parsed.name,
143 size: String(fetched.content.byteLength),
144 },
145 };
146 } catch (err) {
147 return {
148 exitCode: 1,
149 stderr:
150 "download-artifact error: " +
151 (err instanceof Error ? err.message : String(err)),
152 };
153 }
154 },
155};
Addedsrc/lib/actions/gatetest-action.ts+97−0View fileUnifiedSplit
1/**
2 * `gluecron/gatetest@v1` — runs the external GateTest scanner against the
3 * current commit and surfaces its pass/fail as the step exit code.
4 *
5 * Wraps `runGateTestScan` from `src/lib/gate.ts` (owned by another agent —
6 * read-only import here). In dev where `GATETEST_URL` isn't configured the
7 * step short-circuits with exitCode 0 so workflows don't fail spuriously.
8 *
9 * `with:` inputs are accepted but only a subset are honored in v1:
10 * url, apiKey, timeout — reserved for future overrides. Today the action
11 * always uses the process-wide config. The inputs are parsed defensively
12 * so user typos don't break the run.
13 */
14
15import { eq } from "drizzle-orm";
16import type { ActionHandler } from "../action-registry";
17import { db } from "../../db";
18import { repositories, users } from "../../db/schema";
19import { runGateTestScan } from "../gate";
20import { config } from "../config";
21
22async function lookupOwnerAndRepo(
23 repoId: string
24): Promise<{ owner: string; repo: string } | null> {
25 try {
26 const [row] = await db
27 .select({
28 name: repositories.name,
29 ownerId: repositories.ownerId,
30 })
31 .from(repositories)
32 .where(eq(repositories.id, repoId))
33 .limit(1);
34 if (!row) return null;
35
36 const [u] = await db
37 .select({ username: users.username })
38 .from(users)
39 .where(eq(users.id, row.ownerId))
40 .limit(1);
41 if (!u) return null;
42
43 return { owner: u.username, repo: row.name };
44 } catch {
45 return null;
46 }
47}
48
49export const gatetestAction: ActionHandler = {
50 name: "gluecron/gatetest",
51 version: "v1",
52 async run(ctx): Promise<import("../action-registry").ActionResult> {
53 try {
54 // Dev-mode short-circuit: no URL configured → skip quietly.
55 if (!config.gatetestUrl) {
56 return {
57 exitCode: 0,
58 stdout: "GateTest not configured — skipping",
59 outputs: { status: "skipped" },
60 };
61 }
62
63 const lookup = await lookupOwnerAndRepo(ctx.repoId);
64 if (!lookup) {
65 return {
66 exitCode: 1,
67 stderr: `GateTest: unable to resolve repository ${ctx.repoId}`,
68 };
69 }
70
71 const ref = ctx.ref || "refs/heads/main";
72 const sha = ctx.commitSha || "";
73 const result = await runGateTestScan(lookup.owner, lookup.repo, ref, sha);
74
75 const stdout = `GateTest: ${result.passed ? "PASS" : "FAIL"}${result.details}`;
76 return {
77 exitCode: result.passed || result.skipped ? 0 : 1,
78 stdout,
79 outputs: {
80 status: result.skipped
81 ? "skipped"
82 : result.passed
83 ? "passed"
84 : "failed",
85 details: result.details,
86 },
87 };
88 } catch (err) {
89 return {
90 exitCode: 1,
91 stderr:
92 "GateTest action error: " +
93 (err instanceof Error ? err.message : String(err)),
94 };
95 }
96 },
97};
Addedsrc/lib/actions/upload-artifact-action.ts+166−0View fileUnifiedSplit
1/**
2 * `gluecron/upload-artifact@v1` — persists files from the workspace as a
3 * named artifact attached to the current run.
4 *
5 * Wraps Agent 6's `uploadArtifact` helper. `with:` inputs:
6 * name: string (required) — artifact name, stored on the run
7 * path: string (required) — file or directory inside `ctx.workspace`
8 *
9 * Behaviour:
10 * - If `path` resolves to a single file, the file is uploaded as-is with
11 * contentType inferred from the extension.
12 * - If `path` resolves to a directory, the directory is tar-gz'd first and
13 * uploaded as `application/gzip`.
14 * - If the artifact helper module can't be imported (e.g. out-of-tree
15 * deployment) the step degrades gracefully to exitCode 0 with a stderr
16 * note — a missing upload must never fail the pipeline unrelated to it.
17 * - Other errors (missing file, oversize, DB failure) return exitCode 1.
18 */
19
20import { stat } from "fs/promises";
21import { basename, join } from "path";
22import { tmpdir } from "os";
23import type { ActionHandler, ActionContext } from "../action-registry";
24
25function parseInputs(
26 ctx: ActionContext
27): { name: string; path: string } | { error: string } {
28 const w = ctx.with || {};
29 const name = typeof w.name === "string" ? w.name.trim() : "";
30 const path = typeof w.path === "string" ? w.path.trim() : "";
31 if (!name) return { error: "upload-artifact: `name` is required" };
32 if (!path) return { error: "upload-artifact: `path` is required" };
33 return { name, path };
34}
35
36function guessContentType(filename: string): string {
37 const lower = filename.toLowerCase();
38 if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "application/gzip";
39 if (lower.endsWith(".gz")) return "application/gzip";
40 if (lower.endsWith(".zip")) return "application/zip";
41 if (lower.endsWith(".tar")) return "application/x-tar";
42 if (lower.endsWith(".json")) return "application/json";
43 if (lower.endsWith(".txt") || lower.endsWith(".log")) return "text/plain";
44 if (lower.endsWith(".xml")) return "application/xml";
45 if (lower.endsWith(".html")) return "text/html";
46 return "application/octet-stream";
47}
48
49/**
50 * Tar-gz a directory into a tmp file and return the buffered bytes. Cleans
51 * up the tmp file regardless of success.
52 */
53async function tarGzDirectory(dir: string): Promise<Buffer> {
54 const tmpPath = join(
55 tmpdir(),
56 `gluecron-artifact-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`
57 );
58 try {
59 const proc = Bun.spawn(
60 ["tar", "-czf", tmpPath, "-C", dir, "."],
61 { stdout: "pipe", stderr: "pipe" }
62 );
63 const exit = await proc.exited;
64 if (exit !== 0) {
65 const err = await new Response(proc.stderr).text().catch(() => "");
66 throw new Error(`tar failed (exit ${exit}): ${err.slice(0, 200)}`);
67 }
68 const bytes = await Bun.file(tmpPath).arrayBuffer();
69 return Buffer.from(bytes);
70 } finally {
71 try {
72 const fs = await import("fs/promises");
73 await fs.unlink(tmpPath).catch(() => {});
74 } catch {
75 /* noop */
76 }
77 }
78}
79
80export const uploadArtifactAction: ActionHandler = {
81 name: "gluecron/upload-artifact",
82 version: "v1",
83 async run(ctx) {
84 try {
85 const parsed = parseInputs(ctx);
86 if ("error" in parsed) {
87 return { exitCode: 1, stderr: parsed.error };
88 }
89
90 // Dynamic import so a missing helper module degrades gracefully
91 // rather than crashing the registry at load time.
92 let uploadArtifact: typeof import("../workflow-artifacts").uploadArtifact;
93 try {
94 ({ uploadArtifact } = await import("../workflow-artifacts"));
95 } catch (err) {
96 return {
97 exitCode: 0,
98 stderr:
99 "upload-artifact unavailable; skipping (" +
100 (err instanceof Error ? err.message : String(err)) +
101 ")",
102 };
103 }
104
105 const abs = join(ctx.workspace, parsed.path);
106 let info;
107 try {
108 info = await stat(abs);
109 } catch (err) {
110 return {
111 exitCode: 1,
112 stderr:
113 `upload-artifact: path not found: ${parsed.path} (${err instanceof Error ? err.message : String(err)})`,
114 };
115 }
116
117 let content: Buffer;
118 let contentType: string;
119 if (info.isDirectory()) {
120 content = await tarGzDirectory(abs);
121 contentType = "application/gzip";
122 } else if (info.isFile()) {
123 const bytes = await Bun.file(abs).arrayBuffer();
124 content = Buffer.from(bytes);
125 contentType = guessContentType(basename(abs));
126 } else {
127 return {
128 exitCode: 1,
129 stderr: `upload-artifact: unsupported path type for ${parsed.path}`,
130 };
131 }
132
133 const result = await uploadArtifact({
134 runId: ctx.runId,
135 jobId: ctx.jobId,
136 name: parsed.name,
137 content,
138 contentType,
139 });
140
141 if (!result.ok) {
142 return {
143 exitCode: 1,
144 stderr: `upload-artifact: ${result.error}`,
145 };
146 }
147
148 return {
149 exitCode: 0,
150 stdout: `Uploaded artifact "${parsed.name}" (${content.byteLength} bytes, ${contentType})`,
151 outputs: {
152 "artifact-id": result.artifactId,
153 name: parsed.name,
154 size: String(content.byteLength),
155 },
156 };
157 } catch (err) {
158 return {
159 exitCode: 1,
160 stderr:
161 "upload-artifact error: " +
162 (err instanceof Error ? err.message : String(err)),
163 };
164 }
165 },
166};
Modifiedsrc/lib/sse-client.ts+56−0View fileUnifiedSplit
6565 "}connect();}catch(e){}})();"
6666 );
6767}
68
69/**
70 * Live log-tail script: subscribe to a workflow-run topic, append step-log
71 * chunks to a <pre>, and auto-close when 'run-done' arrives.
72 *
73 * Unlike liveSubscribeScript, this helper distinguishes SSE event types
74 * (step-log / step-start / step-done / run-done) and writes plain text
75 * (escaped to prevent HTML injection) into a <pre>. All interpolated
76 * option strings are JSON-encoded via safeJsonForScript so that the
77 * resulting script fragment is safe to splice into server-rendered HTML.
78 */
79export function liveLogTailScript(opts: {
80 topic: string;
81 targetElementId: string;
82 jobId?: string;
83 onRunDone?: string;
84}): string {
85 const topic = safeJsonForScript(opts.topic);
86 const targetId = safeJsonForScript(opts.targetElementId);
87 const jobFilter = safeJsonForScript(opts.jobId ?? "");
88 // onRunDone is raw JS supplied by the server. Wrap in try/catch.
89 const onRunDone = opts.onRunDone ? String(opts.onRunDone) : "";
90 const onRunDoneJson = safeJsonForScript(onRunDone);
91
92 return (
93 "(function(){try{" +
94 "if(typeof EventSource==='undefined')return;" +
95 "var t=" + topic + ",id=" + targetId + ",jf=" + jobFilter + ",onDone=" + onRunDoneJson + ";" +
96 "var el=document.getElementById(id);if(!el)return;" +
97 "var status=document.getElementById(id+'-status');" +
98 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
99 "function setStatus(s){if(status)status.textContent=s;}" +
100 "function scroll(){try{el.scrollTop=el.scrollHeight;}catch(e){}}" +
101 "function append(txt){el.insertAdjacentHTML('beforeend',esc(txt));scroll();}" +
102 "function match(d){if(!jf)return true;return d&&d.jobId===jf;}" +
103 "var es;" +
104 "try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){return;}" +
105 "es.addEventListener('open',function(){setStatus('live');});" +
106 "es.addEventListener('step-log',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
107 "var prefix='[step '+d.stepIndex+' '+(d.stream||'stdout')+'] ';" +
108 "var chunk=String(d.chunk==null?'':d.chunk);" +
109 "var lines=chunk.split('\\n');" +
110 "for(var i=0;i<lines.length;i++){if(i===lines.length-1&&lines[i]==='')continue;append(prefix+lines[i]+'\\n');}" +
111 "}catch(e){}});" +
112 "es.addEventListener('step-start',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
113 "append('>>> step '+d.stepIndex+' ('+(d.name||'')+') started\\n');" +
114 "}catch(e){}});" +
115 "es.addEventListener('step-done',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
116 "var dur=typeof d.durationMs==='number'?(d.durationMs<1000?d.durationMs+'ms':(d.durationMs/1000).toFixed(1)+'s'):'';" +
117 "append('<<< step '+d.stepIndex+' done (exit '+d.exitCode+(dur?', '+dur:'')+')\\n');" +
118 "}catch(e){}});" +
119 "es.addEventListener('run-done',function(m){try{setStatus('done');try{es.close();}catch(e){}if(onDone){try{(new Function(onDone))();}catch(e){}}}catch(e){}});" +
120 "es.onerror=function(){setStatus('disconnected');};" +
121 "}catch(e){}})();"
122 );
123}
Addedsrc/lib/workflow-artifacts.ts+224−0View fileUnifiedSplit
1/**
2 * Workflow artifact helpers (Block C1 / Sprint 1 — Agent 6).
3 *
4 * Pure functions for uploading, listing, downloading and deleting workflow
5 * run artifacts. Shared between the REST API (`src/routes/workflow-artifacts.ts`)
6 * and in-process action handlers (e.g. `gluecron/upload-artifact@v1`,
7 * `gluecron/download-artifact@v1` — built by Agent 8).
8 *
9 * Storage contract: `workflow_artifacts.content` is declared as `text` in
10 * drizzle (base64-encoded bytes), even though the underlying column type is
11 * `bytea`. This mismatch is intentional for v1 — see the `bytea` customType
12 * comment in `src/db/schema.ts`. We therefore base64-encode on write and
13 * base64-decode on read.
14 *
15 * These functions never throw. DB/validation failures return
16 * `{ ok: false, error }`.
17 */
18
19import { eq } from "drizzle-orm";
20import { db } from "../db";
21import { workflowArtifacts } from "../db/schema";
22
23/** 100 MiB — matches the REST API cap. */
24export const MAX_ARTIFACT_BYTES = 100 * 1024 * 1024;
25
26const NAME_RE = /^[A-Za-z0-9._-]+$/;
27
28function toBuffer(input: Uint8Array | Buffer): Buffer {
29 if (Buffer.isBuffer(input)) return input;
30 return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
31}
32
33export async function uploadArtifact(args: {
34 runId: string;
35 jobId: string;
36 name: string;
37 content: Uint8Array | Buffer;
38 contentType?: string;
39}): Promise<{ ok: true; artifactId: string } | { ok: false; error: string }> {
40 const { runId, jobId, name, content } = args;
41 const contentType = args.contentType || "application/octet-stream";
42
43 if (!runId || typeof runId !== "string") {
44 return { ok: false, error: "runId is required" };
45 }
46 if (!jobId || typeof jobId !== "string") {
47 return { ok: false, error: "jobId is required" };
48 }
49 if (!name || typeof name !== "string") {
50 return { ok: false, error: "name is required" };
51 }
52 if (name.length > 255) {
53 return { ok: false, error: "name too long (max 255 chars)" };
54 }
55 if (!NAME_RE.test(name)) {
56 return {
57 ok: false,
58 error: "name must match /^[A-Za-z0-9._-]+$/",
59 };
60 }
61
62 const buf = toBuffer(content);
63 if (buf.byteLength > MAX_ARTIFACT_BYTES) {
64 return { ok: false, error: "artifact exceeds 100MB limit" };
65 }
66
67 try {
68 const [row] = await db
69 .insert(workflowArtifacts)
70 .values({
71 runId,
72 jobId,
73 name,
74 sizeBytes: buf.byteLength,
75 contentType,
76 // Stored as base64 text for v1 (see schema comment).
77 content: buf.toString("base64"),
78 })
79 .returning({ id: workflowArtifacts.id });
80
81 if (!row) {
82 return { ok: false, error: "insert returned no row" };
83 }
84 return { ok: true, artifactId: row.id };
85 } catch (err) {
86 console.error("[workflow-artifacts] uploadArtifact:", err);
87 return { ok: false, error: "database error" };
88 }
89}
90
91export async function listArtifacts(
92 runId: string
93): Promise<
94 | {
95 ok: true;
96 artifacts: {
97 id: string;
98 name: string;
99 size: number;
100 contentType: string;
101 createdAt: Date;
102 }[];
103 }
104 | { ok: false; error: string }
105> {
106 if (!runId || typeof runId !== "string") {
107 return { ok: false, error: "runId is required" };
108 }
109 try {
110 const rows = await db
111 .select({
112 id: workflowArtifacts.id,
113 name: workflowArtifacts.name,
114 size: workflowArtifacts.sizeBytes,
115 contentType: workflowArtifacts.contentType,
116 createdAt: workflowArtifacts.createdAt,
117 })
118 .from(workflowArtifacts)
119 .where(eq(workflowArtifacts.runId, runId));
120
121 return { ok: true, artifacts: rows };
122 } catch (err) {
123 console.error("[workflow-artifacts] listArtifacts:", err);
124 return { ok: false, error: "database error" };
125 }
126}
127
128export async function downloadArtifact(
129 artifactId: string
130): Promise<
131 | { ok: true; name: string; contentType: string; content: Buffer }
132 | { ok: false; error: string }
133> {
134 if (!artifactId || typeof artifactId !== "string") {
135 return { ok: false, error: "artifactId is required" };
136 }
137 try {
138 const [row] = await db
139 .select()
140 .from(workflowArtifacts)
141 .where(eq(workflowArtifacts.id, artifactId))
142 .limit(1);
143
144 if (!row) {
145 return { ok: false, error: "not found" };
146 }
147
148 const raw = row.content;
149 const buf = raw ? Buffer.from(raw, "base64") : Buffer.alloc(0);
150 return {
151 ok: true,
152 name: row.name,
153 contentType: row.contentType,
154 content: buf,
155 };
156 } catch (err) {
157 console.error("[workflow-artifacts] downloadArtifact:", err);
158 return { ok: false, error: "database error" };
159 }
160}
161
162export async function deleteArtifact(
163 artifactId: string
164): Promise<{ ok: true } | { ok: false; error: string }> {
165 if (!artifactId || typeof artifactId !== "string") {
166 return { ok: false, error: "artifactId is required" };
167 }
168 try {
169 const res = await db
170 .delete(workflowArtifacts)
171 .where(eq(workflowArtifacts.id, artifactId))
172 .returning({ id: workflowArtifacts.id });
173
174 if (res.length === 0) {
175 return { ok: false, error: "not found" };
176 }
177 return { ok: true };
178 } catch (err) {
179 console.error("[workflow-artifacts] deleteArtifact:", err);
180 return { ok: false, error: "database error" };
181 }
182}
183
184/**
185 * Internal helper for the REST layer: returns just the owning repositoryId
186 * for a run. Kept here so both the API route and any future helpers share
187 * the same lookup without reaching into `workflowRuns` directly from N places.
188 */
189export async function getRunRepositoryId(
190 runId: string
191): Promise<string | null> {
192 try {
193 const { workflowRuns } = await import("../db/schema");
194 const [row] = await db
195 .select({ repositoryId: workflowRuns.repositoryId })
196 .from(workflowRuns)
197 .where(eq(workflowRuns.id, runId))
198 .limit(1);
199 return row ? row.repositoryId : null;
200 } catch (err) {
201 console.error("[workflow-artifacts] getRunRepositoryId:", err);
202 return null;
203 }
204}
205
206/**
207 * Internal helper: look up a single artifact's runId (used by GET/DELETE
208 * endpoints that only receive `:artifactId`).
209 */
210export async function getArtifactRunId(
211 artifactId: string
212): Promise<string | null> {
213 try {
214 const [row] = await db
215 .select({ runId: workflowArtifacts.runId })
216 .from(workflowArtifacts)
217 .where(eq(workflowArtifacts.id, artifactId))
218 .limit(1);
219 return row ? row.runId : null;
220 } catch (err) {
221 console.error("[workflow-artifacts] getArtifactRunId:", err);
222 return null;
223 }
224}
Addedsrc/lib/workflow-conditionals.ts+564−0View fileUnifiedSplit
1/**
2 * workflow-conditionals.ts
3 *
4 * Restricted expression evaluator for workflow `if:` clauses.
5 *
6 * NO eval(), NO Function constructor. Hand-written tokenizer + recursive-
7 * descent / precedence-climbing parser. Never throws on any input.
8 *
9 * Grammar (lowest precedence first):
10 * or := and ( '||' and )*
11 * and := eq ( '&&' eq )*
12 * eq := cmp ( ('=='|'!=') cmp )*
13 * cmp := unary ( ('<'|'<='|'>'|'>=') unary )*
14 * unary := '!' unary | primary
15 * primary := literal | callOrPath | '(' or ')'
16 * path := IDENT ( '.' IDENT )*
17 * call := IDENT '(' [ arg (',' arg)* ] ')'
18 */
19
20export type ConditionalContext = {
21 env?: Record<string, string>;
22 matrix?: Record<string, unknown>;
23 steps?: Record<
24 string,
25 { outcome?: string; conclusion?: string; outputs?: Record<string, string> }
26 >;
27 needs?: Record<string, { result?: string; outputs?: Record<string, string> }>;
28 secrets?: Record<string, string>;
29 inputs?: Record<string, unknown>;
30 github?: {
31 event_name?: string;
32 ref?: string;
33 sha?: string;
34 actor?: string;
35 repository?: string;
36 };
37 job?: { status?: string };
38 runner?: { os?: string; arch?: string };
39};
40
41export type EvalResult = { ok: true; value: boolean } | { ok: false; error: string };
42
43// ---------------------------------------------------------------------------
44// Tokenizer
45// ---------------------------------------------------------------------------
46
47type Tok =
48 | { k: "num"; v: number }
49 | { k: "str"; v: string }
50 | { k: "ident"; v: string }
51 | { k: "bool"; v: boolean }
52 | { k: "null" }
53 | { k: "op"; v: string }
54 | { k: "lparen" }
55 | { k: "rparen" }
56 | { k: "dot" }
57 | { k: "comma" }
58 | { k: "eof" };
59
60function tokenize(src: string): Tok[] | { error: string } {
61 const toks: Tok[] = [];
62 let i = 0;
63 const n = src.length;
64 while (i < n) {
65 const c = src[i]!;
66 // whitespace
67 if (c === " " || c === "\t" || c === "\n" || c === "\r") {
68 i++;
69 continue;
70 }
71 // strings
72 if (c === "'" || c === '"') {
73 const quote = c;
74 let j = i + 1;
75 let s = "";
76 let closed = false;
77 while (j < n) {
78 const ch = src[j]!;
79 if (ch === quote) {
80 // GitHub Actions uses doubled-quote escape inside single-quoted strings.
81 if (quote === "'" && src[j + 1] === "'") {
82 s += "'";
83 j += 2;
84 continue;
85 }
86 closed = true;
87 j++;
88 break;
89 }
90 if (ch === "\\" && quote === '"' && j + 1 < n) {
91 const nx = src[j + 1]!;
92 if (nx === "n") s += "\n";
93 else if (nx === "t") s += "\t";
94 else if (nx === "r") s += "\r";
95 else if (nx === "\\") s += "\\";
96 else if (nx === '"') s += '"';
97 else s += nx;
98 j += 2;
99 continue;
100 }
101 s += ch;
102 j++;
103 }
104 if (!closed) return { error: `unterminated string at ${i}` };
105 toks.push({ k: "str", v: s });
106 i = j;
107 continue;
108 }
109 // numbers (integers and simple decimals)
110 if ((c >= "0" && c <= "9") || (c === "-" && src[i + 1] && src[i + 1]! >= "0" && src[i + 1]! <= "9")) {
111 // Only treat as number if preceded by nothing, an operator, comma, or lparen.
112 const prev = toks[toks.length - 1];
113 const allowNeg =
114 c !== "-" ||
115 !prev ||
116 prev.k === "op" ||
117 prev.k === "comma" ||
118 prev.k === "lparen";
119 if (c === "-" && !allowNeg) {
120 // fall through to operator handling
121 } else {
122 let j = i;
123 if (c === "-") j++;
124 while (j < n && src[j]! >= "0" && src[j]! <= "9") j++;
125 if (src[j] === "." && src[j + 1] && src[j + 1]! >= "0" && src[j + 1]! <= "9") {
126 j++;
127 while (j < n && src[j]! >= "0" && src[j]! <= "9") j++;
128 }
129 const num = Number(src.slice(i, j));
130 if (!Number.isFinite(num)) return { error: `bad number at ${i}` };
131 toks.push({ k: "num", v: num });
132 i = j;
133 continue;
134 }
135 }
136 // identifiers / keywords
137 if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_") {
138 let j = i + 1;
139 while (
140 j < n &&
141 ((src[j]! >= "a" && src[j]! <= "z") ||
142 (src[j]! >= "A" && src[j]! <= "Z") ||
143 (src[j]! >= "0" && src[j]! <= "9") ||
144 src[j] === "_" ||
145 src[j] === "-")
146 ) {
147 j++;
148 }
149 const word = src.slice(i, j);
150 if (word === "true") toks.push({ k: "bool", v: true });
151 else if (word === "false") toks.push({ k: "bool", v: false });
152 else if (word === "null") toks.push({ k: "null" });
153 else toks.push({ k: "ident", v: word });
154 i = j;
155 continue;
156 }
157 // operators / punctuation
158 const two = src.slice(i, i + 2);
159 if (two === "==" || two === "!=" || two === "&&" || two === "||" || two === "<=" || two === ">=") {
160 toks.push({ k: "op", v: two });
161 i += 2;
162 continue;
163 }
164 if (c === "<" || c === ">") {
165 toks.push({ k: "op", v: c });
166 i++;
167 continue;
168 }
169 if (c === "!") {
170 toks.push({ k: "op", v: "!" });
171 i++;
172 continue;
173 }
174 if (c === "(") {
175 toks.push({ k: "lparen" });
176 i++;
177 continue;
178 }
179 if (c === ")") {
180 toks.push({ k: "rparen" });
181 i++;
182 continue;
183 }
184 if (c === ".") {
185 toks.push({ k: "dot" });
186 i++;
187 continue;
188 }
189 if (c === ",") {
190 toks.push({ k: "comma" });
191 i++;
192 continue;
193 }
194 return { error: `unexpected character "${c}" at ${i}` };
195 }
196 toks.push({ k: "eof" });
197 return toks;
198}
199
200// ---------------------------------------------------------------------------
201// AST nodes — represented as tagged unions of plain objects.
202// ---------------------------------------------------------------------------
203
204type Node =
205 | { t: "lit"; v: unknown }
206 | { t: "path"; segs: string[] }
207 | { t: "call"; name: string; args: Node[] }
208 | { t: "unary"; op: "!"; x: Node }
209 | { t: "bin"; op: string; a: Node; b: Node };
210
211// ---------------------------------------------------------------------------
212// Parser
213// ---------------------------------------------------------------------------
214
215class Parser {
216 pos = 0;
217 constructor(private toks: Tok[]) {}
218
219 peek(): Tok {
220 return this.toks[this.pos]!;
221 }
222 eat(): Tok {
223 return this.toks[this.pos++]!;
224 }
225 expect(k: Tok["k"]): Tok {
226 const t = this.eat();
227 if (t.k !== k) throw new Error(`expected ${k} got ${t.k}`);
228 return t;
229 }
230
231 parseOr(): Node {
232 let a = this.parseAnd();
233 while (this.peek().k === "op" && (this.peek() as { v: string }).v === "||") {
234 this.eat();
235 const b = this.parseAnd();
236 a = { t: "bin", op: "||", a, b };
237 }
238 return a;
239 }
240 parseAnd(): Node {
241 let a = this.parseEq();
242 while (this.peek().k === "op" && (this.peek() as { v: string }).v === "&&") {
243 this.eat();
244 const b = this.parseEq();
245 a = { t: "bin", op: "&&", a, b };
246 }
247 return a;
248 }
249 parseEq(): Node {
250 let a = this.parseCmp();
251 while (this.peek().k === "op") {
252 const op = (this.peek() as { v: string }).v;
253 if (op !== "==" && op !== "!=") break;
254 this.eat();
255 const b = this.parseCmp();
256 a = { t: "bin", op, a, b };
257 }
258 return a;
259 }
260 parseCmp(): Node {
261 let a = this.parseUnary();
262 while (this.peek().k === "op") {
263 const op = (this.peek() as { v: string }).v;
264 if (op !== "<" && op !== "<=" && op !== ">" && op !== ">=") break;
265 this.eat();
266 const b = this.parseUnary();
267 a = { t: "bin", op, a, b };
268 }
269 return a;
270 }
271 parseUnary(): Node {
272 if (this.peek().k === "op" && (this.peek() as { v: string }).v === "!") {
273 this.eat();
274 return { t: "unary", op: "!", x: this.parseUnary() };
275 }
276 return this.parsePrimary();
277 }
278 parsePrimary(): Node {
279 const t = this.peek();
280 if (t.k === "lparen") {
281 this.eat();
282 const inner = this.parseOr();
283 this.expect("rparen");
284 return inner;
285 }
286 if (t.k === "num") {
287 this.eat();
288 return { t: "lit", v: t.v };
289 }
290 if (t.k === "str") {
291 this.eat();
292 return { t: "lit", v: t.v };
293 }
294 if (t.k === "bool") {
295 this.eat();
296 return { t: "lit", v: t.v };
297 }
298 if (t.k === "null") {
299 this.eat();
300 return { t: "lit", v: null };
301 }
302 if (t.k === "ident") {
303 this.eat();
304 // function call?
305 if (this.peek().k === "lparen") {
306 this.eat();
307 const args: Node[] = [];
308 if (this.peek().k !== "rparen") {
309 args.push(this.parseOr());
310 while (this.peek().k === "comma") {
311 this.eat();
312 args.push(this.parseOr());
313 }
314 }
315 this.expect("rparen");
316 return { t: "call", name: t.v, args };
317 }
318 // path
319 const segs: string[] = [t.v];
320 while (this.peek().k === "dot") {
321 this.eat();
322 const nx = this.eat();
323 if (nx.k !== "ident") throw new Error("expected identifier after '.'");
324 segs.push(nx.v);
325 }
326 return { t: "path", segs };
327 }
328 throw new Error(`unexpected token ${t.k}`);
329 }
330}
331
332// ---------------------------------------------------------------------------
333// Evaluator
334// ---------------------------------------------------------------------------
335
336function resolvePath(segs: string[], ctx: ConditionalContext): unknown {
337 if (segs.length === 0) return undefined;
338 const root = segs[0]!;
339 let cur: unknown;
340 switch (root) {
341 case "env":
342 cur = ctx.env ?? {};
343 break;
344 case "matrix":
345 cur = ctx.matrix ?? {};
346 break;
347 case "steps":
348 cur = ctx.steps ?? {};
349 break;
350 case "needs":
351 cur = ctx.needs ?? {};
352 break;
353 case "secrets":
354 cur = ctx.secrets ?? {};
355 break;
356 case "inputs":
357 cur = ctx.inputs ?? {};
358 break;
359 case "github":
360 cur = ctx.github ?? {};
361 break;
362 case "job":
363 cur = ctx.job ?? {};
364 break;
365 case "runner":
366 cur = ctx.runner ?? {};
367 break;
368 default:
369 return undefined;
370 }
371 for (let i = 1; i < segs.length; i++) {
372 if (cur == null) return undefined;
373 if (typeof cur !== "object") return undefined;
374 cur = (cur as Record<string, unknown>)[segs[i]!];
375 }
376 return cur;
377}
378
379function toBool(v: unknown): boolean {
380 if (v === null || v === undefined) return false;
381 if (typeof v === "boolean") return v;
382 if (typeof v === "number") return v !== 0 && !Number.isNaN(v);
383 if (typeof v === "string") return v.length > 0;
384 if (Array.isArray(v)) return true;
385 if (typeof v === "object") return true;
386 return false;
387}
388
389function looseEq(a: unknown, b: unknown): boolean {
390 if (a === b) return true;
391 if (a == null && b == null) return true;
392 if (a == null || b == null) return false;
393 // If one side is number and the other is string, compare numerically when possible.
394 if (typeof a === "number" && typeof b === "string") {
395 const nb = Number(b);
396 if (!Number.isNaN(nb)) return a === nb;
397 return false;
398 }
399 if (typeof a === "string" && typeof b === "number") {
400 const na = Number(a);
401 if (!Number.isNaN(na)) return na === b;
402 return false;
403 }
404 // Case-insensitive string comparison (GitHub Actions semantic).
405 if (typeof a === "string" && typeof b === "string") {
406 return a.toLowerCase() === b.toLowerCase();
407 }
408 if (typeof a === "boolean" && typeof b === "boolean") return a === b;
409 return false;
410}
411
412function toNum(v: unknown): number {
413 if (typeof v === "number") return v;
414 if (typeof v === "string") {
415 const n = Number(v);
416 return Number.isNaN(n) ? NaN : n;
417 }
418 if (typeof v === "boolean") return v ? 1 : 0;
419 if (v == null) return 0;
420 return NaN;
421}
422
423function toStr(v: unknown): string {
424 if (v == null) return "";
425 if (typeof v === "string") return v;
426 if (typeof v === "number" || typeof v === "boolean") return String(v);
427 try {
428 return JSON.stringify(v);
429 } catch {
430 return "";
431 }
432}
433
434function evalNode(node: Node, ctx: ConditionalContext): unknown {
435 switch (node.t) {
436 case "lit":
437 return node.v;
438 case "path":
439 return resolvePath(node.segs, ctx);
440 case "unary":
441 return !toBool(evalNode(node.x, ctx));
442 case "bin": {
443 if (node.op === "&&") {
444 const av = evalNode(node.a, ctx);
445 if (!toBool(av)) return false;
446 return toBool(evalNode(node.b, ctx));
447 }
448 if (node.op === "||") {
449 const av = evalNode(node.a, ctx);
450 if (toBool(av)) return true;
451 return toBool(evalNode(node.b, ctx));
452 }
453 const a = evalNode(node.a, ctx);
454 const b = evalNode(node.b, ctx);
455 if (node.op === "==") return looseEq(a, b);
456 if (node.op === "!=") return !looseEq(a, b);
457 const na = toNum(a);
458 const nb = toNum(b);
459 if (Number.isNaN(na) || Number.isNaN(nb)) return false;
460 if (node.op === "<") return na < nb;
461 if (node.op === "<=") return na <= nb;
462 if (node.op === ">") return na > nb;
463 if (node.op === ">=") return na >= nb;
464 return false;
465 }
466 case "call":
467 return evalCall(node, ctx);
468 }
469}
470
471function evalCall(node: Extract<Node, { t: "call" }>, ctx: ConditionalContext): unknown {
472 const name = node.name.toLowerCase();
473 const argVals = node.args.map((a) => evalNode(a, ctx));
474 switch (name) {
475 case "success": {
476 const s = ctx.job?.status;
477 return s !== "failure" && s !== "cancelled";
478 }
479 case "failure":
480 return ctx.job?.status === "failure";
481 case "cancelled":
482 return ctx.job?.status === "cancelled";
483 case "always":
484 return true;
485 case "contains": {
486 const haystack = argVals[0];
487 const needle = argVals[1];
488 if (Array.isArray(haystack)) {
489 for (const it of haystack) {
490 if (looseEq(it, needle)) return true;
491 }
492 return false;
493 }
494 const hs = toStr(haystack);
495 const nd = toStr(needle);
496 if (nd === "") return true;
497 return hs.toLowerCase().includes(nd.toLowerCase());
498 }
499 case "startswith": {
500 const s = toStr(argVals[0]).toLowerCase();
501 const p = toStr(argVals[1]).toLowerCase();
502 return s.startsWith(p);
503 }
504 case "endswith": {
505 const s = toStr(argVals[0]).toLowerCase();
506 const p = toStr(argVals[1]).toLowerCase();
507 return s.endsWith(p);
508 }
509 case "format": {
510 const fmt = toStr(argVals[0]);
511 const rest = argVals.slice(1);
512 return fmt.replace(/\{(\d+)\}/g, (_m, idx) => {
513 const n = Number(idx);
514 if (!Number.isFinite(n) || n < 0 || n >= rest.length) return "";
515 return toStr(rest[n]);
516 });
517 }
518 default:
519 return undefined;
520 }
521}
522
523// ---------------------------------------------------------------------------
524// Public entry point
525// ---------------------------------------------------------------------------
526
527export function evaluateIf(
528 expr: string | undefined | null,
529 ctx: ConditionalContext,
530): EvalResult {
531 if (expr === undefined || expr === null) return { ok: true, value: true };
532 let s = String(expr).trim();
533 if (s.length === 0) return { ok: true, value: true };
534
535 // Strip optional ${{ ... }} wrapper if the whole expression is wrapped.
536 const m = s.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/);
537 if (m && m[1] !== undefined) s = m[1].trim();
538 if (s.length === 0) return { ok: true, value: true };
539
540 const toks = tokenize(s);
541 if (!Array.isArray(toks)) {
542 return { ok: false, error: `parse: ${toks.error}` };
543 }
544
545 let ast: Node;
546 try {
547 const p = new Parser(toks);
548 ast = p.parseOr();
549 if (p.peek().k !== "eof") {
550 return { ok: false, error: `parse: unexpected trailing token ${p.peek().k}` };
551 }
552 } catch (e) {
553 const msg = e instanceof Error ? e.message : String(e);
554 return { ok: false, error: `parse: ${msg}` };
555 }
556
557 try {
558 const v = evalNode(ast, ctx);
559 return { ok: true, value: toBool(v) };
560 } catch (e) {
561 const msg = e instanceof Error ? e.message : String(e);
562 return { ok: false, error: `eval: ${msg}` };
563 }
564}
Addedsrc/lib/workflow-matrix.ts+245−0View fileUnifiedSplit
1/**
2 * workflow-matrix.ts
3 *
4 * Pure-function matrix expansion for workflow jobs. No DB, no I/O, no deps.
5 *
6 * Semantics mirror GitHub Actions `strategy.matrix`:
7 * - Cartesian product of named axes.
8 * - `exclude`: remove any cartesian combo whose keys all match an exclude entry.
9 * - `include`: extend an existing combo if it matches on all of include's keys,
10 * otherwise append as a standalone combo.
11 *
12 * Never throws. On bad input returns [].
13 */
14
15export type MatrixSpec = {
16 axes: Record<string, unknown[]>;
17 include?: Record<string, unknown>[];
18 exclude?: Record<string, unknown>[];
19 failFast?: boolean;
20 maxParallel?: number;
21};
22
23export type MatrixCombo = Record<string, unknown>;
24
25// ---------------------------------------------------------------------------
26// Deep-equality for primitive-holding matrix values.
27// Supports scalars, arrays, and plain objects nested arbitrarily.
28// ---------------------------------------------------------------------------
29
30function deepEqual(a: unknown, b: unknown): boolean {
31 if (a === b) return true;
32 if (a === null || b === null) return a === b;
33 if (typeof a !== typeof b) return false;
34 if (typeof a !== "object") return false;
35 if (Array.isArray(a) !== Array.isArray(b)) return false;
36 if (Array.isArray(a) && Array.isArray(b)) {
37 if (a.length !== b.length) return false;
38 for (let i = 0; i < a.length; i++) {
39 if (!deepEqual(a[i], b[i])) return false;
40 }
41 return true;
42 }
43 const ao = a as Record<string, unknown>;
44 const bo = b as Record<string, unknown>;
45 const ak = Object.keys(ao).sort();
46 const bk = Object.keys(bo).sort();
47 if (ak.length !== bk.length) return false;
48 for (let i = 0; i < ak.length; i++) {
49 if (ak[i] !== bk[i]) return false;
50 if (!deepEqual(ao[ak[i]!], bo[bk[i]!])) return false;
51 }
52 return true;
53}
54
55// A combo matches a partial entry iff every key in the entry deep-equals
56// the same key in the combo. Keys not in the entry are ignored.
57function matchesPartial(combo: MatrixCombo, partial: Record<string, unknown>): boolean {
58 for (const k of Object.keys(partial)) {
59 if (!(k in combo)) return false;
60 if (!deepEqual(combo[k], partial[k])) return false;
61 }
62 return true;
63}
64
65// ---------------------------------------------------------------------------
66// Cartesian expansion.
67// Sort axis keys alphabetically so ordering is deterministic across runs.
68// ---------------------------------------------------------------------------
69
70function cartesian(axes: Record<string, unknown[]>): MatrixCombo[] {
71 const keys = Object.keys(axes).sort();
72 if (keys.length === 0) return [];
73 // If any axis has zero values, the product is empty — matches GitHub Actions.
74 for (const k of keys) {
75 const v = axes[k];
76 if (!Array.isArray(v) || v.length === 0) return [];
77 }
78 let combos: MatrixCombo[] = [{}];
79 for (const k of keys) {
80 const values = axes[k]!;
81 const next: MatrixCombo[] = [];
82 for (const combo of combos) {
83 for (const val of values) {
84 next.push({ ...combo, [k]: val });
85 }
86 }
87 combos = next;
88 }
89 return combos;
90}
91
92export function expandMatrix(spec: MatrixSpec): MatrixCombo[] {
93 if (!spec || typeof spec !== "object") return [];
94 const axes = spec.axes;
95 const include = Array.isArray(spec.include) ? spec.include : [];
96 const exclude = Array.isArray(spec.exclude) ? spec.exclude : [];
97
98 // Validate axes: must be a plain object mapping string -> array.
99 let validAxes: Record<string, unknown[]> = {};
100 if (axes && typeof axes === "object" && !Array.isArray(axes)) {
101 for (const k of Object.keys(axes)) {
102 const v = (axes as Record<string, unknown>)[k];
103 if (!Array.isArray(v)) return [];
104 validAxes[k] = v;
105 }
106 } else if (axes !== undefined && axes !== null) {
107 return [];
108 }
109
110 // 1. Cartesian product (possibly empty if no axes).
111 let combos: MatrixCombo[] = cartesian(validAxes);
112
113 // 2. Apply exclude. An exclude entry removes any cartesian combo that
114 // partially matches all of the exclude's keys.
115 if (exclude.length > 0) {
116 combos = combos.filter((combo) => {
117 for (const ex of exclude) {
118 if (ex && typeof ex === "object" && matchesPartial(combo, ex)) return false;
119 }
120 return true;
121 });
122 }
123
124 // 3. Apply include. For each include entry:
125 // - If it fully matches an existing combo (partial match on include's
126 // keys), merge extra keys into that combo.
127 // - Otherwise append as a standalone combo.
128 // "Matches an existing combo" means the include entry's keys that are
129 // also axis keys deep-equal the combo's values for those keys.
130 const axisKeySet = new Set(Object.keys(validAxes));
131 for (const inc of include) {
132 if (!inc || typeof inc !== "object") continue;
133 // Build the matcher: only axis-key fields count for matching.
134 const matcher: Record<string, unknown> = {};
135 let hasAxisKeys = false;
136 for (const k of Object.keys(inc)) {
137 if (axisKeySet.has(k)) {
138 matcher[k] = inc[k];
139 hasAxisKeys = true;
140 }
141 }
142 let extended = false;
143 if (hasAxisKeys && combos.length > 0) {
144 for (const combo of combos) {
145 if (matchesPartial(combo, matcher)) {
146 // Extend with extra (non-axis or additive) keys that are not already
147 // present in the combo. GitHub Actions semantics: include's extra
148 // fields are added, but they never overwrite an axis value.
149 for (const k of Object.keys(inc)) {
150 if (!(k in combo)) combo[k] = inc[k];
151 }
152 extended = true;
153 }
154 }
155 }
156 if (!extended) {
157 // Append as a standalone combo (copy to avoid aliasing caller's object).
158 combos.push({ ...inc });
159 }
160 }
161
162 return combos;
163}
164
165// ---------------------------------------------------------------------------
166// validateMatrix — type-guard / sanitiser for untrusted input.
167// ---------------------------------------------------------------------------
168
169export function validateMatrix(
170 spec: unknown,
171): { ok: true; spec: MatrixSpec } | { ok: false; error: string } {
172 if (spec === null || spec === undefined) {
173 return { ok: false, error: "matrix: spec is null or undefined" };
174 }
175 if (typeof spec !== "object" || Array.isArray(spec)) {
176 return { ok: false, error: "matrix: spec must be an object" };
177 }
178 const s = spec as Record<string, unknown>;
179
180 const axes: Record<string, unknown[]> = {};
181 const rawAxes = s.axes;
182 if (rawAxes !== undefined && rawAxes !== null) {
183 if (typeof rawAxes !== "object" || Array.isArray(rawAxes)) {
184 return { ok: false, error: "matrix: axes must be an object" };
185 }
186 for (const k of Object.keys(rawAxes as object)) {
187 const v = (rawAxes as Record<string, unknown>)[k];
188 if (!Array.isArray(v)) {
189 return { ok: false, error: `matrix: axis "${k}" must be an array` };
190 }
191 axes[k] = v;
192 }
193 }
194
195 let include: Record<string, unknown>[] | undefined;
196 if (s.include !== undefined && s.include !== null) {
197 if (!Array.isArray(s.include)) {
198 return { ok: false, error: "matrix: include must be an array" };
199 }
200 include = [];
201 for (let i = 0; i < s.include.length; i++) {
202 const e = s.include[i];
203 if (!e || typeof e !== "object" || Array.isArray(e)) {
204 return { ok: false, error: `matrix: include[${i}] must be an object` };
205 }
206 include.push(e as Record<string, unknown>);
207 }
208 }
209
210 let exclude: Record<string, unknown>[] | undefined;
211 if (s.exclude !== undefined && s.exclude !== null) {
212 if (!Array.isArray(s.exclude)) {
213 return { ok: false, error: "matrix: exclude must be an array" };
214 }
215 exclude = [];
216 for (let i = 0; i < s.exclude.length; i++) {
217 const e = s.exclude[i];
218 if (!e || typeof e !== "object" || Array.isArray(e)) {
219 return { ok: false, error: `matrix: exclude[${i}] must be an object` };
220 }
221 exclude.push(e as Record<string, unknown>);
222 }
223 }
224
225 let failFast: boolean | undefined;
226 if (s.failFast !== undefined) {
227 if (typeof s.failFast !== "boolean") {
228 return { ok: false, error: "matrix: failFast must be boolean" };
229 }
230 failFast = s.failFast;
231 }
232
233 let maxParallel: number | undefined;
234 if (s.maxParallel !== undefined) {
235 if (typeof s.maxParallel !== "number" || !Number.isFinite(s.maxParallel) || s.maxParallel < 1) {
236 return { ok: false, error: "matrix: maxParallel must be a positive number" };
237 }
238 maxParallel = Math.floor(s.maxParallel);
239 }
240
241 return {
242 ok: true,
243 spec: { axes, include, exclude, failFast, maxParallel },
244 };
245}
Addedsrc/lib/workflow-secrets-crypto.ts+0−0View fileUnifiedSplit
Binary file not shown.
Addedsrc/lib/workflow-secrets.ts+239−0View fileUnifiedSplit
1/**
2 * Workflow secrets — DB-backed CRUD + runtime context loader.
3 *
4 * Sibling to `workflow-secrets-crypto.ts`, which owns the pure AES-256-GCM
5 * primitives. This file is the *only* place outside the runner that touches
6 * `workflowSecrets` rows. All public fns follow the project-wide
7 * `{ok:true,...}` / `{ok:false,error}` contract and never throw — DB errors
8 * are caught and collapsed to a terse error string.
9 *
10 * Callers:
11 * - Agent 7 (settings UI) uses `listRepoSecrets`, `upsertRepoSecret`,
12 * `deleteRepoSecret` to render + mutate the per-repo secrets table.
13 * - Agent 5 (workflow runner) calls `loadSecretsContext` once at run start
14 * to build the `{ NAME: plaintext }` map that feeds
15 * `substituteSecrets(template, secrets)` for every step's `run:` / `env:`.
16 *
17 * Security notes:
18 * - `listRepoSecrets` never returns plaintext or ciphertext — UI only sees
19 * metadata (name, createdAt, createdBy).
20 * - `deleteRepoSecret` is scoped on both (repoId, secretId) to prevent a
21 * caller with one repo's context from deleting another repo's secret by
22 * guessing a UUID.
23 * - `loadSecretsContext` silently omits secrets whose decryption fails so
24 * the `${{ secrets.NAME }}` template left intact in logs — that's a louder
25 * failure signal than substituting empty string.
26 */
27
28import { and, asc, eq } from "drizzle-orm";
29import { db } from "../db";
30import { workflowSecrets } from "../db/schema";
31import { decryptSecret, encryptSecret, getMasterKey } from "./workflow-secrets-crypto";
32
33/** Matches GitHub Actions secret-name rules: uppercase + digits + underscore,
34 * must not start with a digit, max 100 chars. Case is enforced strictly. */
35const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
36const MAX_NAME_LEN = 100;
37
38export type SecretMetadata = {
39 id: string;
40 name: string;
41 createdAt: Date;
42 createdBy: string | null;
43};
44
45/**
46 * List a repository's secrets — metadata only.
47 *
48 * Returns rows ordered by `name` ascending. Plaintext and ciphertext are
49 * deliberately excluded so this fn is safe to call from a web handler that
50 * renders the settings page.
51 */
52export async function listRepoSecrets(
53 repoId: string
54): Promise<
55 | { ok: true; secrets: SecretMetadata[] }
56 | { ok: false; error: string }
57> {
58 if (typeof repoId !== "string" || repoId.length === 0) {
59 return { ok: false, error: "repoId is required" };
60 }
61 try {
62 const rows = await db
63 .select({
64 id: workflowSecrets.id,
65 name: workflowSecrets.name,
66 createdAt: workflowSecrets.createdAt,
67 createdBy: workflowSecrets.createdBy,
68 })
69 .from(workflowSecrets)
70 .where(eq(workflowSecrets.repositoryId, repoId))
71 .orderBy(asc(workflowSecrets.name));
72 return { ok: true, secrets: rows };
73 } catch (err) {
74 console.error("[workflow-secrets] listRepoSecrets:", err);
75 return { ok: false, error: "db query failed" };
76 }
77}
78
79/**
80 * Create or update a repository secret. Name must match `[A-Z_][A-Z0-9_]*`
81 * (max 100 chars) and value is encrypted under the master key before being
82 * written. On conflict (repo+name already exists) the existing row is
83 * replaced and its `updated_at` bumped; `created_at` and `created_by` stay
84 * pinned to the original insert for audit history.
85 */
86export async function upsertRepoSecret(args: {
87 repoId: string;
88 name: string;
89 value: string;
90 createdBy: string;
91}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
92 const { repoId, name, value, createdBy } = args;
93 if (typeof repoId !== "string" || repoId.length === 0) {
94 return { ok: false, error: "repoId is required" };
95 }
96 if (typeof createdBy !== "string" || createdBy.length === 0) {
97 return { ok: false, error: "createdBy is required" };
98 }
99 if (typeof name !== "string" || name.length === 0) {
100 return { ok: false, error: "name is required" };
101 }
102 if (name.length > MAX_NAME_LEN) {
103 return { ok: false, error: `name must be <= ${MAX_NAME_LEN} chars` };
104 }
105 if (!SECRET_NAME_RE.test(name)) {
106 return {
107 ok: false,
108 error: "name must match /^[A-Z_][A-Z0-9_]*$/ (uppercase, digits, underscore; not leading digit)",
109 };
110 }
111 if (typeof value !== "string") {
112 return { ok: false, error: "value must be a string" };
113 }
114
115 const enc = encryptSecret(value);
116 if (!enc.ok) return { ok: false, error: enc.error };
117
118 try {
119 const rows = await db
120 .insert(workflowSecrets)
121 .values({
122 repositoryId: repoId,
123 name,
124 encryptedValue: enc.ciphertext,
125 createdBy,
126 })
127 .onConflictDoUpdate({
128 target: [workflowSecrets.repositoryId, workflowSecrets.name],
129 set: {
130 encryptedValue: enc.ciphertext,
131 updatedAt: new Date(),
132 },
133 })
134 .returning({ id: workflowSecrets.id });
135 const id = rows[0]?.id;
136 if (!id) return { ok: false, error: "insert returned no row" };
137 return { ok: true, id };
138 } catch (err) {
139 console.error("[workflow-secrets] upsertRepoSecret:", err);
140 return {
141 ok: false,
142 error: `db write failed: ${err instanceof Error ? err.message : String(err)}`,
143 };
144 }
145}
146
147/**
148 * Delete a single secret by id, scoped to its repository. Both filters must
149 * match — this is defence-in-depth against a caller that has a secretId but
150 * the wrong repo context (e.g. path-param manipulation).
151 *
152 * Returns `{ok:true}` even if no row matched; the caller can't distinguish
153 * "deleted" from "never existed" and that's fine for an idempotent DELETE.
154 */
155export async function deleteRepoSecret(args: {
156 repoId: string;
157 secretId: string;
158}): Promise<{ ok: true } | { ok: false; error: string }> {
159 const { repoId, secretId } = args;
160 if (typeof repoId !== "string" || repoId.length === 0) {
161 return { ok: false, error: "repoId is required" };
162 }
163 if (typeof secretId !== "string" || secretId.length === 0) {
164 return { ok: false, error: "secretId is required" };
165 }
166 try {
167 await db
168 .delete(workflowSecrets)
169 .where(
170 and(
171 eq(workflowSecrets.id, secretId),
172 eq(workflowSecrets.repositoryId, repoId)
173 )
174 );
175 return { ok: true };
176 } catch (err) {
177 console.error("[workflow-secrets] deleteRepoSecret:", err);
178 return {
179 ok: false,
180 error: `db delete failed: ${err instanceof Error ? err.message : String(err)}`,
181 };
182 }
183}
184
185/**
186 * Build the `{ NAME: plaintext }` map consumed by the runner's
187 * `substituteSecrets(template, secrets)` calls.
188 *
189 * Graceful-degrade semantics (intentional — the runner MUST NOT crash here):
190 * - Master key missing → returns `{}`. A warning is logged. Every
191 * `${{ secrets.X }}` token will pass through untouched, making the
192 * misconfiguration visible in job logs.
193 * - Individual decryption failure (tampered row, key rotated, etc.) → that
194 * secret is skipped; others still load.
195 * - DB error → returns `{}`.
196 *
197 * Note: this returns the raw map, not a `{ok,...}` tuple, because the runner
198 * wants a hot path with no branching at call sites.
199 */
200export async function loadSecretsContext(
201 repoId: string
202): Promise<Record<string, string>> {
203 if (typeof repoId !== "string" || repoId.length === 0) {
204 return {};
205 }
206 if (!getMasterKey()) {
207 console.error(
208 "[workflow-secrets] loadSecretsContext: WORKFLOW_SECRETS_KEY missing; secrets will not be substituted"
209 );
210 return {};
211 }
212
213 let rows: { name: string; encryptedValue: string }[];
214 try {
215 rows = await db
216 .select({
217 name: workflowSecrets.name,
218 encryptedValue: workflowSecrets.encryptedValue,
219 })
220 .from(workflowSecrets)
221 .where(eq(workflowSecrets.repositoryId, repoId));
222 } catch (err) {
223 console.error("[workflow-secrets] loadSecretsContext db error:", err);
224 return {};
225 }
226
227 const out: Record<string, string> = {};
228 for (const row of rows) {
229 const dec = decryptSecret(row.encryptedValue);
230 if (!dec.ok) {
231 console.error(
232 `[workflow-secrets] decrypt failed for secret "${row.name}": ${dec.error}`
233 );
234 continue;
235 }
236 out[row.name] = dec.plaintext;
237 }
238 return out;
239}
Addedsrc/routes/workflow-artifacts.ts+390−0View fileUnifiedSplit
1/**
2 * REST API for workflow run artifacts (Block C1 / Sprint 1 — Agent 6).
3 *
4 * Mount point: `/api/v1/...` — the main thread wires this in via
5 * `app.route('/', artifactsRoutes)` in `app.tsx` (out of scope for this agent).
6 *
7 * Endpoints
8 * ---------
9 * POST /api/v1/runs/:runId/artifacts → create artifact (multipart or JSON+base64)
10 * GET /api/v1/runs/:runId/artifacts → list artifact metadata
11 * GET /api/v1/artifacts/:artifactId/download → download binary
12 * DELETE /api/v1/artifacts/:artifactId → delete
13 *
14 * Auth
15 * ----
16 * `Authorization: Bearer <glc_...>` PAT. The token row in `api_tokens`
17 * carries comma-separated scopes (see `src/routes/tokens.tsx`). For write
18 * operations we require the token to have `repo` / `write` / `admin`; for
19 * deletes we require `admin`. List/download allow public-repo anonymous
20 * access (falls back to session cookie or unauthenticated for public repos).
21 *
22 * NOTE: we intentionally DO NOT use the existing `requireAuth` middleware
23 * here — it redirects cookie-less requests to `/login` which is wrong for
24 * an API client. Instead we resolve the bearer ourselves at the top of each
25 * handler.
26 */
27
28import { Hono } from "hono";
29import { and, eq } from "drizzle-orm";
30import { db } from "../db";
31import {
32 apiTokens,
33 repositories,
34 users,
35 workflowRuns,
36} from "../db/schema";
37import type { User } from "../db/schema";
38import { sha256Hex } from "../lib/oauth";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41import {
42 uploadArtifact,
43 listArtifacts,
44 downloadArtifact,
45 deleteArtifact,
46 getRunRepositoryId,
47 getArtifactRunId,
48 MAX_ARTIFACT_BYTES,
49} from "../lib/workflow-artifacts";
50
51const app = new Hono<AuthEnv>();
52
53// Soft-auth so that public-repo GET requests with no creds still resolve
54// `c.get("user") === null` cleanly (rather than touching the DB inside each
55// handler). Write/delete handlers re-resolve the bearer themselves below
56// because they also need the PAT scope list.
57app.use("/api/v1/runs/*", softAuth);
58app.use("/api/v1/artifacts/*", softAuth);
59
60// ---------------------------------------------------------------------------
61// Bearer-PAT helper (~30 lines per sprint notes).
62// Returns the user + scopes if the header carries a valid `glc_` PAT, else
63// null. We don't handle `glct_` OAuth tokens here — the API surface for
64// workflow artifacts is PAT-oriented.
65// ---------------------------------------------------------------------------
66
67async function resolveBearer(
68 authHeader: string | undefined
69): Promise<{ user: User; scopes: string[] } | null> {
70 if (!authHeader) return null;
71 const lower = authHeader.toLowerCase();
72 if (!lower.startsWith("bearer ")) return null;
73 const token = authHeader.slice(7).trim();
74 if (!token.startsWith("glc_")) return null;
75 try {
76 const hash = await sha256Hex(token);
77 const [row] = await db
78 .select()
79 .from(apiTokens)
80 .where(eq(apiTokens.tokenHash, hash))
81 .limit(1);
82 if (!row) return null;
83 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
84 const [user] = await db
85 .select()
86 .from(users)
87 .where(eq(users.id, row.userId))
88 .limit(1);
89 if (!user) return null;
90 const scopes = row.scopes
91 ? row.scopes.split(/[,\s]+/).filter(Boolean)
92 : [];
93 return { user, scopes };
94 } catch (err) {
95 console.error("[workflow-artifacts] resolveBearer:", err);
96 return null;
97 }
98}
99
100/** Scope checks. Our PATs use names like `repo` / `user` / `admin`. We also
101 * accept the literal names from the spec (`read` / `write` / `admin`) so the
102 * action runner and CI clients can pick whichever feels natural. */
103function hasReadScope(scopes: string[]): boolean {
104 return (
105 scopes.includes("repo") ||
106 scopes.includes("read") ||
107 scopes.includes("write") ||
108 scopes.includes("admin")
109 );
110}
111function hasWriteScope(scopes: string[]): boolean {
112 return (
113 scopes.includes("repo") ||
114 scopes.includes("write") ||
115 scopes.includes("admin")
116 );
117}
118function hasAdminScope(scopes: string[]): boolean {
119 return scopes.includes("admin");
120}
121
122async function loadRepoOwner(repositoryId: string): Promise<
123 | { id: string; ownerId: string; isPrivate: boolean }
124 | null
125> {
126 try {
127 const [row] = await db
128 .select({
129 id: repositories.id,
130 ownerId: repositories.ownerId,
131 isPrivate: repositories.isPrivate,
132 })
133 .from(repositories)
134 .where(eq(repositories.id, repositoryId))
135 .limit(1);
136 return row || null;
137 } catch (err) {
138 console.error("[workflow-artifacts] loadRepoOwner:", err);
139 return null;
140 }
141}
142
143// ---------------------------------------------------------------------------
144// POST /api/v1/runs/:runId/artifacts — upload
145// ---------------------------------------------------------------------------
146
147app.post("/api/v1/runs/:runId/artifacts", async (c) => {
148 const runId = c.req.param("runId");
149
150 const bearer = await resolveBearer(c.req.header("authorization"));
151 if (!bearer) {
152 return c.json({ error: "authentication required" }, 401);
153 }
154 if (!hasWriteScope(bearer.scopes)) {
155 return c.json({ error: "token missing write scope" }, 403);
156 }
157
158 const repositoryId = await getRunRepositoryId(runId);
159 if (!repositoryId) {
160 return c.json({ error: "run not found" }, 404);
161 }
162 const repo = await loadRepoOwner(repositoryId);
163 if (!repo) {
164 return c.json({ error: "repository not found" }, 404);
165 }
166 if (repo.ownerId !== bearer.user.id) {
167 return c.json({ error: "forbidden" }, 403);
168 }
169
170 // Parse body — either multipart/form-data or JSON with base64 `content`.
171 const ctype = (c.req.header("content-type") || "").toLowerCase();
172 let name: string | undefined;
173 let jobId: string | undefined;
174 let contentType: string | undefined;
175 let content: Buffer | undefined;
176
177 try {
178 if (ctype.startsWith("application/json")) {
179 const body = await c.req.json<{
180 name?: string;
181 jobId?: string;
182 contentType?: string;
183 content?: string; // base64
184 }>();
185 name = body.name;
186 jobId = body.jobId;
187 contentType = body.contentType;
188 if (typeof body.content === "string") {
189 content = Buffer.from(body.content, "base64");
190 }
191 } else {
192 // Treat everything else (multipart, x-www-form-urlencoded) as form data.
193 const form = await c.req.parseBody({ all: false });
194 const n = form["name"];
195 const j = form["jobId"];
196 const ct = form["contentType"];
197 const f = form["content"];
198 if (typeof n === "string") name = n;
199 if (typeof j === "string") jobId = j;
200 if (typeof ct === "string") contentType = ct;
201 if (f instanceof File) {
202 const ab = await f.arrayBuffer();
203 content = Buffer.from(ab);
204 if (!contentType) contentType = f.type || undefined;
205 if (!name) name = f.name;
206 } else if (typeof f === "string") {
207 // Fallback: raw text content in a form field.
208 content = Buffer.from(f, "utf8");
209 }
210 }
211 } catch (err) {
212 console.error("[workflow-artifacts] parse body:", err);
213 return c.json({ error: "invalid request body" }, 400);
214 }
215
216 if (!name) return c.json({ error: "name is required" }, 400);
217 if (!jobId) return c.json({ error: "jobId is required" }, 400);
218 if (!content) return c.json({ error: "content is required" }, 400);
219
220 if (content.byteLength > MAX_ARTIFACT_BYTES) {
221 return c.json({ error: "payload exceeds 100MB limit" }, 413);
222 }
223
224 const result = await uploadArtifact({
225 runId,
226 jobId,
227 name,
228 content,
229 contentType,
230 });
231 if (!result.ok) {
232 // Treat validation errors as 400; other helper errors as 500.
233 const msg = result.error;
234 const status =
235 msg.startsWith("name ") ||
236 msg.includes("exceeds") ||
237 msg.includes("required")
238 ? 400
239 : 500;
240 return c.json({ error: msg }, status);
241 }
242
243 return c.json(
244 {
245 id: result.artifactId,
246 name,
247 size: content.byteLength,
248 contentType: contentType || "application/octet-stream",
249 downloadUrl: `/api/v1/artifacts/${result.artifactId}/download`,
250 },
251 201
252 );
253});
254
255// ---------------------------------------------------------------------------
256// GET /api/v1/runs/:runId/artifacts — list
257// ---------------------------------------------------------------------------
258
259app.get("/api/v1/runs/:runId/artifacts", async (c) => {
260 const runId = c.req.param("runId");
261 const repositoryId = await getRunRepositoryId(runId);
262 if (!repositoryId) return c.json({ error: "run not found" }, 404);
263 const repo = await loadRepoOwner(repositoryId);
264 if (!repo) return c.json({ error: "repository not found" }, 404);
265
266 const bearer = await resolveBearer(c.req.header("authorization"));
267 const cookieUser = c.get("user");
268
269 // Auth logic:
270 // - public repo → anyone with a valid bearer OR cookie session reads.
271 // Also allow totally-anonymous reads (matches packages + web UI style
272 // for public resources).
273 // - private repo → require bearer with read scope OR cookie session,
274 // AND caller must be the repo owner.
275 if (repo.isPrivate) {
276 let userId: string | null = null;
277 if (bearer) {
278 if (!hasReadScope(bearer.scopes)) {
279 return c.json({ error: "token missing read scope" }, 403);
280 }
281 userId = bearer.user.id;
282 } else if (cookieUser) {
283 userId = cookieUser.id;
284 } else {
285 return c.json({ error: "authentication required" }, 401);
286 }
287 if (userId !== repo.ownerId) {
288 return c.json({ error: "forbidden" }, 403);
289 }
290 } else if (bearer && !hasReadScope(bearer.scopes)) {
291 // Public repo but caller presented a weird-scoped token. Don't silently
292 // upgrade — reject.
293 return c.json({ error: "token missing read scope" }, 403);
294 }
295
296 const result = await listArtifacts(runId);
297 if (!result.ok) return c.json({ error: result.error }, 500);
298 return c.json({ artifacts: result.artifacts });
299});
300
301// ---------------------------------------------------------------------------
302// GET /api/v1/artifacts/:artifactId/download — binary download
303// ---------------------------------------------------------------------------
304
305app.get("/api/v1/artifacts/:artifactId/download", async (c) => {
306 const artifactId = c.req.param("artifactId");
307
308 const runId = await getArtifactRunId(artifactId);
309 if (!runId) return c.json({ error: "not found" }, 404);
310 const repositoryId = await getRunRepositoryId(runId);
311 if (!repositoryId) return c.json({ error: "not found" }, 404);
312 const repo = await loadRepoOwner(repositoryId);
313 if (!repo) return c.json({ error: "not found" }, 404);
314
315 const bearer = await resolveBearer(c.req.header("authorization"));
316 const cookieUser = c.get("user");
317
318 if (repo.isPrivate) {
319 let userId: string | null = null;
320 if (bearer) {
321 if (!hasReadScope(bearer.scopes)) {
322 return c.json({ error: "token missing read scope" }, 403);
323 }
324 userId = bearer.user.id;
325 } else if (cookieUser) {
326 userId = cookieUser.id;
327 } else {
328 return c.json({ error: "authentication required" }, 401);
329 }
330 if (userId !== repo.ownerId) {
331 return c.json({ error: "forbidden" }, 403);
332 }
333 } else if (bearer && !hasReadScope(bearer.scopes)) {
334 return c.json({ error: "token missing read scope" }, 403);
335 }
336
337 const result = await downloadArtifact(artifactId);
338 if (!result.ok) {
339 const status = result.error === "not found" ? 404 : 500;
340 return c.json({ error: result.error }, status);
341 }
342
343 // Sanitize filename for Content-Disposition. Artifact name regex is
344 // already `[A-Za-z0-9._-]+` so nothing dangerous can slip in, but we still
345 // strip any stray quotes/newlines defensively.
346 const safeName = result.name.replace(/["\r\n]/g, "");
347
348 return new Response(result.content as BodyInit, {
349 status: 200,
350 headers: {
351 "Content-Type": result.contentType,
352 "Content-Disposition": `attachment; filename="${safeName}"`,
353 "Content-Length": String(result.content.byteLength),
354 "Cache-Control": "private, max-age=0, no-store",
355 },
356 });
357});
358
359// ---------------------------------------------------------------------------
360// DELETE /api/v1/artifacts/:artifactId
361// ---------------------------------------------------------------------------
362
363app.delete("/api/v1/artifacts/:artifactId", async (c) => {
364 const artifactId = c.req.param("artifactId");
365
366 const bearer = await resolveBearer(c.req.header("authorization"));
367 if (!bearer) return c.json({ error: "authentication required" }, 401);
368 if (!hasAdminScope(bearer.scopes)) {
369 return c.json({ error: "admin scope required" }, 403);
370 }
371
372 const runId = await getArtifactRunId(artifactId);
373 if (!runId) return c.body(null, 404);
374 const repositoryId = await getRunRepositoryId(runId);
375 if (!repositoryId) return c.body(null, 404);
376 const repo = await loadRepoOwner(repositoryId);
377 if (!repo) return c.body(null, 404);
378 if (repo.ownerId !== bearer.user.id) {
379 return c.json({ error: "forbidden" }, 403);
380 }
381
382 const result = await deleteArtifact(artifactId);
383 if (!result.ok) {
384 const status = result.error === "not found" ? 404 : 500;
385 return c.json({ error: result.error }, status);
386 }
387 return c.body(null, 204);
388});
389
390export default app;
Addedsrc/routes/workflow-secrets.tsx+411−0View fileUnifiedSplit
1/**
2 * Per-repo workflow secrets — settings UI for listing, creating, and deleting
3 * encrypted secrets that are substituted into workflow steps at runtime.
4 *
5 * Shape mirrors `src/routes/tokens.tsx` + `src/routes/webhooks.tsx`:
6 * - JSX page rendered through Layout + RepoHeader + RepoNav (active=settings).
7 * - Flash state via `?added=NAME | ?deleted=1 | ?error=...` query params.
8 * - Every mutating route is gated on `requireAuth` + `requireRepoAccess("admin")`.
9 *
10 * The UI never sees plaintext values after upsert — the `listRepoSecrets`
11 * helper in `../lib/workflow-secrets` returns metadata only (id, name,
12 * createdAt, createdBy). This file's sole job is to render and mutate.
13 */
14
15import { Hono } from "hono";
16import { eq, inArray } from "drizzle-orm";
17import { db } from "../db";
18import { users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { requireRepoAccess } from "../middleware/repo-access";
24import {
25 Container,
26 Alert,
27 EmptyState,
28 Button,
29 Text,
30 formatRelative,
31} from "../views/ui";
32import {
33 listRepoSecrets,
34 upsertRepoSecret,
35 deleteRepoSecret,
36} from "../lib/workflow-secrets";
37import { audit } from "../lib/notify";
38
39const workflowSecretsRoutes = new Hono<AuthEnv>();
40
41workflowSecretsRoutes.use("*", softAuth);
42
43const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
44const MAX_NAME_LEN = 100;
45const MAX_VALUE_LEN = 32768;
46
47/** Sub-nav shown under the main RepoNav for repo settings pages. */
48function SettingsSubNav({
49 owner,
50 repo,
51 active,
52}: {
53 owner: string;
54 repo: string;
55 active: "general" | "collaborators" | "webhooks" | "secrets";
56}) {
57 const link = (
58 href: string,
59 label: string,
60 key: "general" | "collaborators" | "webhooks" | "secrets"
61 ) => (
62 <a
63 href={href}
64 style={
65 "padding: 6px 12px; border-radius: 6px; text-decoration: none; " +
66 (active === key
67 ? "background: var(--bg-secondary); color: var(--text); font-weight: 600"
68 : "color: var(--text-muted)")
69 }
70 >
71 {label}
72 </a>
73 );
74 return (
75 <div
76 style="display: flex; gap: 4px; margin: 12px 0 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border)"
77 >
78 {link(`/${owner}/${repo}/settings`, "General", "general")}
79 {link(
80 `/${owner}/${repo}/settings/collaborators`,
81 "Collaborators",
82 "collaborators"
83 )}
84 {link(
85 `/${owner}/${repo}/settings/webhooks`,
86 "Webhooks",
87 "webhooks"
88 )}
89 {link(
90 `/${owner}/${repo}/settings/secrets`,
91 "Secrets",
92 "secrets"
93 )}
94 </div>
95 );
96}
97
98// ─── GET: List + add form ───────────────────────────────────────────────────
99
100workflowSecretsRoutes.get(
101 "/:owner/:repo/settings/secrets",
102 requireAuth,
103 requireRepoAccess("admin"),
104 async (c) => {
105 const { owner: ownerName, repo: repoName } = c.req.param();
106 const user = c.get("user")!;
107 const repo = c.get("repository") as { id: string } | undefined;
108
109 const added = c.req.query("added");
110 const deleted = c.req.query("deleted");
111 const error = c.req.query("error");
112
113 if (!repo) {
114 return c.notFound();
115 }
116
117 const result = await listRepoSecrets(repo.id);
118 const secrets = result.ok ? result.secrets : [];
119 const loadError = result.ok ? null : result.error;
120
121 // Resolve createdBy user ids -> usernames for display/linking.
122 const creatorIds = Array.from(
123 new Set(secrets.map((s) => s.createdBy).filter(Boolean) as string[])
124 );
125 const creatorRows = creatorIds.length
126 ? await db
127 .select({ id: users.id, username: users.username })
128 .from(users)
129 .where(inArray(users.id, creatorIds))
130 : [];
131 const creatorMap = new Map(creatorRows.map((r) => [r.id, r.username]));
132
133 return c.html(
134 <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}>
135 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
136 <RepoNav owner={ownerName} repo={repoName} active="settings" />
137 <Container maxWidth={760}>
138 <SettingsSubNav
139 owner={ownerName}
140 repo={repoName}
141 active="secrets"
142 />
143
144 <h2 style="margin-bottom: 8px">Workflow secrets</h2>
145 <Text size={14} muted style="display:block;margin-bottom:20px">
146 Secrets are encrypted at rest and only decrypted at workflow
147 runtime. They are never printed to logs. Reference them in YAML
148 as{" "}
149 <code style="font-family: var(--font-mono); background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px">
150 {"${{ secrets.NAME }}"}
151 </code>
152 . Values are write-only — after saving, only the name is
153 visible.
154 </Text>
155
156 {added && (
157 <Alert variant="success">
158 Secret <code>{decodeURIComponent(added)}</code> saved.
159 </Alert>
160 )}
161 {deleted && <Alert variant="success">Secret deleted.</Alert>}
162 {error && (
163 <Alert variant="error">{decodeURIComponent(error)}</Alert>
164 )}
165 {loadError && (
166 <Alert variant="error">
167 Could not load secrets: {loadError}
168 </Alert>
169 )}
170
171 <div class="panel" style="margin-top: 16px; padding: 0; overflow: hidden">
172 {secrets.length === 0 ? (
173 <div style="padding: 24px">
174 <EmptyState>
175 <Text muted>No secrets yet.</Text>
176 </EmptyState>
177 </div>
178 ) : (
179 <table
180 class="file-table"
181 style="width: 100%; border-collapse: collapse"
182 >
183 <thead>
184 <tr style="background: var(--bg-secondary); text-align: left">
185 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
186 Name
187 </th>
188 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
189 Added by
190 </th>
191 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
192 Added
193 </th>
194 <th style="padding: 10px 14px" />
195 </tr>
196 </thead>
197 <tbody>
198 {secrets.map((s) => {
199 const creator = s.createdBy
200 ? creatorMap.get(s.createdBy)
201 : null;
202 return (
203 <tr style="border-top: 1px solid var(--border)">
204 <td style="padding: 10px 14px">
205 <code
206 style="font-family: var(--font-mono); font-size: 13px"
207 >
208 {s.name}
209 </code>
210 </td>
211 <td style="padding: 10px 14px; font-size: 13px">
212 {creator ? (
213 <a href={`/${creator}`}>{creator}</a>
214 ) : (
215 <span style="color: var(--text-muted)">
216 unknown
217 </span>
218 )}
219 </td>
220 <td
221 style="padding: 10px 14px; font-size: 13px; color: var(--text-muted)"
222 title={
223 s.createdAt
224 ? new Date(s.createdAt).toISOString()
225 : ""
226 }
227 >
228 {s.createdAt
229 ? formatRelative(s.createdAt)
230 : "—"}
231 </td>
232 <td style="padding: 10px 14px; text-align: right">
233 <form
234 method="post"
235 action={`/${ownerName}/${repoName}/settings/secrets/${s.id}/delete`}
236 style="display: inline"
237 onsubmit={`return confirm('Delete secret ${s.name}?')`}
238 >
239 <Button
240 type="submit"
241 variant="danger"
242 size="sm"
243 >
244 Delete
245 </Button>
246 </form>
247 </td>
248 </tr>
249 );
250 })}
251 </tbody>
252 </table>
253 )}
254 </div>
255
256 <h3 style="margin-top: 32px; margin-bottom: 4px">
257 Add a new secret
258 </h3>
259 <Text size={13} muted style="display:block;margin-bottom:12px">
260 Names must be uppercase letters, digits, and underscores, and
261 cannot start with a digit. Adding a secret with an existing
262 name replaces the stored value.
263 </Text>
264 <form
265 method="post"
266 action={`/${ownerName}/${repoName}/settings/secrets`}
267 >
268 <div class="form-group">
269 <label for="secret-name">Name</label>
270 <input
271 type="text"
272 id="secret-name"
273 name="name"
274 required
275 pattern="[A-Z_][A-Z0-9_]*"
276 maxlength={MAX_NAME_LEN}
277 placeholder="DEPLOY_TOKEN"
278 autocomplete="off"
279 style="font-family: var(--font-mono)"
280 title="Uppercase letters, digits, and underscores; cannot start with a digit"
281 />
282 </div>
283 <div class="form-group">
284 <label for="secret-value">Value</label>
285 <textarea
286 id="secret-value"
287 name="value"
288 required
289 rows={4}
290 maxlength={MAX_VALUE_LEN}
291 placeholder="Paste secret value"
292 autocomplete="off"
293 spellcheck={false}
294 style="width: 100%; font-family: var(--font-mono); font-size: 13px"
295 />
296 </div>
297 <button type="submit" class="btn btn-primary">
298 Add secret
299 </button>
300 </form>
301 </Container>
302 </Layout>
303 );
304 }
305);
306
307// ─── POST: Create / update ──────────────────────────────────────────────────
308
309workflowSecretsRoutes.post(
310 "/:owner/:repo/settings/secrets",
311 requireAuth,
312 requireRepoAccess("admin"),
313 async (c) => {
314 const { owner: ownerName, repo: repoName } = c.req.param();
315 const user = c.get("user")!;
316 const repo = c.get("repository") as { id: string } | undefined;
317 if (!repo) return c.notFound();
318
319 const body = await c.req.parseBody();
320 const name = String(body.name || "").trim();
321 const value = typeof body.value === "string" ? body.value : "";
322
323 const base = `/${ownerName}/${repoName}/settings/secrets`;
324 const fail = (msg: string) =>
325 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
326
327 if (!name) return fail("Name is required");
328 if (name.length > MAX_NAME_LEN)
329 return fail(`Name must be ≤ ${MAX_NAME_LEN} characters`);
330 if (!SECRET_NAME_RE.test(name))
331 return fail(
332 "Name must be uppercase letters, digits, and underscores, and cannot start with a digit"
333 );
334 if (!value) return fail("Value is required");
335 if (value.length > MAX_VALUE_LEN)
336 return fail(`Value must be ≤ ${MAX_VALUE_LEN} characters`);
337
338 const result = await upsertRepoSecret({
339 repoId: repo.id,
340 name,
341 value,
342 createdBy: user.id,
343 });
344
345 if (!result.ok) return fail(result.error);
346
347 // Best-effort audit — swallow any error so it never breaks the redirect.
348 try {
349 await audit({
350 userId: user.id,
351 repositoryId: repo.id,
352 action: "workflow.secret.create",
353 targetType: "workflow_secret",
354 targetId: result.id,
355 metadata: { name },
356 });
357 } catch {
358 // audit() already swallows errors, but guard anyway.
359 }
360
361 return c.redirect(`${base}?added=${encodeURIComponent(name)}`);
362 }
363);
364
365// ─── POST: Delete ───────────────────────────────────────────────────────────
366
367workflowSecretsRoutes.post(
368 "/:owner/:repo/settings/secrets/:secretId/delete",
369 requireAuth,
370 requireRepoAccess("admin"),
371 async (c) => {
372 const { owner: ownerName, repo: repoName } = c.req.param();
373 const user = c.get("user")!;
374 const repo = c.get("repository") as { id: string } | undefined;
375 if (!repo) return c.notFound();
376
377 const secretId = c.req.param("secretId");
378 const base = `/${ownerName}/${repoName}/settings/secrets`;
379
380 if (!secretId) {
381 return c.redirect(`${base}?error=${encodeURIComponent("Missing secret id")}`);
382 }
383
384 const result = await deleteRepoSecret({
385 repoId: repo.id,
386 secretId,
387 });
388
389 if (!result.ok) {
390 return c.redirect(
391 `${base}?error=${encodeURIComponent(result.error)}`
392 );
393 }
394
395 try {
396 await audit({
397 userId: user.id,
398 repositoryId: repo.id,
399 action: "workflow.secret.delete",
400 targetType: "workflow_secret",
401 targetId: secretId,
402 });
403 } catch {
404 // best-effort
405 }
406
407 return c.redirect(`${base}?deleted=1`);
408 }
409);
410
411export default workflowSecretsRoutes;
Modifiedsrc/routes/workflows.tsx+33−7View fileUnifiedSplit
2424} from "../db/schema";
2525import { Layout } from "../views/layout";
2626import { RepoHeader, RepoNav } from "../views/components";
27import { LogTail } from "../views/log-tail";
2728import { softAuth, requireAuth } from "../middleware/auth";
2829import type { AuthEnv } from "../middleware/auth";
2930import { getUnreadCount } from "../lib/unread";
468469 ))}
469470 </div>
470471 )}
471 {j.logs && j.logs.length > 0 && (
472 <pre
473 style="margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
474 >
475 {j.logs}
476 </pre>
477 )}
472 {(() => {
473 const runLive =
474 run.status === "running" || run.status === "queued";
475 const jobTerminal =
476 j.status === "success" ||
477 j.status === "failure" ||
478 j.status === "cancelled" ||
479 j.status === "skipped" ||
480 j.conclusion === "success" ||
481 j.conclusion === "failure" ||
482 j.conclusion === "cancelled" ||
483 j.conclusion === "skipped";
484 if (runLive && !jobTerminal) {
485 return (
486 <LogTail
487 runId={run.id}
488 jobId={j.id}
489 fallbackLogs={j.logs || ""}
490 />
491 );
492 }
493 if (j.logs && j.logs.length > 0) {
494 return (
495 <pre
496 style="margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
497 >
498 {j.logs}
499 </pre>
500 );
501 }
502 return null;
503 })()}
478504 </details>
479505 );
480506 })}
Addedsrc/views/log-tail.tsx+50−0View fileUnifiedSplit
1/**
2 * LogTail — SSR component that renders a <pre> and streams live workflow-run
3 * step-log chunks into it via SSE.
4 *
5 * The initial <pre> is pre-populated with `fallbackLogs` (the DB row's stored
6 * logs blob so a viewer with JS disabled, an unsupported browser, or a
7 * blocked SSE endpoint still sees whatever was already persisted). Once the
8 * inline script connects to `/live-events/workflow-run-<runId>`, it appends
9 * step-log chunks as plain escaped text and reloads the page on run-done so
10 * the static view takes over.
11 */
12
13import { raw } from "hono/html";
14import { liveLogTailScript } from "../lib/sse-client";
15
16export function LogTail(props: {
17 runId: string;
18 jobId?: string;
19 fallbackLogs?: string | null;
20 height?: string;
21 reloadOnRunDone?: boolean;
22}): JSX.Element {
23 const elementId = `log-tail-${props.runId}${props.jobId ? "-" + props.jobId : ""}`;
24 const topic = `workflow-run-${props.runId}`;
25 const script = liveLogTailScript({
26 topic,
27 targetElementId: elementId,
28 jobId: props.jobId,
29 onRunDone:
30 props.reloadOnRunDone === false ? undefined : "location.reload()",
31 });
32
33 return (
34 <div>
35 <div
36 style="display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; background: var(--bg-tertiary); border-top-left-radius: 6px; border-top-right-radius: 6px; font-size: 11px; color: var(--text-muted); font-family: monospace"
37 >
38 <span>● live log</span>
39 <span id={`${elementId}-status`}>connecting…</span>
40 </div>
41 <pre
42 id={elementId}
43 style={`margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow: auto; max-height: ${props.height || "480px"}; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px`}
44 >
45 {props.fallbackLogs || ""}
46 </pre>
47 <script dangerouslySetInnerHTML={{ __html: script }} />
48 </div>
49 );
50}
051