From adb30eb31337a0241f4988b56d688800ab403f38 Mon Sep 17 00:00:00 2001 From: Jacob Prall Date: Thu, 18 Jun 2026 16:45:43 -0700 Subject: [PATCH 1/2] update --- .github/workflows/setup-attendee.yml | 50 ----- README.md | 36 +-- package.json | 9 +- packages/naive-agent/render.yaml | 2 +- packages/queue-agents/render.yaml | 2 +- packages/workflow-agents/render.yaml | 11 +- scripts/setup-attendee.js | 306 -------------------------- shared/agent/src/model.ts | 26 ++- tests/unit/setup-attendee.test.ts | 211 ------------------ workshop/facilitator-guide.md | 313 --------------------------- 10 files changed, 42 insertions(+), 924 deletions(-) delete mode 100644 .github/workflows/setup-attendee.yml delete mode 100644 scripts/setup-attendee.js delete mode 100644 tests/unit/setup-attendee.test.ts delete mode 100644 workshop/facilitator-guide.md diff --git a/.github/workflows/setup-attendee.yml b/.github/workflows/setup-attendee.yml deleted file mode 100644 index 15a0ba5d..00000000 --- a/.github/workflows/setup-attendee.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Setup attendee Blueprint names - -on: - workflow_dispatch: - -permissions: - contents: write - -jobs: - setup: - name: Namespace Render Blueprints - runs-on: ubuntu-latest - - steps: - - name: Refuse upstream main - if: ${{ (github.repository_owner == 'render-lab' || github.repository_owner == 'render-examples') && github.ref_name == 'main' }} - run: | - echo "This setup workflow is for attendee forks." - echo "Refusing to namespace Blueprints on ${GITHUB_REPOSITORY} main." - exit 1 - - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Namespace Blueprint resources - run: npm run setup - - - name: Commit namespaced Blueprints - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add packages/naive-agent/render.yaml packages/queue-agents/render.yaml packages/workflow-agents/render.yaml - - if git diff --cached --quiet; then - echo "Blueprints are already namespaced for ${GITHUB_ACTOR}." - exit 0 - fi - - git commit -m "Namespace Render resources for ${GITHUB_ACTOR}" - git push diff --git a/README.md b/README.md index a407a2e6..6c57d34e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,12 @@ -# Render Workflow Agents Workshop +# Render Workflow Agents — TypeScript -A hands-on workshop that deploys **one agentic code-review use case** across +An example repo showing **one agentic code-review use case** deployed across **three Render execution substrates**: an in-process web service, a web service plus queue-backed worker, and Render Workflows. -You deploy the same multi-agent PR reviewer (`security`, `performance`, `ux`, then -a `judge`) across progressively more durable execution models. Along the way, you -open live Render URLs, inspect logs and traces in the Dashboard, and use local -development for focused test loops. - -For someone facilitating this workshop, start with [`workshop/facilitator-guide.md`](workshop/facilitator-guide.md). +The same multi-agent PR reviewer (`security`, `performance`, `ux`, then a `judge`) +runs across progressively more durable execution models. Each pattern comes with a +Blueprint for one-click deployment and local development scripts for focused testing. ## The three patterns @@ -41,7 +38,7 @@ uses a deterministic **mock** model, so live deploys and local tests still work. Set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for real reviews, or force the mock with `AGENT_MODEL=mock`. -## Workshop path +## Deploy Patterns 1 and 2 use Blueprints: @@ -56,22 +53,11 @@ Pattern 3 uses both: - [`packages/workflow-agents/render.yaml`](packages/workflow-agents/render.yaml) creates the web service and Postgres database - `render workflows create` creates the Workflow service - `render workflows start` triggers task runs -- `render logs`, `render deploys`, and the Dashboard help learners inspect what ran - -## Interactive beats - -- **Session 1 — inspect the coordination.** In queue-agents, learners trace the ack - contract in [`packages/queue-agents/src/kv.ts`](packages/queue-agents/src/kv.ts), - run the focused test with `npm run test:worker`, scale the worker, and observe - what they now own. -- **Session 2 — let agents author tasks.** In workflow-agents, learners explore the - `your-review` sandbox and work with the small `task()` API surface. The same - durability that took a whole queue in Session 1 is now a config object, a live - task run, and a Dashboard trace. +- `render logs`, `render deploys`, and the Dashboard show what ran ## Local development -Local runs are useful for tests, facilitator prep, and debugging. +Local runs are useful for tests and debugging. For local runs, copy the example env file: @@ -140,10 +126,6 @@ shared/ → src/index.ts createUiRouter() + read APIs → src/page.ts dashboard HTML template -docs/ guided walkthrough (00–05) - -facilitator/ facilitator notes and exercise solutions - tests/ unit, integration, and e2e tests (mock model, no API key) → integration/run-review.test.ts core pipeline end-to-end → integration/workflow-dispatch.test.ts Pattern 3 dispatch path @@ -217,7 +199,7 @@ npm run typecheck # TypeScript across every workspace ``` Tests live under [`tests/`](tests) (`unit/`, `integration/`, `e2e/`). The -`worker-kv` integration test is the red-to-green check for the Session 1 exercise. +`worker-kv` integration test validates the ack/retry contract in Pattern 2. ### Troubleshooting `test:worker` diff --git a/package.json b/package.json index 81df8bf9..324393e1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "packages/workflow-agents" ], "scripts": { - "setup": "node scripts/setup-attendee.js", "typecheck": "npm run typecheck --workspaces --if-present && tsc -p tests/tsconfig.json", "test": "AGENT_MODEL=mock RENDER_USE_LOCAL_DEV=true node --import tsx --test \"tests/**/*.test.ts\"", "test:unit": "AGENT_MODEL=mock node --import tsx --test \"tests/unit/**/*.test.ts\"", @@ -24,14 +23,10 @@ "test:worker": "AGENT_MODEL=mock node --import tsx --test \"tests/integration/worker-kv.test.ts\"", "naive:dev": "npm run dev --workspace @workshop/naive-agent", "queue:web": "npm run web --workspace @workshop/queue-agents", - "queue:worker": "npm run worker --workspace @workshop/queue-agents", - "docker:up": "docker compose up --build", - "docker:down": "docker compose down", - "docker:logs": "docker compose logs -f" + "queue:worker": "npm run worker --workspace @workshop/queue-agents" }, "dependencies": { - "tsx": "^4.20.5", - "yaml": "^2.9.0" + "tsx": "^4.20.5" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/naive-agent/render.yaml b/packages/naive-agent/render.yaml index ba3eb642..4ea4017d 100644 --- a/packages/naive-agent/render.yaml +++ b/packages/naive-agent/render.yaml @@ -2,7 +2,7 @@ # Builds from the repo root so the npm workspaces resolve. projects: - - name: agents-workshop-naive + - name: naive-agent environments: - name: production databases: diff --git a/packages/queue-agents/render.yaml b/packages/queue-agents/render.yaml index 3e24be46..69e93034 100644 --- a/packages/queue-agents/render.yaml +++ b/packages/queue-agents/render.yaml @@ -2,7 +2,7 @@ # Builds from the repo root so the npm workspaces resolve. projects: - - name: agents-workshop-queue + - name: queue-agents environments: - name: production databases: diff --git a/packages/workflow-agents/render.yaml b/packages/workflow-agents/render.yaml index 087f2574..2d2fc432 100644 --- a/packages/workflow-agents/render.yaml +++ b/packages/workflow-agents/render.yaml @@ -3,11 +3,9 @@ # workflow-agents. Web host (Hono) + managed Postgres. # Builds from the repo root so the npm workspaces resolve. # -# The Workflow service is created separately with the Render CLI — see -# docs/03-workflow-agents.md. projects: - - name: agents-workshop-workflows + - name: workflow-agents environments: - name: production databases: @@ -40,5 +38,12 @@ projects: - key: RENDER_WORKFLOW_SLUG sync: false + - key: ANTHROPIC_API_KEY + sync: false + - key: OPENAI_API_KEY + sync: false + - key: GITHUB_TOKEN + sync: false + - key: NODE_ENV value: production diff --git a/scripts/setup-attendee.js b/scripts/setup-attendee.js deleted file mode 100644 index 5f8b88e2..00000000 --- a/scripts/setup-attendee.js +++ /dev/null @@ -1,306 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { isMap, isScalar, isSeq, parseDocument } from 'yaml' - -const DEFAULT_BLUEPRINTS = [ - 'packages/naive-agent/render.yaml', - 'packages/queue-agents/render.yaml', - 'packages/workflow-agents/render.yaml', -] -const BASE_RESOURCE_NAMES = [ - 'agents-workshop-naive', - 'naive-agent-db', - 'naive-agent', - 'agents-workshop-queue', - 'queue-agents-db', - 'queue-agents-valkey', - 'queue-agents-web', - 'queue-agents-worker', - 'agents-workshop-workflows', - 'workflow-agents-db', - 'workflow-agents', -] - -const currentFile = fileURLToPath(import.meta.url) -const defaultRoot = resolve(dirname(currentFile), '..') - -function getStringAt(map, key) { - const value = map.get(key, true) - return isScalar(value) && typeof value.value === 'string' ? value.value : null -} - -function setStringAt(map, key, value) { - const node = map.get(key, true) - if (isScalar(node)) { - node.value = value - return - } - - map.set(key, value) -} - -function normalizeNamespace(value) { - const namespace = value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - - if (!namespace) { - throw new Error('Namespace must contain at least one letter or number.') - } - - return namespace -} - -function namespaceName(name, namespace) { - if (name.startsWith(`${namespace}-`)) { - return name - } - - const baseName = BASE_RESOURCE_NAMES.find( - (candidate) => name === candidate || name.endsWith(`-${candidate}`), - ) - - return `${namespace}-${baseName ?? name}` -} - -function collectNameChange(map, namespace, nameChanges, changedNames) { - const currentName = getStringAt(map, 'name') - if (!currentName) { - return - } - - const nextName = namespaceName(currentName, namespace) - if (nextName !== currentName) { - nameChanges.set(currentName, nextName) - changedNames.push({ from: currentName, to: nextName }) - - for (const baseName of BASE_RESOURCE_NAMES) { - if (currentName === baseName || currentName.endsWith(`-${baseName}`)) { - nameChanges.set(baseName, nextName) - break - } - } - setStringAt(map, 'name', nextName) - } -} - -function updateReference(map, key, nameChanges) { - const reference = map.get(key, true) - if (!isMap(reference)) { - return - } - - const currentName = getStringAt(reference, 'name') - if (!currentName) { - return - } - - const nextName = nameChanges.get(currentName) - if (nextName) { - setStringAt(reference, 'name', nextName) - } -} - -function getSeqAt(map, key) { - const value = map.get(key, true) - return isSeq(value) ? value : null -} - -function collectNamesFromSeq(seq, namespace, nameChanges, changedNames) { - for (const item of seq.items) { - if (isMap(item)) { - collectNameChange(item, namespace, nameChanges, changedNames) - } - } -} - -function visitMaps(node, visitor) { - if (isMap(node)) { - visitor(node) - - for (const item of node.items) { - visitMaps(item.value, visitor) - } - return - } - - if (isSeq(node)) { - for (const item of node.items) { - visitMaps(item, visitor) - } - } -} - -function namespaceBlueprint(source, namespace) { - const doc = parseDocument(source) - const nameChanges = new Map() - const changedNames = [] - - if (isMap(doc.contents)) { - const rootDatabases = getSeqAt(doc.contents, 'databases') - const rootServices = getSeqAt(doc.contents, 'services') - const projects = getSeqAt(doc.contents, 'projects') - - if (rootDatabases) { - collectNamesFromSeq(rootDatabases, namespace, nameChanges, changedNames) - } - if (rootServices) { - collectNamesFromSeq(rootServices, namespace, nameChanges, changedNames) - } - - if (projects) { - for (const project of projects.items) { - if (!isMap(project)) { - continue - } - - collectNameChange(project, namespace, nameChanges, changedNames) - - const environments = getSeqAt(project, 'environments') - if (!environments) { - continue - } - - for (const environment of environments.items) { - if (!isMap(environment)) { - continue - } - - const databases = getSeqAt(environment, 'databases') - const services = getSeqAt(environment, 'services') - - if (databases) { - collectNamesFromSeq(databases, namespace, nameChanges, changedNames) - } - if (services) { - collectNamesFromSeq(services, namespace, nameChanges, changedNames) - } - } - } - } - } - - visitMaps(doc.contents, (map) => { - updateReference(map, 'fromDatabase', nameChanges) - updateReference(map, 'fromService', nameChanges) - }) - - return { - contents: doc.toString(), - changedNames, - } -} - -function parseArgs(argv) { - const args = { - blueprints: DEFAULT_BLUEPRINTS, - namespace: process.env.GITHUB_ACTOR, - root: defaultRoot, - } - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index] - const value = argv[index + 1] - - if (arg.startsWith('--namespace=')) { - args.namespace = arg.slice('--namespace='.length) - continue - } - - if (arg.startsWith('--')) { - switch (arg) { - case '--namespace': - if (!value) { - throw new Error('Missing value for --namespace.') - } - args.namespace = value - index += 1 - break - case '--root': - if (!value) { - throw new Error('Missing value for --root.') - } - args.root = resolve(value) - index += 1 - break - default: - throw new Error(`Unknown argument: ${arg}`) - } - continue - } - - if (args.namespace && args.namespace !== process.env.GITHUB_ACTOR) { - throw new Error(`Unexpected extra argument: ${arg}`) - } - - args.namespace = arg - } - - if (!args.namespace) { - throw new Error( - 'Missing namespace. Run `npm run setup -- your-github-username`.', - ) - } - - return { - blueprints: args.blueprints, - namespace: normalizeNamespace(args.namespace), - root: args.root, - } -} - -async function setupAttendee({ blueprints, namespace, root }) { - const changes = [] - - for (const relativePath of blueprints) { - const path = resolve(root, relativePath) - const source = await readFile(path, 'utf8') - const result = namespaceBlueprint(source, namespace) - - await writeFile(path, result.contents) - changes.push({ path: relativePath, changedNames: result.changedNames }) - } - - return changes -} - -function printSummary(changes) { - for (const change of changes) { - console.log(change.path) - - if (change.changedNames.length === 0) { - console.log(' no changes') - continue - } - - for (const { from, to } of change.changedNames) { - console.log(` ${from} -> ${to}`) - } - } -} - -async function main() { - try { - const args = parseArgs(process.argv.slice(2)) - const changes = await setupAttendee(args) - printSummary(changes) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(message) - process.exitCode = 1 - } -} - -if (process.argv[1] === currentFile) { - await main() -} - -export { - namespaceBlueprint, - namespaceName, - normalizeNamespace, - parseArgs, - setupAttendee, -} diff --git a/shared/agent/src/model.ts b/shared/agent/src/model.ts index dcf6a374..2e51f9fb 100644 --- a/shared/agent/src/model.ts +++ b/shared/agent/src/model.ts @@ -2,8 +2,8 @@ * Model adapters. A provider-agnostic ModelClient with one `complete` call. * Each adapter talks to its provider's API directly over `fetch` (no vendor SDK). * - * Workshop nicety: when no API key is configured (or AGENT_MODEL=mock), we return - * a deterministic MockClient so the entire pipeline runs offline. + * When no API key is configured (or AGENT_MODEL=mock), we return a deterministic + * MockClient so the entire pipeline runs offline. */ import type { CompleteArgs, @@ -23,11 +23,27 @@ export function resolveClient(model: ModelSpec): ModelClient { const keyEnv = model.apiKeyEnv ?? (model.provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY') - if (!process.env[keyEnv]) { - console.warn(`[agent] no ${keyEnv} set — falling back to mock model client`) - return new MockClient() + if (process.env[keyEnv]) { + return clientForProvider(model) + } + + // The requested provider's key isn't set — try the other provider before + // falling back to mock so that setting only OPENAI_API_KEY (or only + // ANTHROPIC_API_KEY) still produces a real review. + const altProvider = model.provider === 'openai' ? 'anthropic' : 'openai' + const altKeyEnv = altProvider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY' + if (process.env[altKeyEnv]) { + console.warn( + `[agent] no ${keyEnv} set but ${altKeyEnv} found — using ${altProvider} provider`, + ) + return clientForProvider({ ...model, provider: altProvider }) } + console.warn(`[agent] no ${keyEnv} set — falling back to mock model client`) + return new MockClient() +} + +function clientForProvider(model: ModelSpec): ModelClient { switch (model.provider) { case 'anthropic': return new AnthropicClient(model) diff --git a/tests/unit/setup-attendee.test.ts b/tests/unit/setup-attendee.test.ts deleted file mode 100644 index 676f6123..00000000 --- a/tests/unit/setup-attendee.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import assert from 'node:assert/strict' -import { execFile } from 'node:child_process' -import { mkdtemp, readFile, mkdir, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { promisify } from 'node:util' -import { test } from 'node:test' - -const execFileAsync = promisify(execFile) -const repoRoot = new URL('../..', import.meta.url).pathname -const setupScript = join(repoRoot, 'scripts/setup-attendee.js') -const blueprintPaths = [ - 'packages/naive-agent/render.yaml', - 'packages/queue-agents/render.yaml', - 'packages/workflow-agents/render.yaml', -] -const blueprintFixtures: Record = { - 'packages/naive-agent/render.yaml': `projects: - - name: agents-workshop-naive - environments: - - name: production - databases: - - name: naive-agent-db - services: - - type: web - name: naive-agent - envVars: - - key: DATABASE_URL - fromDatabase: - name: naive-agent-db - property: connectionString -`, - 'packages/queue-agents/render.yaml': `projects: - - name: agents-workshop-queue - environments: - - name: production - databases: - - name: queue-agents-db - services: - - type: keyvalue - name: queue-agents-valkey - - type: web - name: queue-agents-web - envVars: - - key: VALKEY_URL - fromService: - name: queue-agents-valkey - type: keyvalue - property: connectionString - - type: worker - name: queue-agents-worker -`, - 'packages/workflow-agents/render.yaml': `projects: - - name: agents-workshop-workflows - environments: - - name: production - databases: - - name: workflow-agents-db - services: - - type: web - name: workflow-agents - envVars: - - key: DATABASE_URL - fromDatabase: - name: workflow-agents-db - property: connectionString -`, -} - -async function copyBlueprintsToTempRepo() { - const root = await mkdtemp(join(tmpdir(), 'attendee-setup-')) - - for (const relativePath of blueprintPaths) { - const destination = join(root, relativePath) - const fixture = blueprintFixtures[relativePath] - assert.ok(fixture) - await mkdir(dirname(destination), { recursive: true }) - await writeFile(destination, fixture) - } - - return root -} - -async function runSetup(root: string, namespace = 'Octo.User') { - return execFileAsync(process.execPath, [ - setupScript, - '--root', - root, - '--namespace', - namespace, - ]) -} - -async function runSetupWithPositionalNamespace(root: string, namespace: string) { - return execFileAsync(process.execPath, [setupScript, '--root', root, namespace]) -} - -async function runSetupWithEqualsNamespace(root: string, namespace: string) { - return execFileAsync(process.execPath, [ - setupScript, - '--root', - root, - `--namespace=${namespace}`, - ]) -} - -async function runSetupWithGithubActor(root: string, actor: string) { - return execFileAsync(process.execPath, [setupScript, '--root', root], { - env: { - ...process.env, - GITHUB_ACTOR: actor, - }, - }) -} - -test('setup script namespaces all blueprint resources and references', async () => { - const root = await copyBlueprintsToTempRepo() - - await runSetup(root) - - const naive = await readFile( - join(root, 'packages/naive-agent/render.yaml'), - 'utf8', - ) - assert.match(naive, /name: octo-user-agents-workshop-naive/) - assert.match(naive, /name: production/) - assert.match(naive, /name: octo-user-naive-agent-db/) - assert.match(naive, /name: octo-user-naive-agent/) - assert.match(naive, /fromDatabase:\n\s+name: octo-user-naive-agent-db/) - - const worker = await readFile( - join(root, 'packages/queue-agents/render.yaml'), - 'utf8', - ) - assert.match(worker, /name: octo-user-queue-agents-valkey/) - assert.match(worker, /name: octo-user-queue-agents-web/) - assert.match(worker, /name: octo-user-queue-agents-worker/) - assert.match(worker, /fromService:\n\s+name: octo-user-queue-agents-valkey/) - - const workflow = await readFile( - join(root, 'packages/workflow-agents/render.yaml'), - 'utf8', - ) - assert.match(workflow, /name: octo-user-agents-workshop-workflows/) - assert.match(workflow, /name: octo-user-workflow-agents-db/) - assert.match(workflow, /name: octo-user-workflow-agents/) - assert.match(workflow, /fromDatabase:\n\s+name: octo-user-workflow-agents-db/) -}) - -test('setup script is idempotent for the same namespace', async () => { - const root = await copyBlueprintsToTempRepo() - - await runSetup(root) - await runSetup(root) - - const naive = await readFile( - join(root, 'packages/naive-agent/render.yaml'), - 'utf8', - ) - assert.doesNotMatch(naive, /octo-user-octo-user-/) -}) - -test('setup script uses GITHUB_ACTOR when namespace is omitted', async () => { - const root = await copyBlueprintsToTempRepo() - - await runSetupWithGithubActor(root, 'Button.User') - - const naive = await readFile( - join(root, 'packages/naive-agent/render.yaml'), - 'utf8', - ) - assert.match(naive, /name: button-user-agents-workshop-naive/) -}) - -test('setup script replaces a previous attendee prefix', async () => { - const root = await copyBlueprintsToTempRepo() - - await runSetup(root, 'First.User') - await runSetup(root, 'Second.User') - - const naive = await readFile( - join(root, 'packages/naive-agent/render.yaml'), - 'utf8', - ) - assert.match(naive, /name: second-user-agents-workshop-naive/) - assert.doesNotMatch(naive, /second-user-first-user-/) -}) - -test('setup script accepts a positional namespace for npm run setup', async () => { - const root = await copyBlueprintsToTempRepo() - - await runSetupWithPositionalNamespace(root, 'Positional.User') - - const naive = await readFile( - join(root, 'packages/naive-agent/render.yaml'), - 'utf8', - ) - assert.match(naive, /name: positional-user-agents-workshop-naive/) -}) - -test('setup script accepts --namespace=value', async () => { - const root = await copyBlueprintsToTempRepo() - - await runSetupWithEqualsNamespace(root, 'Equals.User') - - const naive = await readFile( - join(root, 'packages/naive-agent/render.yaml'), - 'utf8', - ) - assert.match(naive, /name: equals-user-agents-workshop-naive/) -}) diff --git a/workshop/facilitator-guide.md b/workshop/facilitator-guide.md deleted file mode 100644 index dc2f2e88..00000000 --- a/workshop/facilitator-guide.md +++ /dev/null @@ -1,313 +0,0 @@ -# Facilitator Guide — Building Agents on Render Workflows - -The teaching layer on top of the learner-facing tutorials. The tutorials carry -the deploy steps, repo layout, and code context; this guide carries the talk -tracks, timing, aha moments, hint ladders, and worked solutions. - -Read once end-to-end before your first run. Keep the **Run sheet** and -**Solutions** open on a second screen while you present. - ---- - -## 1. The spine (the one mental model to land) - -``` - SAME AGENT ───────────────────────────────────▶ (never changes) - -Pattern 1 [ web request runs the agent ] you own: nothing - └ breaks: timeouts, lost on deploy, no scale scales: no - -Pattern 2 [ web ] → (Valkey queue) → [ worker runs agent ] you own: queue, - └ durable, scales by adding workers consumer group, - acks, retries, pub/sub - -Pattern 3 [ web ] → Render Workflows → [ task per agent ] you own: nothing - └ same durability + scale, declarative scales: yes -``` - -The emotional arc you're selling: - -1. **Pattern 1 feels good** ("look, it's live") → then you break it. -2. **Pattern 2 is powerful** ("now it's durable and scales!") → then they read - the ack contract and see how much coordination they now *own*. -3. **Pattern 3 feels like cheating** ("wait, that's it?") → the guarantees from - Pattern 2 collapse into `retry: { maxRetries: 2 }`, a CLI-created Workflow, and - a trace. - -If learners leave able to recite "the agent never changed, the substrate did the -work," the workshop succeeded. - ---- - -## 2. Logistics - -- **Total time:** ~1h 50 mins, designed as **two sessions** with a 10 minute break. - - **Session 1 — Substrates & coordination** (~50 min): Patterns 1 & 2, - including tracing the ack contract and scaling the worker. - - **Session 2 — Let the platform (and agents) do it** (~50 min): Pattern 3 - and the author-a-task finale, where coding agents come out. -- **Format:** live deploys + a hands-on lab. Learners follow along on their own - Render accounts and machines. -- **Group size:** works 1:1 up to ~30 with a helper for debugging environments. -- **Delivery:** in-person or remote. Remote works fine. Have learners share service - URLs, CLI output, and Dashboard screenshots when they get stuck. - ---- - -## 3. Pre-flight checklist - -Do this **before** learners arrive (and have learners do the install ahead of time -if you can — environment setup is the #1 time sink). - -Facilitator machine: - -- [ ] Node >= 22.12 (`node -v`). -- [ ] `npm install` from the repo root completes clean. -- [ ] Render CLI installed, logged in, and pointed at the right workspace - (`render login`, then `render workspace set`). -- [ ] A fork or workshop repo connected to Render. -- [ ] Pattern 1 and Pattern 2 Blueprints tested from that repo. -- [ ] Pattern 3 hybrid path rehearsed with the web+Postgres Blueprint and - `render workflows create`. -- [ ] Optional local Valkey running (`valkey-server &` or - `docker run -p 6379:6379 valkey/valkey`). -- [ ] `npm test` is green (proves the mock model path works end-to-end). -- [ ] Decide: real model or mock? With **no LLM provider API key** everything runs - on a deterministic mock model — totally fine and fully offline. Set - `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` only if you want live reviews. Have - `AGENT_MODEL=mock` ready as a fallback if the gateway misbehaves on stage. - -Room / screen: - -- [ ] Terminal font large enough to read from the back. -- [ ] Browser tabs open to the Render Dashboard, the learner-facing docs, and one - deployed service URL. -- [ ] Terminal tabs ready for `render services`, `render logs`, and - `render workflows`. - -Tell learners up front: **no LLM provider API key is required.** This removes the -single biggest source of "it doesn't work for me." - -### Stage reliability - -- **Mock model is the default.** With no API key set, every deploy and test - runs on a deterministic mock — totally offline, totally reproducible. -- **Public PRs as input.** GitHub's unauthenticated rate limit is generous - enough for a room of 30. -- **Pre-deployed reference instance.** Deploy all three patterns from your - facilitator fork before the session and keep the URLs bookmarked. If a - learner's deploy stalls, they can point at yours. -- **`AGENT_MODEL=mock` as escape hatch.** If a live model misbehaves mid-demo, - switch and keep moving. - -### Setup triage (first 10 minutes) - -| Symptom | Fix | Time | -| --- | --- | --- | -| No fork yet | Fork now; run the setup-attendee Action while `npm install` runs | 2 min | -| `npm install` fails | Check Node version — need >= 22.12. `nvm install 22` | 2 min | -| Render CLI not installed | `brew install render` (macOS) or `npm install -g @render-oss/cli` | 1 min | -| Can't connect Git provider in Render | Pair with a neighbor; defer to break | 0 min | -| Blueprint names collide | They didn't run `setup-attendee.yml`. Run `npm run setup` locally, commit, push | 1 min | - -**Rule of thumb:** if an attendee isn't unblocked within 3 minutes, pair them -with a neighbor and circle back at the break. - ---- - -## 4. Run sheet - -### Demo flow at a glance - -``` -Setup render login → render workspace set → npm install → draw the spine -Pattern 1 Blueprint deploy → live URL → submit PR → show spans/logs → break it -Pattern 2 Blueprint deploy → submit PRs → tail worker logs → scale worker - → open kv.ts: "this is the price" -── break ── -Pattern 3 Blueprint web+DB → workflows create → run code-review → show trace - → side-by-side fan-out table -Lab preview your-review → compose agent → force retry → ship live -Close re-draw spine: "the agent never changed" -``` - -### Module 0 — Setup & framing (10 min) - -- **Talk track:** "We're going to build a code reviewer once and run it three ways. - Watch what *doesn't* change." Draw the spine. -- **Pitfall:** learners without Git provider access in Render. Pair them with a - helper or have them follow the facilitator deploy while they keep coding locally. -- **CFU:** "Which folder holds the agent itself?" (`shared/agent`) - -### Module 1 — Pattern 1: the naive baseline (15 min) - -- **Talk track:** "Simplest thing that works: the agent runs *inside the request*." -- **On stage:** after the first successful review, show `server.ts` — every `await` - blocks the HTTP connection. Read the file's top comment aloud. -- **Break it on stage:** submit a large PR → the request blocks. "What happens if - I redeploy mid-review?" → in-flight work is lost. This motivates Pattern 2. -- **Pitfall:** a big PR on a real model can genuinely time out. That's the point, - but switch to a small PR to keep pace. -- **CFU:** "Name two reasons this design fails under load." (timeouts, lost on - deploy/crash, no independent scale.) - -### Module 2 — Pattern 2: worker + queue (20 min) - -- **Talk track:** "Same building blocks — same prepareDiff, same agents, same judge. - The web tier becomes a thin producer. A background worker consumes a Valkey - queue and runs the same pipeline out-of-band." -- **The aha:** open `worker.ts` and compare side-by-side with `naive-agent/server.ts`. - Same imports, same pipeline. Only *where it runs* changed. -- **Now flip it:** open `kv.ts` and scroll slowly. "This is the price. The stream, - the consumer group, blocking reads, acks, retry-on-failure, the pub/sub progress - bus — all of this is coordination code *you* now own and debug." -- **Pitfall:** the web app can open before the worker is ready. Check service - health and worker logs first. -- **CFU:** "What did we have to add, and what did we change in the agent?" - (Added: queue/worker/acks/pub-sub. Changed in agent: nothing.) - -> Break here between sessions. - -### Module 3 — Pattern 3: Workflows (20 min) - -- **Talk track:** "Same fan-out, expressed as Render tasks. The queue, retries, - coordination, and observability you hand-rolled are now declarative. The unit you - author is a **task**: a plain async function + a config object." -- **Show the code:** `code-review/index.ts` — each reviewer is a `task()` wrapping - `agent.run()`. `Promise.all` fans out. `ux` is conditional. -- **The aha — the fan-out table** (this is the punchline): - - | Pattern | How fan-out is written | You maintain | - | --- | --- | --- | - | naive | `Promise.all([...])` in one process | nothing, but no scale/durability | - | worker | `XADD` → consumer group → acks → pub/sub | the whole queue | - | workflow | `Promise.all([agent.run(), ...])` where `agent` is a `task()` | nothing | - -- **Pitfall:** empty task list → workflow didn't auto-discover. Must be - `src/workflows//index.ts` exporting a `task()`. -- **Pitfall:** module resolution errors → root directory/commands wrong. Root - should be `packages/workflow-agents`; build/start commands `cd ../..` first. -- **CFU:** "Where are the retries in Pattern 3?" (In the task's config object.) - -### Lab — Author a task (25–35 min, the finale) - -**Now coding agents come out.** - -- **Starter:** `your-review/index.ts` is a working sandbox, auto-discovered. -- **Sequence:** - 1. **Preview:** `render workflows tasks list --local` → run `your-review`. - 2. **Compose an agent as a task.** Encourage coding agents (Cursor/Claude/etc.) - — point them at the ideas at the bottom of the file. - 3. **Force a retry.** Add `if (Math.random() < 0.5) throw new Error("flaky!")`. - Watch Render retry with no try/catch. **Remove when done.** - 4. **Bonus — fan out** with `Promise.all` (mirrors `code-review`). - 5. **Ship it live.** Push, release a version, start the task, open the trace. -- **Pacing:** - - ~5 min on step 1. Buffer for stragglers. - - ~12 min on steps 2–3 (the core). Circulate. - - **15-min mark:** room check. If securityReviewer.run(input, { tracer: storeTracer() }))`." - 3. "Call it: `const review = await securityTask({ patches: filtered.patches })`." - 4. "For fan-out: one `task()` per reviewer, then `Promise.all`. See - `code-review/index.ts`." -- **Common bug:** forgetting to import `task` from `@renderinc/sdk/workflows` or - `storeTracer` from `@workshop/db`. -- **Coding agent tip:** point learners at `.agents/skills/` — the - `render-workflows` skill is useful for `task()` patterns. -- **The aha (say this):** "You just added durable, retried, isolated, traced, - parallel execution by writing a plain function and a config object. In Pattern 2 - that took a queue, consumer group, acks, and pub/sub. The agent never changed." -- **CFU:** "What's the difference between a step and a task?" (A step is a plain - function. A task is wrapped in `task()` for isolation/retries/traces.) - -### Module 4 — Close (10 min) - -- Name what they built and shipped. They have a running fork — this is code they own. -- Point at future iterations: eval harness, guardrails, circuit breakers. "More - steps, tasks, budgets, tracers. Still the same agent." -- The fork is the handoff. Mock model means they can keep going with zero credentials. - ---- - -## 5. Clock-time run sheet (print this) - -Adjust the start time to your slot. Everything else shifts. Modules marked with -a flex icon (~) can be shortened by the amount shown if you're running behind. -Modules marked with a lock icon (!) should never be cut — they carry the core -payoff. - -**Example: 9:00 AM start, 90-minute slot** - -| Clock | Dur | Module | Flex | Notes | -| --- | --- | --- | --- | --- | -| 9:00 AM | 10 min | **Module 0 — Setup & framing** | ~ can cut to 7 min | Draw the spine. Confirm `render login`. | -| 9:10 AM | 15 min | **Module 1 — Pattern 1** | ~ can cut to 10 min | Deploy, submit PR, show spans, break it on stage. | -| 9:25 AM | 20 min | **Module 2 — Pattern 2** | ~ can cut to 15 min | Deploy, tail worker logs, trace acks, scale, open `kv.ts`. | -| 9:45 AM | 10 min | **Break** | ~ can cut to 0 | Skip if running behind — but people need it. | -| 9:55 AM | 20 min | **Module 3 — Pattern 3** | ~ can cut to 12 min | Blueprint + CLI Workflow, run task, show trace, fan-out table. | -| 10:15 AM | 25 min | **Lab — Author a task** | ! never cut below 20 min | The finale. Steps 1–3 minimum; 4–5 if time allows. | -| 10:40 AM | 10 min | **Module 4 — Close** | ~ can cut to 5 min | Re-draw spine, exit ticket, point at future iterations. | -| 10:50 AM | — | **End** | | | - -**If you're 10+ min behind at the break:** cut Module 3 to 12 min (skip the -Dashboard walkthrough — just show the CLI and the trace) and start the Lab with -"steps 1–3 only, ship-live is homework." Protect the Lab's minimum 20 min — -it's the reason they came. - -**Transition cues (say these at each boundary):** -- Setup → Pattern 1: "Now let's see the simplest version live." -- Pattern 1 → Pattern 2: "That broke. Let's fix durability — but watch what it costs." -- Pattern 2 → Break: "That coordination is real. Hold onto that feeling." -- Break → Pattern 3: "Now watch all of that become a config object." -- Pattern 3 → Lab: "Your turn — bring out your coding agents." -- Lab → Close: "Let's zoom out. What changed in the agent? Nothing." - ---- - -## 6. Solutions - -### Lab — `your-review` (compose an agent as a task) - -```ts -import { task } from "@renderinc/sdk/workflows"; -import { securityReviewer } from "@workshop/agent"; -import { storeTracer } from "@workshop/db"; - -const securityTask = task( - { name: "security", timeoutSeconds: 120 }, - async (input: { patches: Patch[] }, runId?: string) => { - return securityReviewer.run(input, { tracer: storeTracer(), runId }); - }, -); - -// inside yourReview, after you have filtered.patches: -const review = await securityTask({ patches: filtered.patches }); -return { ...existingReturn, review: review.text }; -``` - -Bonus (fan out both reviewers): - -```ts -import { REVIEWERS } from "@workshop/agent"; -const reviews = await Promise.all( - REVIEWERS.map((agent) => - task( - { name: agent.name }, - async (input: { patches: Patch[] }) => agent.run(input, { tracer: storeTracer() }), - )({ patches: filtered.patches }), - ), -); -``` - ---- - -## 7. Assessment / exit ticket - -Quick checks that learners hit the objectives (use any 2–3): - -1. "Give one failure mode of Pattern 1 and the Pattern 2 feature that fixes it." -2. "What two things make up a Render `task()`?" (a config object + an async fn) -3. "Where did the retry logic live in Pattern 2 vs Pattern 3?" From 33065308b699b7800cc6281dc7591bbb09adee0f Mon Sep 17 00:00:00 2001 From: Jacob Prall Date: Thu, 18 Jun 2026 16:46:34 -0700 Subject: [PATCH 2/2] remove dead code --- scripts/docker-workflow-dev.sh | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 scripts/docker-workflow-dev.sh diff --git a/scripts/docker-workflow-dev.sh b/scripts/docker-workflow-dev.sh deleted file mode 100644 index 7addce03..00000000 --- a/scripts/docker-workflow-dev.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -# Pattern 3 Docker entrypoint: Render workflow dev server + gateway. -# -# 1. `render workflows dev` starts the local task server (:8120) and registers -# tasks from workflow.ts (task definitions only — no HTTP). -# 2. The gateway (server.ts) dispatches via SDK → :8120 and serves the UI (:3000). - -set -e - -PORT="${RENDER_DEV_PORT:-8120}" -DEV_URL="http://127.0.0.1:${PORT}" - -echo "[workflow-agents] starting Render workflow dev server on :${PORT}…" -render workflows dev --port "${PORT}" -- \ - node --import tsx packages/workflow-agents/src/workflow.ts & -DEV_PID=$! - -echo "[workflow-agents] waiting for task server on :${PORT}…" -for _ in $(seq 1 60); do - if curl -s --connect-timeout 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then - break - fi - sleep 1 -done - -export RENDER_USE_LOCAL_DEV=true -export RENDER_LOCAL_DEV_URL="${DEV_URL}" -export RENDER_API_KEY="${RENDER_API_KEY:-local-dev}" -export PORT=3000 - -echo "[workflow-agents] starting gateway on :${PORT} (dispatch → ${DEV_URL})…" -exec node --import tsx packages/workflow-agents/src/server.ts