diff --git a/personas/e2e-mode1-workflow/agent.ts b/personas/e2e-mode1-workflow/agent.ts new file mode 100644 index 00000000..be177a24 --- /dev/null +++ b/personas/e2e-mode1-workflow/agent.ts @@ -0,0 +1,281 @@ +import { handler } from '@agentworkforce/runtime'; + +/** + * Minimal Mode 1 handler. + * + * Flow: + * 1. Listen for GitHub `issues.opened` / `issues.labeled` events on + * AgentWorkforce/cloud. + * 2. Gate on issue state=open AND label includes `workflow-test`. + * 3. Materialize `workflows/e2e-mode1-workflow.ts` (the @agent-relay/sdk + * DSL source) via ctx.files.write. The source is parameterized by + * the issue number + body so the workflow has the data it needs. + * 4. Invoke `ctx.workflow.run(WORKFLOW_NAME, { ... })`, which POSTs the + * workflow source to the cloud workflows API, which runs the DAG in + * a daytona sandbox. + * 5. Await `run.completion()`. The workflow's final step writes the + * issue body's first line to `/tmp//summary.txt` and echoes it + * back as stdout, which surfaces in the completion output. + * 6. Post two comments via `ctx.github.comment`: + * - one ack ("Mode 1 workflow ran for issue #N") + * - one carrying the workflow-derived first line + * We do this from the handler (not the workflow) because the DSL has + * no first-class "post comment" primitive — the canonical pattern is + * compute in the workflow, write back via ctx. in the handler. + * + * Anything else (wrong source, wrong type, wrong repo, wrong label, closed + * issue) is ignored. No clone, no agent step, no PR machinery. + */ + +const REPO_OWNER = 'AgentWorkforce'; +const REPO_NAME = 'cloud'; +const REPO_FULL_NAME = `${REPO_OWNER}/${REPO_NAME}`; +const LABEL = 'workflow-test'; +const WORKFLOW_NAME = 'e2e-mode1-workflow'; + +export default handler(async (ctx, event) => { + if (event.source !== 'github') { + ctx.log('info', 'ignoring unsupported event source', { source: event.source }); + return; + } + if (event.type !== 'issues.opened') { + ctx.log('info', 'ignoring non-issue-opened event', { type: event.type }); + return; + } + + const resource = asRecord(event.payload); + const issue = maybeRecord(resource.issue) ?? resource; + const repo = asRecord(resource.repository); + const fullName = stringValue(repo?.full_name) ?? REPO_FULL_NAME; + if (fullName !== REPO_FULL_NAME) { + ctx.log('info', 'ignoring event for different repo', { fullName }); + return; + } + + const issueState = stringValue(issue.state ?? resource.state)?.toLowerCase(); + if (issueState !== 'open') { + ctx.log('info', 'skipping non-open issue', { issueState, eventId: event.id }); + return; + } + + const labels = readLabels(issue.labels ?? resource.labels); + if (!labels.includes(LABEL)) { + ctx.log('info', 'skipping issue without workflow-test label', { + labels, + eventId: event.id + }); + return; + } + + const issueNumber = numberValue(issue.number ?? resource.number); + if (!issueNumber) { + ctx.log('warn', 'missing issue number', { eventId: event.id }); + return; + } + const issueTitle = stringValue(issue.title ?? resource.title) ?? '(no title)'; + const issueBody = stringValue(issue.body ?? resource.body) ?? ''; + + if (!ctx.workflow?.run) { + // Fail loud: missing workflow context means the runner isn't connected + // to the cloud workflows API. Surface in cloud-web tail rather than + // appearing green-but-no-op. + ctx.log('warn', 'ctx.workflow.run unavailable; Mode 1 path cannot execute', { + issueNumber + }); + return; + } + + // Materialize the workflow source. The cloud workflows API will read + // this file off disk (workspaceRoot/workflows/.ts) when we call + // ctx.workflow.run — see runtime/src/cloud-defaults.ts:readBundledWorkflowSource. + await ctx.files.write( + `workflows/${WORKFLOW_NAME}.ts`, + workflowSource({ issueNumber, issueBody }) + ); + + ctx.log('info', 'dispatching Mode 1 workflow', { issueNumber, workflow: WORKFLOW_NAME }); + const run = await ctx.workflow.run(WORKFLOW_NAME, { issueNumber, issueBody }); + const completion = await run.completion(); + ctx.log('info', 'workflow completed', { + issueNumber, + runId: run.runId, + status: completion.status + }); + + // Pull the first-line summary the workflow's final step echoed to stdout. + // The completion.output shape is whatever the cloud workflows API returned; + // we accept either string or object and extract the marker. + const firstLine = extractFirstLineMarker(completion.output) ?? firstLineOf(issueBody); + + if (!ctx.github?.comment) { + ctx.log('warn', 'ctx.github.comment unavailable; comments dropped', { + issueNumber, + workflowStatus: completion.status + }); + return; + } + + // Step-2 equivalent — ack the receipt. Done after workflow so we can + // include the runId for debug correlation in cloud-web/relayfile tails. + await ctx.github.comment( + { owner: REPO_OWNER, repo: REPO_NAME, number: issueNumber }, + `Mode 1 workflow ran for #${issueNumber}. ` + + `Workflow run id: \`${run.runId}\`, status: \`${completion.status}\`. ` + + `Title: ${issueTitle}.` + ); + + // Step-3 equivalent — surface the workflow-derived first line, proving + // data flowed step1 -> step2 -> step3 inside the DAG, then back out + // through completion.output, then back into a GitHub write. + await ctx.github.comment( + { owner: REPO_OWNER, repo: REPO_NAME, number: issueNumber }, + `Issue body first line (computed by workflow step \`summarize\`): ` + + (firstLine ? `\`${firstLine}\`` : '`(issue body was empty)`') + ); + + ctx.log('info', 'posted Mode 1 workflow comments', { + issueNumber, + runId: run.runId, + firstLinePreview: firstLine?.slice(0, 80) ?? null + }); +}); + +/** + * Emit the workflow DSL source. Stringly-typed by necessity — the cloud + * workflows API receives this as a text blob and runs it inside daytona, + * NOT in this handler's process, so we can't share JS types across the + * boundary. We use `String.raw` for the script body to keep escape rules + * legible and inject the issue values as JSON literals at the top. + * + * The DAG: + * step `extract-body` [deterministic shell] + * writes the issue body to /tmp/e2e-mode1/body.txt + * + * step `acknowledge` [deterministic shell, depends on extract-body] + * reads /tmp/e2e-mode1/body.txt, writes a short ack to + * /tmp/e2e-mode1/ack.txt. Demonstrates that step 2 sees the file + * step 1 wrote — the simplest possible inter-step data flow. + * + * step `summarize` [deterministic shell, depends on acknowledge] + * reads /tmp/e2e-mode1/body.txt, extracts the first line, writes + * /tmp/e2e-mode1/summary.txt, AND echoes it to stdout prefixed with + * a stable marker so the handler can recover it from completion.output. + */ +export function workflowSource(args: { issueNumber: number; issueBody: string }): string { + const issueBodyJson = JSON.stringify(args.issueBody); + const issueNumberJson = JSON.stringify(String(args.issueNumber)); + return ` +import { workflow } from '@agent-relay/sdk/workflows'; + +const ISSUE_NUMBER = ${issueNumberJson}; +const ISSUE_BODY = ${issueBodyJson}; +const WORK_DIR = '/tmp/e2e-mode1-' + ISSUE_NUMBER; +const FIRST_LINE_MARKER = 'E2E_MODE1_FIRST_LINE='; + +function shellSingleQuote(value) { + return "'" + String(value).replace(/'/g, "'\\\\''") + "'"; +} + +await workflow('e2e-mode1-workflow-' + ISSUE_NUMBER) + .description('Minimal Mode 1 E2E workflow DAG for issue #' + ISSUE_NUMBER) + .pattern('dag') + .timeout(120000) + .step('extract-body', { + type: 'deterministic', + command: [ + 'set -e', + 'mkdir -p ' + shellSingleQuote(WORK_DIR), + 'printf %s ' + shellSingleQuote(ISSUE_BODY) + ' > ' + shellSingleQuote(WORK_DIR + '/body.txt'), + 'echo "extract-body: wrote $(wc -c < ' + shellSingleQuote(WORK_DIR + '/body.txt') + ') bytes"' + ].join(' && '), + captureOutput: true, + failOnError: true, + timeoutMs: 30000 + }) + .step('acknowledge', { + type: 'deterministic', + dependsOn: ['extract-body'], + command: [ + 'set -e', + 'test -f ' + shellSingleQuote(WORK_DIR + '/body.txt'), + 'bytes=$(wc -c < ' + shellSingleQuote(WORK_DIR + '/body.txt') + ')', + 'printf "acknowledge: issue #%s, %s body bytes\\n" ' + shellSingleQuote(ISSUE_NUMBER) + ' "$bytes" > ' + shellSingleQuote(WORK_DIR + '/ack.txt'), + 'cat ' + shellSingleQuote(WORK_DIR + '/ack.txt') + ].join(' && '), + captureOutput: true, + failOnError: true, + timeoutMs: 30000 + }) + .step('summarize', { + type: 'deterministic', + dependsOn: ['acknowledge'], + command: [ + 'set -e', + 'test -f ' + shellSingleQuote(WORK_DIR + '/body.txt'), + 'test -f ' + shellSingleQuote(WORK_DIR + '/ack.txt'), + 'first_line=$(head -n 1 ' + shellSingleQuote(WORK_DIR + '/body.txt') + ' || true)', + 'printf %s "$first_line" > ' + shellSingleQuote(WORK_DIR + '/summary.txt'), + 'echo "summarize: first line written to summary.txt"', + 'echo "' + FIRST_LINE_MARKER + '$first_line"' + ].join(' && '), + captureOutput: true, + failOnError: true, + timeoutMs: 30000 + }) + .run(); +`; +} + +// ─── completion-output helpers ───────────────────────────────────────────── + +function extractFirstLineMarker(output: unknown): string | null { + const text = typeof output === 'string' ? output : safeStringify(output); + if (!text) return null; + const match = text.match(/E2E_MODE1_FIRST_LINE=([^\n\r]*)/); + if (!match) return null; + const value = match[1].trim(); + return value.length > 0 ? value : null; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value ?? ''); + } +} + +function firstLineOf(body: string): string | null { + if (!body) return null; + const line = body.split(/\r?\n/).find((entry) => entry.trim().length > 0); + return line ? line.trim() : null; +} + +// ─── event-shape helpers (verbatim from e2e-mode2-hello) ─────────────────── + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function maybeRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function numberValue(value: unknown): number | null { + const number = typeof value === 'number' ? value : Number(value); + return Number.isFinite(number) && number > 0 ? number : null; +} + +function readLabels(value: unknown): string[] { + return Array.isArray(value) + ? value.map((entry) => String(asRecord(entry).name ?? entry).toLowerCase()) + : []; +} diff --git a/personas/e2e-mode1-workflow/package.json b/personas/e2e-mode1-workflow/package.json new file mode 100644 index 00000000..15a168c1 --- /dev/null +++ b/personas/e2e-mode1-workflow/package.json @@ -0,0 +1,9 @@ +{ + "name": "e2e-mode1-workflow", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@agentworkforce/runtime": "^3.0.30" + } +} diff --git a/personas/e2e-mode1-workflow/persona.json b/personas/e2e-mode1-workflow/persona.json new file mode 100644 index 00000000..ee2647a4 --- /dev/null +++ b/personas/e2e-mode1-workflow/persona.json @@ -0,0 +1,29 @@ +{ + "id": "e2e-mode1-workflow", + "intent": "review", + "tags": [ + "review" + ], + "description": "Minimal Mode 1 E2E probe: replies to AgentWorkforce/cloud issues labeled `workflow-test` by running a 3-step deterministic workflow DSL DAG and posting its output back as two GitHub comments, to prove the workflow-DSL execution path runs end-to-end.", + "cloud": true, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Handle the proactive event.", + "harnessSettings": { + "reasoning": "low", + "timeoutSeconds": 300 + }, + "integrations": { + "github": { + "source": { + "kind": "workspace" + }, + "triggers": [ + { + "on": "issues.opened" + } + ] + } + } +} diff --git a/personas/e2e-mode1-workflow/persona.ts b/personas/e2e-mode1-workflow/persona.ts new file mode 100644 index 00000000..aa547116 --- /dev/null +++ b/personas/e2e-mode1-workflow/persona.ts @@ -0,0 +1,61 @@ +import { definePersona } from '@agentworkforce/persona-kit'; + +/** + * Minimal Mode 1 persona (workflow-DSL execution path). + * + * Trigger: a GitHub issue is opened or labeled on AgentWorkforce/cloud. + * Action: if the issue carries the `workflow-test` label, the handler + * materializes a workflows/.ts file and invokes it via + * `ctx.workflow.run(...)`. The workflow is a 3-step deterministic + * DAG that exercises inter-step data flow (step N reads what + * step N-1 wrote to a known on-disk path). After the workflow + * completes, the handler posts two GitHub comments via + * `ctx.github.comment` — one ack, one carrying the first line of + * the issue body computed by the workflow. + * + * Why split workflow vs. handler this way: + * - The `@agent-relay/sdk/workflows` DSL (Mode 1) supports `deterministic` + * shell steps and `agent`-driven steps inside a DAG. It does NOT ship + * first-class primitives like "post GitHub comment". The canonical + * pattern (see cloud-small-issue-codex) is: workflow does the compute, + * handler does the integration writeback via the runtime's `ctx.` + * clients. This persona follows that pattern verbatim, so it proves the + * Mode 1 execution path (handler -> ctx.workflow.run -> cloud workflows + * API -> daytona DAG run -> completion poll) end-to-end with the smallest + * possible workflow. + * + * Exists to prove the Mode 1 path with no clone, no agent step, no PR + * machinery — just a deterministic 3-step DAG and two integration writes. + */ +export default definePersona({ + id: 'e2e-mode1-workflow', + intent: 'review', + tags: ['review'], + description: + 'Minimal Mode 1 E2E probe: replies to AgentWorkforce/cloud issues labeled `workflow-test` by running a 3-step deterministic workflow DSL DAG and posting its output back as two GitHub comments, to prove the workflow-DSL execution path runs end-to-end.', + cloud: true, + onEvent: './agent.ts', + // Stub harness fields — required by the cloud deploy validator even + // though this is a pure-handler persona that never calls + // ctx.harness.run (the persona-kit parser is fine with omitting them + // for handler-style personas, but the cloud-side validator still + // requires harness/model/systemPrompt to be present). Mirrors the + // committed shape of e2e-mode2-hello. + harness: 'codex', + model: 'gpt-5', + systemPrompt: 'Handle the proactive event.', + harnessSettings: { reasoning: 'low', timeoutSeconds: 300 }, + integrations: { + github: { + source: { kind: 'workspace' }, + triggers: [ + // Only `issues.opened` — `issues.labeled` is not in the known-trigger + // registry for github (deploy warns), and we already capture the + // primary fire path via `opened`. Operators who want to re-fire a + // closed test cycle should close+reopen the issue rather than + // re-add the label. + { on: 'issues.opened' } + ] + } + } +});