Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

# Cancel superseded runs on the same ref.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check:
name: typecheck · lint · test · build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

# Reads the pnpm version from the root package.json "packageManager" field.
- name: Set up pnpm
uses: pnpm/action-setup@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Persist the Turborepo local cache across runs to skip unchanged tasks.
- name: Cache Turborepo
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-

- name: Typecheck
run: pnpm typecheck

- name: Lint
run: pnpm lint

- name: Test
run: pnpm test

- name: Build
run: pnpm build
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

**An autonomous engineering-management & delivery tool for a single cross-functional engineering org unit** — a persistent org of role-based AI agents that takes work from a one-line epic all the way to a shipped change, with the human as the accountable lead at the approval gates.

[![CI](https://github.com/tobyvee/engineering/actions/workflows/ci.yml/badge.svg)](https://github.com/tobyvee/engineering/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
![TypeScript](https://img.shields.io/badge/TypeScript-strict%2C%20ESM-3178c6)
![Temporal](https://img.shields.io/badge/durable-Temporal-000)
Expand Down Expand Up @@ -121,6 +122,16 @@ pnpm db:generate # drizzle-kit generate
pnpm db:migrate # drizzle-kit migrate
```

## Continuous integration

[`.github/workflows/ci.yml`](./.github/workflows/ci.yml) runs `typecheck` · `lint` · `test` · `build`
on every pull request to `main` (and on pushes to `main`), using the Node version pinned in `.nvmrc`
with a frozen lockfile and pnpm + Turborepo caching. Unit tests need no live services.

To make a failing check **block merges**, enable branch protection on `main`
(GitHub → Settings → Branches → Add rule) and mark the **`typecheck · lint · test · build`** status
check as required. This is a one-time repo setting and can't be committed from the codebase.

## Key invariants

1. **Goal traceability** is enforced in `core` — no work item exists without a parent goal; the chain is injected into every agent's context.
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"hono": "^4.12.26"
},
"devDependencies": {
"@temporalio/testing": "^1.18.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
Expand Down
223 changes: 223 additions & 0 deletions apps/server/src/temporal/workflows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { fileURLToPath } from "node:url"
import { TestWorkflowEnvironment } from "@temporalio/testing"
import { bundleWorkflowCode, Worker, type WorkflowBundle } from "@temporalio/worker"
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
import {
approveSignal,
epicDecomposition,
epicShaping,
heartbeat,
roadmapSignal,
ticketLifecycle,
} from "./workflows"

/**
* ENG-002 — exercises the durable orchestration layer with Temporal's time-skipping test
* environment and mocked activities. Time-skipping fast-forwards the CI/deploy poll `sleep`s; the
* approval gates (Signals) are driven explicitly. All GitHub/DB/agent I/O is mocked, so these run
* with no live services. The workflow code is bundled once (`beforeAll`) and reused per test.
*/

const TQ = "test"
const workflowsPath = fileURLToPath(new URL("./workflows.ts", import.meta.url))

let env: TestWorkflowEnvironment
let bundle: WorkflowBundle
let wfId = 0
const nextId = (p: string) => `${p}-${++wfId}`

beforeAll(async () => {
env = await TestWorkflowEnvironment.createTimeSkipping()
bundle = await bundleWorkflowCode({ workflowsPath })
}, 120_000)

afterAll(async () => {
await env?.teardown()
})

/** Happy-path activity mocks; pass overrides to force failure branches. */
function makeActivities(overrides: Record<string, unknown> = {}) {
return {
transitionTicket: vi.fn(async (_id: string, _status: string) => {}),
implementTicket: vi.fn(async (_id: string) => ({
number: 1,
url: "http://pr/1",
branch: "ticket/t",
})),
verifyTicket: vi.fn(async (_id: string) => true),
checkDeliveryStatus: vi.fn(async (_pr: unknown) => "success"),
mergeDelivery: vi.fn(async (_id: string, _pr: unknown) => true),
startDeploy: vi.fn(async (_id: string) => 42),
checkDeployStatus: vi.fn(async (_runId: number) => "success"),
recordDeploy: vi.fn(async (_id: string, _state: string) => {}),
pickUpBacklog: vi.fn(async () => 0),
decomposeEpic: vi.fn(async (_epicId: string) => ["ticket-1"]),
runShapingStage: vi.fn(async (_epicId: string, _stageKey: string) => {}),
requestRoadmapSignoff: vi.fn(async (_epicId: string) => {}),
recordRoadmapApproval: vi.fn(async (_epicId: string) => {}),
...overrides,
}
}

type Activities = ReturnType<typeof makeActivities>

/** Run `cb` with a worker bound to `activities` for the duration, then shut it down. */
async function withWorker<T>(activities: Activities, cb: () => Promise<T>): Promise<T> {
const worker = await Worker.create({
connection: env.nativeConnection,
taskQueue: TQ,
workflowBundle: bundle,
activities,
})
return worker.runUntil(cb)
}

describe("ticketLifecycle", () => {
it("reaches done when both gates are approved", async () => {
const acts = makeActivities()
await withWorker(acts, async () => {
const handle = await env.client.workflow.start(ticketLifecycle, {
taskQueue: TQ,
workflowId: nextId("ticket"),
args: ["t1"],
})
await handle.signal(approveSignal, "merge")
await handle.signal(approveSignal, "deploy")
await handle.result()
})

expect(acts.mergeDelivery).toHaveBeenCalledTimes(1)
expect(acts.recordDeploy).toHaveBeenCalledWith("t1", "success")
expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "done")
expect(acts.transitionTicket).not.toHaveBeenCalledWith("t1", "blocked")
}, 30_000)

it("blocks on the merge gate until approved", async () => {
const acts = makeActivities()
await withWorker(acts, async () => {
const handle = await env.client.workflow.start(ticketLifecycle, {
taskQueue: TQ,
workflowId: nextId("ticket"),
args: ["t1"],
})
// Let the workflow run to the merge gate; with no signal it stays blocked (no timer to skip).
await env.sleep("1 day")
expect(acts.verifyTicket).toHaveBeenCalledTimes(1)
expect(acts.mergeDelivery).not.toHaveBeenCalled()

await handle.signal(approveSignal, "merge")
await handle.signal(approveSignal, "deploy")
await handle.result()
})

expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "done")
}, 30_000)

it("blocks the ticket when QA fails (before any gate)", async () => {
const acts = makeActivities({ verifyTicket: vi.fn(async (_id: string) => false) })
await withWorker(acts, async () => {
const handle = await env.client.workflow.start(ticketLifecycle, {
taskQueue: TQ,
workflowId: nextId("ticket"),
args: ["t1"],
})
await handle.result() // returns without any signal — QA fail short-circuits
})

expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "blocked")
expect(acts.mergeDelivery).not.toHaveBeenCalled()
expect(acts.transitionTicket).not.toHaveBeenCalledWith("t1", "done")
}, 30_000)

it("blocks the ticket when the merge fails", async () => {
const acts = makeActivities({
mergeDelivery: vi.fn(async (_id: string, _pr: unknown) => false),
})
await withWorker(acts, async () => {
const handle = await env.client.workflow.start(ticketLifecycle, {
taskQueue: TQ,
workflowId: nextId("ticket"),
args: ["t1"],
})
await handle.signal(approveSignal, "merge")
await handle.result()
})

expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "blocked")
expect(acts.startDeploy).not.toHaveBeenCalled()
expect(acts.transitionTicket).not.toHaveBeenCalledWith("t1", "done")
}, 30_000)

it("blocks the ticket when the deploy fails", async () => {
const acts = makeActivities({
checkDeployStatus: vi.fn(async (_runId: number) => "failure"),
})
await withWorker(acts, async () => {
const handle = await env.client.workflow.start(ticketLifecycle, {
taskQueue: TQ,
workflowId: nextId("ticket"),
args: ["t1"],
})
await handle.signal(approveSignal, "merge")
await handle.signal(approveSignal, "deploy")
await handle.result()
})

expect(acts.recordDeploy).toHaveBeenCalledWith("t1", "failure")
expect(acts.transitionTicket).toHaveBeenCalledWith("t1", "blocked")
expect(acts.transitionTicket).not.toHaveBeenCalledWith("t1", "done")
}, 30_000)
})

describe("epicDecomposition", () => {
it("blocks on the roadmap gate until signed off, then decomposes", async () => {
const acts = makeActivities()
await withWorker(acts, async () => {
const handle = await env.client.workflow.start(epicDecomposition, {
taskQueue: TQ,
workflowId: nextId("epic"),
args: ["e1"],
})
await env.sleep("1 day")
expect(acts.requestRoadmapSignoff).toHaveBeenCalledWith("e1")
expect(acts.decomposeEpic).not.toHaveBeenCalled()

await handle.signal(roadmapSignal)
await handle.result()
})

expect(acts.recordRoadmapApproval).toHaveBeenCalledWith("e1")
expect(acts.decomposeEpic).toHaveBeenCalledWith("e1")
}, 30_000)
})

describe("epicShaping", () => {
it("runs every shaping stage in order", async () => {
const acts = makeActivities()
await withWorker(acts, async () => {
await env.client.workflow.execute(epicShaping, {
taskQueue: TQ,
workflowId: nextId("shape"),
args: ["e1"],
})
})
// One call per stage (PM → UX → Architect → System Design).
expect(acts.runShapingStage.mock.calls.length).toBeGreaterThanOrEqual(4)
expect(acts.runShapingStage).toHaveBeenCalledWith("e1", "discovery")
expect(acts.runShapingStage).toHaveBeenCalledWith("e1", "system_design")
}, 30_000)
})

describe("heartbeat", () => {
it("picks up the backlog", async () => {
const acts = makeActivities()
await withWorker(acts, async () => {
await env.client.workflow.execute(heartbeat, {
taskQueue: TQ,
workflowId: nextId("hb"),
args: [],
})
})
expect(acts.pickUpBacklog).toHaveBeenCalledTimes(1)
}, 30_000)
})
32 changes: 32 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading