From 8b2ebe18aed09a3247ea1ef6f9180bc406030925 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 14:13:02 +0200 Subject: [PATCH 1/4] feat(runtime): adopt Relayfile-VFS as the canonical integration-client style Switches workforce's integration clients from direct REST calls to the Relayfile-VFS writeback pattern used by sage + the cloud workflows. Handler-side surface (ctx.github.upsertIssue, ctx.linear.comment, etc.) stays identical; the wire underneath flips from "speak HTTP to GitHub" to "write a JSON draft inside the Relayfile mount and let the writeback worker do the actual API call." Aligns workforce with the rest of the org's integration story and inherits writeback durability + retry for free. Substrate - packages/runtime/src/errors.ts (top-level): WorkforceIntegrationError moves here with the { provider, operation, cause, retryable } shape sage/cloud already use. Old clients/errors.ts is removed; the public surface re-exports it from the same package import path so existing consumers (mcp-workforce) keep compiling. - packages/runtime/src/clients/request.ts: shared VFS helpers (readJsonFile, readTextFile, listJsonFiles, listDirectoryEntries, writeJsonFile + atomic write-then-rename) with mount-root path validation and optional writeback-receipt polling. Clients - github.ts is rewritten as a VFS client. Same GithubClient interface (comment, createIssue, upsertIssue, getPr, postReview); each method now reads/writes files at canonical paths under `/github/repos///...`. - linear, slack, notion, jira ship as new typed clients with the same pattern. IntegrationClients in types.ts now types all five concretely instead of leaving four as unknown. Tests - github.test.ts is rewritten end-to-end against a tempdir mount. - linear/slack/notion/jira tests run against tempdir mounts too. - 29 runtime tests pass (up from 18), 386 across the repo. Example - weekly-digest/agent.ts drops the WORKFORCE_INTEGRATION_GITHUB_TOKEN plumbing; the github client picks up RELAYFILE_MOUNT_ROOT instead. - weekly-digest/README.md documents the writeback model + Relayfile mount env requirement, and drops the GITHUB_TOKEN setup step. Notes - mcp-workforce (PR #91) imports createGithubClient with a different construction shape today (`{ token }`); it'll need a follow-up commit to switch to IntegrationClientOptions once this lands. The MCP package depends on the new shape, not the old. - The direct-REST github implementation that shipped in #90 is replaced wholesale. No persona today depends on it; weekly-digest is updated in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/weekly-digest/README.md | 35 +- examples/weekly-digest/agent.ts | 32 +- packages/runtime/src/clients/errors.ts | 35 -- packages/runtime/src/clients/github.test.ts | 308 ++++++++-------- packages/runtime/src/clients/github.ts | 370 ++++++++++---------- packages/runtime/src/clients/index.ts | 38 +- packages/runtime/src/clients/jira.test.ts | 49 +++ packages/runtime/src/clients/jira.ts | 65 ++++ packages/runtime/src/clients/linear.test.ts | 46 +++ packages/runtime/src/clients/linear.ts | 81 +++++ packages/runtime/src/clients/notion.test.ts | 40 +++ packages/runtime/src/clients/notion.ts | 64 ++++ packages/runtime/src/clients/request.ts | 249 +++++++++++++ packages/runtime/src/clients/slack.test.ts | 65 ++++ packages/runtime/src/clients/slack.ts | 83 +++++ packages/runtime/src/errors.ts | 41 +++ packages/runtime/src/index.ts | 38 +- packages/runtime/src/types.ts | 22 +- 18 files changed, 1218 insertions(+), 443 deletions(-) delete mode 100644 packages/runtime/src/clients/errors.ts create mode 100644 packages/runtime/src/clients/jira.test.ts create mode 100644 packages/runtime/src/clients/jira.ts create mode 100644 packages/runtime/src/clients/linear.test.ts create mode 100644 packages/runtime/src/clients/linear.ts create mode 100644 packages/runtime/src/clients/notion.test.ts create mode 100644 packages/runtime/src/clients/notion.ts create mode 100644 packages/runtime/src/clients/request.ts create mode 100644 packages/runtime/src/clients/slack.test.ts create mode 100644 packages/runtime/src/clients/slack.ts create mode 100644 packages/runtime/src/errors.ts diff --git a/examples/weekly-digest/README.md b/examples/weekly-digest/README.md index 4c6d998e..5ab381f6 100644 --- a/examples/weekly-digest/README.md +++ b/examples/weekly-digest/README.md @@ -4,6 +4,20 @@ Weekly competitive-intel agent. Runs every Saturday at 09:00 UTC, queries Brave Search for the configured topics, dedupes + clusters by source host, and upserts a single GitHub issue per ISO week into `WEEKLY_DIGEST_REPO`. +## How GitHub writes happen + +Workforce integration clients **don't make direct REST calls to GitHub**. +The handler calls `ctx.github.upsertIssue(...)`, which writes a draft +JSON file at the canonical Relayfile path +`/github/repos///issues/...` inside the Relayfile mount. +Relayfile's writeback worker picks up the draft, makes the real GitHub +call, and writes a receipt back to the same file. The handler reads the +receipt to populate issue numbers, URLs, etc. + +This matches the rest of the workforce/cloud stack and gets writeback +durability + retry semantics for free. There's no `GITHUB_TOKEN` to +manage — Relayfile holds the GitHub App / OAuth credentials. + ## Required env ```sh @@ -11,14 +25,14 @@ export WEEKLY_DIGEST_TOPICS="agentworkforce,relayfile,proactive-agents" export WEEKLY_DIGEST_REPO="YourOrg/weekly-digest" export BRAVE_API_KEY="brave_..." -# GitHub credentials — either path works: -export WORKFORCE_INTEGRATION_GITHUB_TOKEN="ghp_..." -# or, for a quick demo without Relayfile: -export GITHUB_TOKEN="ghp_..." - # Workspace (only needed when actually launching, not for --dry-run): export WORKFORCE_WORKSPACE_ID="ws_demo" export WORKFORCE_WORKSPACE_TOKEN="ws_token_..." + +# Relayfile mount root the handler writes into. The workforce runtime +# sets this automatically when it spawns the handler. Only set it +# manually when running the bundle stand-alone (smoke tests). +export RELAYFILE_MOUNT_ROOT="/path/to/your/relayfile/mount" ``` ## Deploy @@ -39,17 +53,22 @@ workforce deploy ./examples/weekly-digest/persona.json --mode dev ## Firing the handler manually The runner reads NDJSON envelopes from stdin. To trigger the handler from -the command line, drive the bundle directly: +the command line against a Relayfile mount you've already set up, drive +the bundle directly: ```sh +RELAYFILE_MOUNT_ROOT=/path/to/mount \ echo '{"id":"manual-1","workspace":"ws_demo","type":"cron.tick","occurredAt":"2026-05-12T09:00:00Z","name":"weekly","cron":"0 9 * * 6"}' \ | node /tmp/wf-weekly-digest/runner.mjs ``` The handler will: -1. Resolve topics + repo + tokens from env. +1. Resolve topics + repo from env. 2. Query Brave Search per topic. 3. Dedupe by URL and cluster results by source host. -4. Upsert a single `Weekly digest — YYYY-WNN` issue in the target repo. +4. Write a draft (or update an existing) `Weekly digest — YYYY-WNN` + issue under `/github/repos///issues/...`. + Relayfile's writeback worker turns the file write into the actual + GitHub call. 5. Save a memory note tagged `weekly-digest` + `week:`. diff --git a/examples/weekly-digest/agent.ts b/examples/weekly-digest/agent.ts index 11a652ec..c51f9933 100644 --- a/examples/weekly-digest/agent.ts +++ b/examples/weekly-digest/agent.ts @@ -89,12 +89,10 @@ export default handler(async (ctx, event) => { }); }); -function readConfig(): { topics: string; repo: string; braveApiKey: string; githubToken: string } { +function readConfig(): { topics: string; repo: string; braveApiKey: string } { const topics = process.env.WEEKLY_DIGEST_TOPICS; const repo = process.env.WEEKLY_DIGEST_REPO; const braveApiKey = process.env.BRAVE_API_KEY; - const githubToken = - process.env.WORKFORCE_INTEGRATION_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''; if (!topics || !topics.trim()) { throw new Error('WEEKLY_DIGEST_TOPICS is required (comma-separated list)'); } @@ -104,24 +102,21 @@ function readConfig(): { topics: string; repo: string; braveApiKey: string; gith if (!braveApiKey) { throw new Error('BRAVE_API_KEY is required to query Brave Search'); } - if (!githubToken) { - throw new Error( - 'WORKFORCE_INTEGRATION_GITHUB_TOKEN (or GITHUB_TOKEN) is required to upsert the digest issue' - ); - } - return { topics, repo, braveApiKey, githubToken }; + return { topics, repo, braveApiKey }; } function resolveGithubClient(ctx: WorkforceCtx): GithubClient { + // The runtime injects a Relayfile-VFS-backed github client whenever + // the persona declares the `github` integration. For stand-alone + // dev runs without the runtime, fall back to a client rooted at the + // configured Relayfile mount (or cwd if RELAYFILE_MOUNT_ROOT is + // unset). The fallback path is mostly useful for local smoke tests + // — production handlers always get `ctx.github`. if (ctx.github) return ctx.github; - const token = - process.env.WORKFORCE_INTEGRATION_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''; - if (!token) { - throw new Error( - 'no GitHub client on ctx and no GITHUB_TOKEN in env — set WORKFORCE_INTEGRATION_GITHUB_TOKEN before deploy' - ); - } - return createGithubClient({ token }); + return createGithubClient({ + ...(process.env.RELAYFILE_MOUNT_ROOT ? { relayfileMountRoot: process.env.RELAYFILE_MOUNT_ROOT } : {}), + writebackTimeoutMs: 30_000 + }); } function parseTopics(raw: string): string[] { @@ -144,8 +139,7 @@ async function searchBrave(query: string, apiKey: string): Promise throw new WorkforceIntegrationError({ provider: 'brave', operation: 'search', - message: `${response.status} ${response.statusText}`, - status: response.status, + cause: new Error(`${response.status} ${response.statusText}`), retryable: response.status >= 500 || response.status === 429 }); } diff --git a/packages/runtime/src/clients/errors.ts b/packages/runtime/src/clients/errors.ts deleted file mode 100644 index 8e4fbe7a..00000000 --- a/packages/runtime/src/clients/errors.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Error thrown by every integration client when a remote call fails. The - * runtime's retry loop reads `retryable` to decide whether to redeliver - * the event; tests + handlers can branch on `provider` + `operation` for - * targeted recovery without parsing message strings. - */ -export class WorkforceIntegrationError extends Error { - readonly provider: string; - readonly operation: string; - readonly status?: number; - readonly retryable: boolean; - override readonly cause?: unknown; - - constructor(opts: { - provider: string; - operation: string; - message: string; - status?: number; - retryable?: boolean; - cause?: unknown; - }) { - super(`${opts.provider}.${opts.operation}: ${opts.message}`); - this.name = 'WorkforceIntegrationError'; - this.provider = opts.provider; - this.operation = opts.operation; - this.retryable = opts.retryable ?? false; - if (opts.status !== undefined) this.status = opts.status; - if (opts.cause !== undefined) this.cause = opts.cause; - } -} - -/** 5xx and 429 responses are retryable; 4xx (other than 429) are not. */ -export function isRetryableStatus(status: number): boolean { - return status >= 500 || status === 429; -} diff --git a/packages/runtime/src/clients/github.test.ts b/packages/runtime/src/clients/github.test.ts index 75b99041..54eb6c4a 100644 --- a/packages/runtime/src/clients/github.test.ts +++ b/packages/runtime/src/clients/github.test.ts @@ -1,177 +1,173 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { createGithubClient } from './github.js'; -import { WorkforceIntegrationError } from './errors.js'; -interface RecordedCall { - url: string; - method: string; - headers: Record; - body?: unknown; +async function tempMount(): Promise { + return mkdtemp(path.join(tmpdir(), 'workforce-runtime-github-')); } -function fakeFetch( - handlers: Array<(call: RecordedCall) => Response | Promise> -): { fetch: typeof fetch; calls: RecordedCall[] } { - const calls: RecordedCall[] = []; - let i = 0; - const fakeImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString(); - const headers: Record = {}; - if (init?.headers) { - const entries = - init.headers instanceof Headers - ? Array.from(init.headers.entries()) - : Array.isArray(init.headers) - ? init.headers - : Object.entries(init.headers); - for (const [k, v] of entries) headers[k.toLowerCase()] = String(v); - } - const body = init?.body ? JSON.parse(init.body.toString()) : undefined; - const call: RecordedCall = { - url, - method: init?.method ?? 'GET', - headers, - ...(body !== undefined ? { body } : {}) - }; - calls.push(call); - const handler = handlers[i]; - if (!handler) throw new Error(`fakeFetch: no handler at call index ${i}`); - i += 1; - return handler(call); - }) as typeof fetch; - return { fetch: fakeImpl, calls }; -} +test('github.comment writes a draft comment file under issues//comments/', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root }); + await client.comment({ owner: 'o', repo: 'r', number: 2 }, 'hello'); -test('createGithubClient.comment POSTs the issue comment endpoint with the right headers', async () => { - const { fetch: fakeImpl, calls } = fakeFetch([ - () => - new Response(JSON.stringify({ id: 1, html_url: 'https://github.com/o/r/issues/2#issuecomment-1' }), { - status: 201 - }) - ]); - const client = createGithubClient({ token: 'pat_abc', fetchImpl: fakeImpl }); - const ref = await client.comment({ owner: 'o', repo: 'r', number: 2 }, 'hello'); - - assert.equal(ref.number, 2); - assert.equal(ref.url, 'https://github.com/o/r/issues/2#issuecomment-1'); - assert.equal(calls[0].url, 'https://api.github.com/repos/o/r/issues/2/comments'); - assert.equal(calls[0].method, 'POST'); - assert.equal(calls[0].headers.authorization, 'Bearer pat_abc'); - assert.equal(calls[0].headers['x-github-api-version'], '2022-11-28'); - assert.deepEqual(calls[0].body, { body: 'hello' }); + const dir = path.join(root, 'github/repos/o/r/issues/2/comments'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + body: 'hello' + }); + } finally { + await rm(root, { recursive: true, force: true }); + } }); -test('createGithubClient.upsertIssue creates when no open match is found', async () => { - const { fetch: fakeImpl, calls } = fakeFetch([ - () => new Response(JSON.stringify({ items: [] }), { status: 200 }), - () => new Response(JSON.stringify({ number: 99, html_url: 'https://github.com/o/r/issues/99' }), { status: 201 }) - ]); - const client = createGithubClient({ token: 't', fetchImpl: fakeImpl }); - const result = await client.upsertIssue({ - owner: 'o', - repo: 'r', - title: 'fresh', - body: 'body', - matchTitle: 'fresh', - labels: ['digest'] - }); - assert.equal(result.created, true); - assert.equal(result.number, 99); - assert.equal(calls[0].method, 'GET'); - assert.match(calls[0].url, /\/search\/issues\?q=/); - assert.equal(calls[1].method, 'POST'); - assert.deepEqual(calls[1].body, { title: 'fresh', body: 'body', labels: ['digest'] }); +test('github.createIssue writes a draft issue file under issues/', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root }); + await client.createIssue({ + owner: 'o', + repo: 'r', + title: 'Track A', + body: 'do the thing', + labels: ['digest'] + }); + + const dir = path.join(root, 'github/repos/o/r/issues'); + const files = await readdir(dir); + const drafts = files.filter((name) => name.endsWith('.json')); + assert.equal(drafts.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, drafts[0] ?? ''), 'utf8')), { + title: 'Track A', + body: 'do the thing', + labels: ['digest'] + }); + } finally { + await rm(root, { recursive: true, force: true }); + } }); -test('createGithubClient.upsertIssue PATCHes when an open match exists', async () => { - const { fetch: fakeImpl, calls } = fakeFetch([ - () => - new Response( - JSON.stringify({ - items: [{ number: 7, title: 'weekly-digest', state: 'open', html_url: 'https://github.com/o/r/issues/7' }] - }), - { status: 200 } - ), - () => new Response(null, { status: 204 }) - ]); - const client = createGithubClient({ token: 't', fetchImpl: fakeImpl }); - const result = await client.upsertIssue({ - owner: 'o', - repo: 'r', - title: 'weekly-digest', - body: 'refreshed', - matchTitle: 'weekly-digest' - }); - assert.equal(result.created, false); - assert.equal(result.number, 7); - assert.equal(calls[1].method, 'PATCH'); - assert.deepEqual(calls[1].body, { body: 'refreshed' }); +test('github.upsertIssue updates an existing flat issue match', async () => { + const root = await tempMount(); + try { + const issueDir = path.join(root, 'github/repos/o/r/issues'); + await mkdir(issueDir, { recursive: true }); + await writeFile( + path.join(issueDir, '7.json'), + JSON.stringify({ number: 7, title: 'Weekly digest — 2026-W20', html_url: 'https://github.com/o/r/issues/7' }) + ); + + const client = createGithubClient({ relayfileMountRoot: root }); + const result = await client.upsertIssue({ + owner: 'o', + repo: 'r', + title: 'Weekly digest — 2026-W20', + body: 'refreshed', + matchTitle: 'Weekly digest — 2026-W20' + }); + assert.equal(result.created, false); + assert.equal(result.number, 7); + // Update wrote the canonical issue file in place. + const updated = JSON.parse(await readFile(path.join(issueDir, '7.json'), 'utf8')); + assert.equal(updated.body, 'refreshed'); + } finally { + await rm(root, { recursive: true, force: true }); + } }); -test('createGithubClient surfaces non-2xx with WorkforceIntegrationError', async () => { - const { fetch: fakeImpl } = fakeFetch([ - () => new Response('rate limited', { status: 429, statusText: 'Too Many Requests' }) - ]); - const client = createGithubClient({ token: 't', fetchImpl: fakeImpl }); - await assert.rejects( - () => client.comment({ owner: 'o', repo: 'r', number: 1 }, 'x'), - (err: unknown) => { - assert.ok(err instanceof WorkforceIntegrationError); - assert.equal(err.provider, 'github'); - assert.equal(err.operation, 'comment'); - assert.equal(err.status, 429); - assert.equal(err.retryable, true); - return true; - } - ); +test('github.upsertIssue creates a draft when no open match exists', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root }); + const result = await client.upsertIssue({ + owner: 'o', + repo: 'r', + title: 'fresh', + body: 'b', + matchTitle: 'fresh' + }); + assert.equal(result.created, true); + + const dir = path.join(root, 'github/repos/o/r/issues'); + const drafts = (await readdir(dir)).filter((name) => name.endsWith('.json')); + assert.equal(drafts.length, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } }); -test('createGithubClient.getPr fetches the diff through the canonical API endpoint (not pr.diff_url)', async () => { - const { fetch: fakeImpl, calls } = fakeFetch([ - () => - new Response( - JSON.stringify({ - title: 't', - body: 'b', - head: { ref: 'feature' }, - base: { ref: 'main' }, - user: { login: 'alice' }, - // Untrusted hint the client must ignore. - diff_url: 'https://attacker.example.com/leaked.diff' - }), - { status: 200 } - ), - () => - new Response('diff --git a/x b/x\n', { - status: 200, - headers: { 'content-type': 'application/vnd.github.v3.diff' } +test('github.getPr reads meta + diff from canonical paths', async () => { + const root = await tempMount(); + try { + const pullRoot = path.join(root, 'github/repos/o/r/pulls/42'); + await mkdir(pullRoot, { recursive: true }); + await writeFile( + path.join(pullRoot, 'meta.json'), + JSON.stringify({ + title: 'Add deploy v1', + body: 'ships it', + head: { ref: 'feature' }, + base: { ref: 'main' }, + user: { login: 'kgnt' } }) - ]); - const client = createGithubClient({ token: 'pat_secret', fetchImpl: fakeImpl }); - const pr = await client.getPr({ owner: 'o', repo: 'r', number: 5 }); - assert.match(pr.diff, /^diff --git/); - // Both requests target api.github.com, never the untrusted diff_url host. - for (const call of calls) { - assert.match(call.url, /^https:\/\/api\.github\.com\//); - assert.ok(!call.url.includes('attacker.example.com')); - assert.equal(call.headers.authorization, 'Bearer pat_secret'); + ); + await writeFile(path.join(pullRoot, 'diff.patch'), 'diff --git a/x b/x\n'); + + const client = createGithubClient({ relayfileMountRoot: root }); + const pr = await client.getPr({ owner: 'o', repo: 'r', number: 42 }); + assert.equal(pr.title, 'Add deploy v1'); + assert.equal(pr.head, 'feature'); + assert.equal(pr.base, 'main'); + assert.equal(pr.author, 'kgnt'); + assert.match(pr.diff, /^diff --git/); + } finally { + await rm(root, { recursive: true, force: true }); } - // Diff call uses the diff accept header. - assert.equal(calls[1].headers.accept, 'application/vnd.github.v3.diff'); }); -test('createGithubClient surfaces 4xx as non-retryable', async () => { - const { fetch: fakeImpl } = fakeFetch([ - () => new Response('not found', { status: 404, statusText: 'Not Found' }) - ]); - const client = createGithubClient({ token: 't', fetchImpl: fakeImpl }); - await assert.rejects( - () => client.comment({ owner: 'o', repo: 'r', number: 1 }, 'x'), - (err: unknown) => { - assert.ok(err instanceof WorkforceIntegrationError); - assert.equal(err.retryable, false); - return true; +test('github.postReview writes a review draft under pulls//reviews/', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root }); + await client.postReview( + { owner: 'o', repo: 'r', number: 42 }, + { + body: 'lgtm', + event: 'APPROVE', + comments: [{ path: 'src/x.ts', line: 7, body: 'nit' }] + } + ); + + const reviewsDir = path.join(root, 'github/repos/o/r/pulls/42/reviews'); + const drafts = (await readdir(reviewsDir)).filter((name) => name.endsWith('.json')); + assert.equal(drafts.length, 1); + const payload = JSON.parse(await readFile(path.join(reviewsDir, drafts[0] ?? ''), 'utf8')); + assert.equal(payload.event, 'APPROVE'); + assert.equal(payload.comments.length, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github.postReview accepts COMMENT/APPROVE/REQUEST_CHANGES events', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root }); + for (const event of ['COMMENT', 'APPROVE', 'REQUEST_CHANGES'] as const) { + await client.postReview({ owner: 'o', repo: 'r', number: event === 'COMMENT' ? 1 : event === 'APPROVE' ? 2 : 3 }, { + body: event.toLowerCase(), + event + }); } - ); + // Three review drafts landed across three different PR review dirs. + const dirs = await readdir(path.join(root, 'github/repos/o/r/pulls')); + assert.deepEqual(dirs.sort(), ['1', '2', '3']); + } finally { + await rm(root, { recursive: true, force: true }); + } }); diff --git a/packages/runtime/src/clients/github.ts b/packages/runtime/src/clients/github.ts index 2f22f889..fe959803 100644 --- a/packages/runtime/src/clients/github.ts +++ b/packages/runtime/src/clients/github.ts @@ -1,224 +1,210 @@ -import { isRetryableStatus, WorkforceIntegrationError } from './errors.js'; +import { + draftFile, + encodeSegment, + type IntegrationClientOptions, + listDirectoryEntries, + listJsonFiles, + readJsonFile, + readTextFile, + writeJsonFile +} from './request.js'; -export interface GithubIssueTarget { - owner: string; - repo: string; - number: number; -} - -export interface GithubRepoCoords { - owner: string; - repo: string; -} - -export interface GithubIssueRef { - number: number; - url: string; +/** + * Relayfile-VFS-backed GitHub client. Same handler-side surface as the + * direct-REST version this replaces; the difference is the wire — every + * call now reads/writes JSON files at canonical paths under a Relayfile + * mount, and Relayfile's writeback worker turns those into real GitHub + * REST calls with retry/durability. Auth + token rotation happens in + * Relayfile, not here, so personas don't have to thread a PAT through + * env. + */ +export interface GithubClient { + comment( + target: { owner: string; repo: string; number: number }, + body: string + ): Promise<{ id: string; url: string }>; + createIssue(args: { + owner: string; + repo: string; + title: string; + body: string; + labels?: string[]; + }): Promise<{ number: number; url: string }>; + upsertIssue(args: { + owner: string; + repo: string; + title: string; + body: string; + labels?: string[]; + matchTitle: string; + }): Promise<{ number: number; url: string; created: boolean }>; + getPr(target: { + owner: string; + repo: string; + number: number; + }): Promise<{ title: string; body: string; diff: string; head: string; base: string; author: string }>; + postReview( + target: { owner: string; repo: string; number: number }, + args: { + body: string; + event: 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'; + comments?: Array<{ path: string; line: number; body: string }>; + } + ): Promise; } -export interface GithubUpsertResult extends GithubIssueRef { - created: boolean; +interface GithubIssueFile { + number?: number; + html_url?: string; + url?: string; + title?: string; } -export interface GithubReviewComment { - path: string; - line: number; - body: string; +interface GithubPullRequestFile { + title?: string; + body?: string | null; + head?: { ref?: string } | string; + base?: { ref?: string } | string; + user?: { login?: string } | string; + author?: string; + diff?: string; } -export interface GithubReview { - body: string; - event: 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'; - comments?: GithubReviewComment[]; +function repoRoot(owner: string, repo: string): string { + return `/github/repos/${encodeSegment(owner)}/${encodeSegment(repo)}`; } -export interface GithubPr { - title: string; - body: string; - diff: string; - head: string; - base: string; - author: string; +async function findNumberSegment( + opts: IntegrationClientOptions, + kind: 'issues' | 'pulls', + owner: string, + repo: string, + number: number +): Promise { + // Relayfile may emit canonical paths under either `.json` or + // `__/` directories depending on the adapter version. Probe + // both shapes; fall back to the raw number so reads still surface a + // useful WorkforceIntegrationError if neither exists. + const dir = `${repoRoot(owner, repo)}/${kind}`; + const prefix = `${number}__`; + const entries = await listDirectoryEntries(opts, 'github', `find.${kind}`, dir); + return entries.find((entry) => entry === String(number) || entry.startsWith(prefix)) ?? String(number); } -/** - * Minimal GitHub client used by personas. Today it covers the operations - * weekly-digest and review-agent need; we grow it by need rather than - * mirroring the full REST surface. - */ -export interface GithubClient { - comment(target: GithubIssueTarget, body: string): Promise; - createIssue(args: GithubRepoCoords & { title: string; body: string; labels?: string[] }): Promise; - upsertIssue(args: GithubRepoCoords & { title: string; body: string; labels?: string[]; matchTitle: string }): Promise; - getPr(target: GithubIssueTarget): Promise; - postReview(target: GithubIssueTarget, review: GithubReview): Promise; +function readRef(value: GithubPullRequestFile['head']): string { + if (typeof value === 'string') return value; + return value?.ref ?? ''; } -export interface GithubClientOptions { - /** Bearer token. Either a PAT or a Relayfile-issued scoped token. */ - token: string; - /** Override for GitHub Enterprise. Defaults to api.github.com. */ - apiUrl?: string; - /** Optional fetch override (tests + custom transports). */ - fetchImpl?: typeof fetch; +function readAuthor(value: GithubPullRequestFile): string { + if (typeof value.user === 'string') return value.user; + return value.user?.login ?? value.author ?? ''; } -/** - * Construct a real GitHub client. The token is sent on every request as - * a Bearer credential, matching both PAT and GitHub App installation - * token conventions. - */ -export function createGithubClient(opts: GithubClientOptions): GithubClient { - const apiUrl = (opts.apiUrl ?? 'https://api.github.com').replace(/\/$/, ''); - const fetchImpl = opts.fetchImpl ?? fetch; - - async function request( - operation: string, - init: { method: string; pathname: string; body?: unknown; accept?: string; responseType?: 'json' | 'text' } - ): Promise { - const url = `${apiUrl}${init.pathname}`; - let response: Response; - try { - response = await fetchImpl(url, { - method: init.method, - headers: { - accept: init.accept ?? 'application/vnd.github+json', - authorization: `Bearer ${opts.token}`, - 'x-github-api-version': '2022-11-28', - ...(init.body !== undefined ? { 'content-type': 'application/json' } : {}), - 'user-agent': 'workforce-runtime' - }, - ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}) - }); - } catch (err) { - throw new WorkforceIntegrationError({ - provider: 'github', - operation, - message: `network error: ${err instanceof Error ? err.message : String(err)}`, - retryable: true, - cause: err - }); - } - - if (!response.ok) { - const bodyText = await response.text().catch(() => ''); - throw new WorkforceIntegrationError({ - provider: 'github', - operation, - message: `${response.status} ${response.statusText}${bodyText ? ` — ${truncate(bodyText, 400)}` : ''}`, - status: response.status, - retryable: isRetryableStatus(response.status) - }); - } - - if (response.status === 204) return undefined as T; - if (init.responseType === 'text') return (await response.text()) as unknown as T; - return (await response.json()) as T; - } - +export function createGithubClient(opts: IntegrationClientOptions): GithubClient { return { async comment(target, body) { - const out = await request<{ id: number; html_url: string }>('comment', { - method: 'POST', - pathname: `/repos/${target.owner}/${target.repo}/issues/${target.number}/comments`, - body: { body } - }); - return { number: target.number, url: out.html_url }; + const result = await writeJsonFile( + opts, + 'github', + 'comment', + `${repoRoot(target.owner, target.repo)}/issues/${encodeSegment(target.number)}/comments/${draftFile('create comment')}`, + { body } + ); + return { + id: result.receipt?.created ?? result.receipt?.id ?? result.path, + url: result.receipt?.url ?? result.path + }; }, + async createIssue(args) { - const out = await request<{ number: number; html_url: string }>('createIssue', { - method: 'POST', - pathname: `/repos/${args.owner}/${args.repo}/issues`, - body: { - title: args.title, - body: args.body, - ...(args.labels ? { labels: args.labels } : {}) - } - }); - return { number: out.number, url: out.html_url }; + const result = await writeJsonFile( + opts, + 'github', + 'createIssue', + `${repoRoot(args.owner, args.repo)}/issues/${draftFile('create issue')}`, + { title: args.title, body: args.body, labels: args.labels } + ); + const number = Number(result.receipt?.created ?? result.receipt?.id ?? 0); + return { number: Number.isFinite(number) ? number : 0, url: result.receipt?.url ?? result.path }; }, + async upsertIssue(args) { - const search = await request<{ - items: Array<{ number: number; title: string; html_url: string; state: string }>; - }>('upsertIssue.search', { - method: 'GET', - pathname: `/search/issues?q=${encodeURIComponent( - `repo:${args.owner}/${args.repo} in:title is:issue "${args.matchTitle}"` - )}&per_page=10` - }); - const exact = search.items.find( - (item) => item.title === args.matchTitle && item.state === 'open' + const issueDir = `${repoRoot(args.owner, args.repo)}/issues`; + const flatIssues = await listJsonFiles(opts, 'github', 'upsertIssue.find.flat', issueDir); + const entries = await listDirectoryEntries(opts, 'github', 'upsertIssue.find.dirs', issueDir); + const nestedIssueCandidates = await Promise.all( + entries + .filter((entry) => /^[1-9]\d*(?:__.*)?$/.test(entry)) + .map(async (entry) => + readJsonFile( + opts, + 'github', + 'upsertIssue.find.meta', + `${issueDir}/${entry}/meta.json` + ) + .then((value) => ({ path: `${issueDir}/${entry}/meta.json`, value })) + .catch(() => undefined) + ) + ); + const nestedIssues = nestedIssueCandidates.filter((result): result is { path: string; value: GithubIssueFile } => + Boolean(result?.value.title) ); - if (exact) { - await request('upsertIssue.edit', { - method: 'PATCH', - pathname: `/repos/${args.owner}/${args.repo}/issues/${exact.number}`, - body: { - body: args.body, - ...(args.labels ? { labels: args.labels } : {}) - } - }); - return { number: exact.number, url: exact.html_url, created: false }; + const issues = [...flatIssues, ...nestedIssues]; + const existing = issues.find((issue) => issue.value.title === args.matchTitle && issue.value.number); + if (existing?.value.number) { + await writeJsonFile( + opts, + 'github', + 'upsertIssue.update', + `${issueDir}/${encodeSegment(existing.value.number)}.json`, + { title: args.title, body: args.body, labels: args.labels } + ); + return { + number: existing.value.number, + url: existing.value.html_url ?? existing.value.url ?? '', + created: false + }; } - const created = await request<{ number: number; html_url: string }>('upsertIssue.create', { - method: 'POST', - pathname: `/repos/${args.owner}/${args.repo}/issues`, - body: { - title: args.matchTitle === args.title ? args.title : args.matchTitle, - body: args.body, - ...(args.labels ? { labels: args.labels } : {}) - } - }); - return { number: created.number, url: created.html_url, created: true }; + const created = await this.createIssue(args); + return { ...created, created: true }; }, + async getPr(target) { - const pr = await request<{ - title: string; - body: string | null; - head: { ref: string }; - base: { ref: string }; - user: { login: string } | null; - }>('getPr.metadata', { - method: 'GET', - pathname: `/repos/${target.owner}/${target.repo}/pulls/${target.number}` - }); - // Fetch the diff through the canonical API endpoint with the same - // configured host + auth pipeline, not whatever URL the previous - // response handed us. Using `request` keeps the bearer token scoped - // to `apiUrl` and reuses the WorkforceIntegrationError mapping. - const diff = await request('getPr.diff', { - method: 'GET', - pathname: `/repos/${target.owner}/${target.repo}/pulls/${target.number}`, - accept: 'application/vnd.github.v3.diff', - responseType: 'text' - }); + const pullSegment = await findNumberSegment(opts, 'pulls', target.owner, target.repo, target.number); + const pullRoot = `${repoRoot(target.owner, target.repo)}/pulls/${encodeSegment(pullSegment)}`; + const pr = await readJsonFile( + opts, + 'github', + 'getPr', + `${pullRoot}/meta.json` + ).catch(() => + readJsonFile( + opts, + 'github', + 'getPr', + `${repoRoot(target.owner, target.repo)}/pulls/${encodeSegment(target.number)}/metadata.json` + ) + ); + const diff = await readTextFile(opts, 'github', 'getPr.diff', `${pullRoot}/diff.patch`).catch(() => ''); return { - title: pr.title, + title: pr.title ?? '', body: pr.body ?? '', - diff, - head: pr.head.ref, - base: pr.base.ref, - author: pr.user?.login ?? '' + diff: pr.diff ?? diff, + head: readRef(pr.head), + base: readRef(pr.base), + author: readAuthor(pr) }; }, - async postReview(target, review) { - await request('postReview', { - method: 'POST', - pathname: `/repos/${target.owner}/${target.repo}/pulls/${target.number}/reviews`, - body: { - body: review.body, - event: review.event, - ...(review.comments - ? { - comments: review.comments.map((c) => ({ path: c.path, line: c.line, body: c.body })) - } - : {}) - } - }); + + async postReview(target, args) { + await writeJsonFile( + opts, + 'github', + 'postReview', + `${repoRoot(target.owner, target.repo)}/pulls/${encodeSegment(target.number)}/reviews/${draftFile('create review')}`, + { ...args, comments: args.comments ?? [] } + ); } }; } - -function truncate(s: string, n: number): string { - return s.length <= n ? s : `${s.slice(0, n)}…`; -} diff --git a/packages/runtime/src/clients/index.ts b/packages/runtime/src/clients/index.ts index 2ebca074..ad84afd6 100644 --- a/packages/runtime/src/clients/index.ts +++ b/packages/runtime/src/clients/index.ts @@ -1,14 +1,32 @@ export { createGithubClient, - type GithubClient, - type GithubClientOptions, - type GithubIssueRef, - type GithubIssueTarget, - type GithubPr, - type GithubRepoCoords, - type GithubReview, - type GithubReviewComment, - type GithubUpsertResult + type GithubClient } from './github.js'; -export { WorkforceIntegrationError, isRetryableStatus } from './errors.js'; +export { createLinearClient, type LinearClient } from './linear.js'; + +export { createSlackClient, type SlackClient } from './slack.js'; + +export { createNotionClient, type NotionClient } from './notion.js'; + +export { createJiraClient, type JiraClient } from './jira.js'; + +// Shared VFS-backed transport surface. Consumers building custom +// clients (a new provider, an in-house writeback variant) can import +// these directly instead of recreating the path-validation + +// receipt-polling logic. +export { + draftFile, + encodeSegment, + listDirectoryEntries, + listJsonFiles, + readJsonFile, + readTextFile, + resolveMountRoot, + writeJsonFile, + type IntegrationClientOptions, + type WritebackReceipt, + type WritebackResult +} from './request.js'; + +export { WorkforceIntegrationError } from '../errors.js'; diff --git a/packages/runtime/src/clients/jira.test.ts b/packages/runtime/src/clients/jira.test.ts new file mode 100644 index 00000000..7286d179 --- /dev/null +++ b/packages/runtime/src/clients/jira.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createJiraClient } from './jira.js'; + +async function tempMount(): Promise { + return mkdtemp(path.join(tmpdir(), 'workforce-runtime-')); +} + +test('jira createIssue writes a Jira issue draft', async () => { + const root = await tempMount(); + try { + const client = createJiraClient({ relayfileMountRoot: root }); + await client.createIssue({ + cloudId: 'cloud_1', + fields: { project: { key: 'ENG' }, summary: 'Ship it', issuetype: { name: 'Task' } } + }); + + const dir = path.join(root, 'jira/issues'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + cloudId: 'cloud_1', + fields: { project: { key: 'ENG' }, summary: 'Ship it', issuetype: { name: 'Task' } } + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('jira transition writes an issue transition draft', async () => { + const root = await tempMount(); + try { + const client = createJiraClient({ relayfileMountRoot: root }); + await client.transition({ cloudId: 'cloud_1', issueIdOrKey: 'ENG-1' }, '31'); + + const dir = path.join(root, 'jira/issues/ENG-1/transitions'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + cloudId: 'cloud_1', + transition: { id: '31' } + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/clients/jira.ts b/packages/runtime/src/clients/jira.ts new file mode 100644 index 00000000..4f8ee20c --- /dev/null +++ b/packages/runtime/src/clients/jira.ts @@ -0,0 +1,65 @@ +import { + draftFile, + encodeSegment, + type IntegrationClientOptions, + writeJsonFile +} from './request.js'; + +export interface JiraClient { + createIssue(args: { + cloudId: string; + fields: Record; + }): Promise<{ id: string; key: string; self: string }>; + comment( + target: { cloudId: string; issueIdOrKey: string }, + body: string | Record + ): Promise<{ id: string; self: string }>; + transition( + target: { cloudId: string; issueIdOrKey: string }, + transition: string | { id: string } + ): Promise; +} + +export function createJiraClient(opts: IntegrationClientOptions): JiraClient { + return { + async createIssue(args) { + const result = await writeJsonFile( + opts, + 'jira', + 'createIssue', + `/jira/issues/${draftFile('create issue')}`, + { cloudId: args.cloudId, fields: args.fields } + ); + return { + id: result.receipt?.created ?? result.receipt?.id ?? result.path, + key: typeof result.receipt?.key === 'string' ? result.receipt.key : '', + self: typeof result.receipt?.self === 'string' ? result.receipt.self : result.path + }; + }, + + async comment(target, body) { + const result = await writeJsonFile( + opts, + 'jira', + 'comment', + `/jira/issues/${encodeSegment(target.issueIdOrKey)}/comments/${draftFile('create comment')}`, + { cloudId: target.cloudId, body } + ); + return { + id: result.receipt?.created ?? result.receipt?.id ?? result.path, + self: typeof result.receipt?.self === 'string' ? result.receipt.self : result.path + }; + }, + + async transition(target, transition) { + const id = typeof transition === 'string' ? transition.trim() : transition.id.trim(); + await writeJsonFile( + opts, + 'jira', + 'transition', + `/jira/issues/${encodeSegment(target.issueIdOrKey)}/transitions/${draftFile('create transition')}`, + { cloudId: target.cloudId, transition: { id } } + ); + } + }; +} diff --git a/packages/runtime/src/clients/linear.test.ts b/packages/runtime/src/clients/linear.test.ts new file mode 100644 index 00000000..9f2dd518 --- /dev/null +++ b/packages/runtime/src/clients/linear.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createLinearClient } from './linear.js'; + +async function tempMount(): Promise { + return mkdtemp(path.join(tmpdir(), 'workforce-runtime-')); +} + +test('linear createIssue writes an issue draft', async () => { + const root = await tempMount(); + try { + const client = createLinearClient({ relayfileMountRoot: root }); + await client.createIssue({ teamId: 'team_1', title: 'Ship it', description: 'Soon' }); + + const dir = path.join(root, 'linear/issues'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + teamId: 'team_1', + title: 'Ship it', + description: 'Soon' + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('linear getIssue reads a canonical issue file', async () => { + const root = await tempMount(); + try { + const issuePath = path.join(root, 'linear/issues/ENG-1.json'); + await mkdir(path.dirname(issuePath), { recursive: true }); + await writeFile( + issuePath, + JSON.stringify({ id: 'i1', identifier: 'ENG-1', title: 'Ship it', description: null, url: 'https://linear.app/i1', state: null }) + ); + + const client = createLinearClient({ relayfileMountRoot: root }); + assert.equal((await client.getIssue('ENG-1')).identifier, 'ENG-1'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/clients/linear.ts b/packages/runtime/src/clients/linear.ts new file mode 100644 index 00000000..f07968e9 --- /dev/null +++ b/packages/runtime/src/clients/linear.ts @@ -0,0 +1,81 @@ +import { + draftFile, + encodeSegment, + type IntegrationClientOptions, + readJsonFile, + writeJsonFile +} from './request.js'; + +export interface LinearClient { + createIssue(args: { + teamId: string; + title: string; + description?: string; + assigneeId?: string; + labelIds?: string[]; + projectId?: string; + stateId?: string; + }): Promise<{ id: string; identifier: string; url: string }>; + updateIssue( + issueId: string, + args: { title?: string; description?: string; assigneeId?: string; stateId?: string } + ): Promise<{ id: string; identifier: string; url: string }>; + comment(issueId: string, body: string): Promise<{ id: string; url: string }>; + getIssue(issueId: string): Promise<{ + id: string; + identifier: string; + title: string; + description: string | null; + url: string; + state: { name: string } | null; + }>; +} + +type LinearIssue = Awaited>; + +export function createLinearClient(opts: IntegrationClientOptions): LinearClient { + return { + async createIssue(args) { + const result = await writeJsonFile( + opts, + 'linear', + 'createIssue', + `/linear/issues/${draftFile('create issue')}`, + args + ); + return { + id: result.receipt?.created ?? result.receipt?.id ?? result.path, + identifier: typeof result.receipt?.identifier === 'string' ? result.receipt.identifier : '', + url: result.receipt?.url ?? result.path + }; + }, + + async updateIssue(issueId, args) { + await writeJsonFile(opts, 'linear', 'updateIssue', `/linear/issues/${encodeSegment(issueId)}.json`, args); + const issue = await this.getIssue(issueId).catch(() => undefined); + return { + id: issue?.id ?? issueId, + identifier: issue?.identifier ?? '', + url: issue?.url ?? '' + }; + }, + + async comment(issueId, body) { + const result = await writeJsonFile( + opts, + 'linear', + 'comment', + `/linear/issues/${encodeSegment(issueId)}/comments/${draftFile('create comment')}`, + { body } + ); + return { + id: result.receipt?.created ?? result.receipt?.id ?? result.path, + url: result.receipt?.url ?? result.path + }; + }, + + getIssue(issueId) { + return readJsonFile(opts, 'linear', 'getIssue', `/linear/issues/${encodeSegment(issueId)}.json`); + } + }; +} diff --git a/packages/runtime/src/clients/notion.test.ts b/packages/runtime/src/clients/notion.test.ts new file mode 100644 index 00000000..8266c570 --- /dev/null +++ b/packages/runtime/src/clients/notion.test.ts @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createNotionClient } from './notion.js'; + +async function tempMount(): Promise { + return mkdtemp(path.join(tmpdir(), 'workforce-runtime-')); +} + +test('notion createPage writes a database page draft', async () => { + const root = await tempMount(); + try { + const client = createNotionClient({ relayfileMountRoot: root }); + await client.createPage( + { database_id: 'db_1' }, + { Name: { title: [{ text: { content: 'Digest' } }] } }, + [{ object: 'block', type: 'paragraph' }] + ); + + const dir = path.join(root, 'notion/databases/db_1/pages'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + properties: { Name: { title: [{ text: { content: 'Digest' } }] } }, + children: [{ object: 'block', type: 'paragraph' }] + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('notion createPage requires a database parent for file writeback', async () => { + const client = createNotionClient({ relayfileMountRoot: '/tmp/unused' }); + await assert.rejects( + () => client.createPage({}, {}, []), + /parent\.database_id/ + ); +}); diff --git a/packages/runtime/src/clients/notion.ts b/packages/runtime/src/clients/notion.ts new file mode 100644 index 00000000..1e1ed03d --- /dev/null +++ b/packages/runtime/src/clients/notion.ts @@ -0,0 +1,64 @@ +import { + draftFile, + encodeSegment, + type IntegrationClientOptions, + readJsonFile, + writeJsonFile +} from './request.js'; + +export interface NotionPage { + id: string; + url?: string; + properties?: Record; + [key: string]: unknown; +} + +export interface NotionClient { + createPage( + parent: Record, + properties: Record, + content: Array> + ): Promise; + updatePage( + pageId: string, + args: { properties?: Record; archived?: boolean; inTrash?: boolean } + ): Promise; + getPage(pageId: string): Promise; +} + +function readDatabaseId(parent: Record): string { + const databaseId = parent.database_id ?? parent.databaseId; + if (typeof databaseId !== 'string' || databaseId.trim().length === 0) { + throw new Error('Notion createPage file writeback requires parent.database_id'); + } + return databaseId.trim(); +} + +export function createNotionClient(opts: IntegrationClientOptions): NotionClient { + return { + async createPage(parent, properties, content) { + const databaseId = readDatabaseId(parent); + const result = await writeJsonFile( + opts, + 'notion', + 'createPage', + `/notion/databases/${encodeSegment(databaseId)}/pages/${draftFile('create page')}`, + { properties, children: content } + ); + return { + id: result.receipt?.created ?? result.receipt?.id ?? '', + url: result.receipt?.url, + properties + }; + }, + + async updatePage(pageId, args) { + await writeJsonFile(opts, 'notion', 'updatePage', `/notion/pages/${encodeSegment(pageId)}.json`, args); + return this.getPage(pageId).catch(() => ({ id: pageId, ...args })); + }, + + getPage(pageId) { + return readJsonFile(opts, 'notion', 'getPage', `/notion/pages/${encodeSegment(pageId)}.json`); + } + }; +} diff --git a/packages/runtime/src/clients/request.ts b/packages/runtime/src/clients/request.ts new file mode 100644 index 00000000..c854ed14 --- /dev/null +++ b/packages/runtime/src/clients/request.ts @@ -0,0 +1,249 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { WorkforceIntegrationError } from '../errors.js'; + +/** + * Shared VFS-backed transport for every workforce integration client. + * + * Workforce integration clients do not call provider REST APIs + * directly. Instead they read and write JSON files at canonical paths + * inside a Relayfile mount (`///.json`). A + * Relayfile writeback worker picks up the draft files and turns them + * into real provider calls, then writes a receipt back to the same + * path. This matches the rest of the workforce/cloud stack (sage, + * workflows) and gets writeback durability + retry semantics for free. + * + * The handler-side ergonomics stay identical to the direct-REST shape + * — `await ctx.github.comment(target, body)` returns when the write + * lands. Whether the receipt is awaited synchronously, polled, or + * fired-and-forgotten depends on `writebackTimeoutMs`. + */ +export interface IntegrationClientOptions { + /** Absolute path to the Relayfile mount the handler is running in. */ + relayfileMountRoot?: string; + /** @deprecated alias for {@link relayfileMountRoot}. */ + relayfileRoot?: string; + /** @deprecated alias for {@link relayfileMountRoot}. */ + mountRoot?: string; + /** Working directory fallback when no mount root is configured. */ + workspaceCwd?: string; + /** + * Max wait, in ms, for the Relayfile writeback worker to emit a + * receipt onto the just-written draft. `0` (default) means + * fire-and-forget — the client returns immediately and the receipt + * is whatever was readable at write time. + */ + writebackTimeoutMs?: number; + /** Poll interval while waiting for a receipt. Default 250ms. */ + writebackPollMs?: number; + /** Relayfile connection id, if the writeback needs one. */ + connectionId?: string; + /** Direct Relayfile API base URL, when the client talks to it out-of-band. */ + relayfileBaseUrl?: string; + /** API token for the Relayfile API, when applicable. */ + relayfileApiToken?: string; + /** Workforce cloud API token, for cross-service auth (slack, jira). */ + cloudApiToken?: string; + /** Workspace id the handler is bound to. */ + workspaceId?: string; + /** Slack team id, when the client targets a specific workspace. */ + slackTeamId?: string; +} + +/** + * Shape of the JSON the Relayfile writeback worker writes back into a + * draft file once the remote write completes. Clients read this back + * to populate their return values (issue numbers, comment ids, etc.). + */ +export interface WritebackReceipt { + created?: string; + path?: string; + url?: string; + id?: string; + identifier?: string; + [key: string]: unknown; +} + +export interface WritebackResult { + path: string; + absolutePath: string; + receipt?: WritebackReceipt; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Percent-encode a path segment so identifiers safely round-trip. */ +export function encodeSegment(value: string | number): string { + return encodeURIComponent(String(value)); +} + +/** + * Allocate a unique draft filename for a new resource. The Relayfile + * writeback worker renames the file to the canonical id on receipt. + */ +export function draftFile(prefix: string): string { + return `${prefix} ${randomUUID()}.json`; +} + +/** + * Resolve the absolute Relayfile mount root, honoring (in order) the + * client-supplied option, the `RELAYFILE_MOUNT_ROOT` / `RELAYFILE_ROOT` + * env vars, and finally `workspaceCwd` / `process.cwd()`. + */ +export function resolveMountRoot(client: IntegrationClientOptions): string { + return path.resolve( + client.relayfileMountRoot ?? + client.relayfileRoot ?? + client.mountRoot ?? + process.env.RELAYFILE_MOUNT_ROOT ?? + process.env.RELAYFILE_ROOT ?? + client.workspaceCwd ?? + process.cwd() + ); +} + +function toAbsolutePath(client: IntegrationClientOptions, relayPath: string): string { + const root = resolveMountRoot(client); + const normalized = relayPath.startsWith('/') ? relayPath.slice(1) : relayPath; + const absolute = path.resolve(root, normalized); + const relative = path.relative(root, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Relayfile path escapes mount root: ${relayPath}`); + } + return absolute; +} + +export async function readJsonFile( + client: IntegrationClientOptions, + provider: string, + operation: string, + relayPath: string +): Promise { + try { + const absolutePath = toAbsolutePath(client, relayPath); + return JSON.parse(await readFile(absolutePath, 'utf8')) as T; + } catch (cause) { + throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); + } +} + +export async function readTextFile( + client: IntegrationClientOptions, + provider: string, + operation: string, + relayPath: string +): Promise { + try { + return await readFile(toAbsolutePath(client, relayPath), 'utf8'); + } catch (cause) { + throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); + } +} + +export async function listJsonFiles( + client: IntegrationClientOptions, + provider: string, + operation: string, + relayDir: string +): Promise> { + try { + const absoluteDir = toAbsolutePath(client, relayDir); + const entries = await readdirIfPresent(absoluteDir); + const out: Array<{ path: string; value: T }> = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const relayPath = `${relayDir.replace(/\/+$/, '')}/${entry}`; + const value = JSON.parse(await readFile(path.join(absoluteDir, entry), 'utf8')) as T; + out.push({ path: relayPath, value }); + } + return out; + } catch (cause) { + throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); + } +} + +export async function listDirectoryEntries( + client: IntegrationClientOptions, + provider: string, + operation: string, + relayDir: string +): Promise { + try { + return await readdirIfPresent(toAbsolutePath(client, relayDir)); + } catch (cause) { + throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); + } +} + +async function readdirIfPresent(absoluteDir: string): Promise { + try { + return await readdir(absoluteDir); + } catch (error) { + if (isNoEntryError(error)) { + return []; + } + throw error; + } +} + +function isNoEntryError(error: unknown): boolean { + return isRecord(error) && error.code === 'ENOENT'; +} + +/** + * Write a draft JSON payload atomically (write-then-rename) so the + * writeback worker never sees a partial file. Waits for a receipt + * when `writebackTimeoutMs > 0`; otherwise returns immediately. + */ +export async function writeJsonFile( + client: IntegrationClientOptions, + provider: string, + operation: string, + relayPath: string, + body: unknown +): Promise { + try { + const absolutePath = toAbsolutePath(client, relayPath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + const tempPath = `${absolutePath}.tmp-${randomUUID()}`; + await writeFile(tempPath, `${JSON.stringify(body, null, 2)}\n`, 'utf8'); + await rename(tempPath, absolutePath); + const receipt = await waitForReceipt(absolutePath, client); + return { path: relayPath, absolutePath, ...(receipt ? { receipt } : {}) }; + } catch (cause) { + throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); + } +} + +async function waitForReceipt( + absolutePath: string, + client: IntegrationClientOptions +): Promise { + const timeoutMs = client.writebackTimeoutMs ?? 0; + const deadline = Date.now() + timeoutMs; + do { + const parsed = await readCurrentJson(absolutePath); + if ( + isRecord(parsed) && + (typeof parsed.created === 'string' || + typeof parsed.path === 'string' || + typeof parsed.id === 'string') + ) { + return parsed as WritebackReceipt; + } + if (timeoutMs <= 0) return undefined; + await new Promise((resolve) => setTimeout(resolve, client.writebackPollMs ?? 250)); + } while (Date.now() < deadline); + return undefined; +} + +async function readCurrentJson(absolutePath: string): Promise { + try { + return JSON.parse(await readFile(absolutePath, 'utf8')) as unknown; + } catch { + return undefined; + } +} diff --git a/packages/runtime/src/clients/slack.test.ts b/packages/runtime/src/clients/slack.test.ts new file mode 100644 index 00000000..3a153ded --- /dev/null +++ b/packages/runtime/src/clients/slack.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { WorkforceIntegrationError } from '../errors.js'; +import { createSlackClient } from './slack.js'; + +async function tempMount(): Promise { + return mkdtemp(path.join(tmpdir(), 'workforce-runtime-')); +} + +test('slack post writes a channel message draft', async () => { + const root = await tempMount(); + try { + const client = createSlackClient({ relayfileMountRoot: root }); + await client.post('C123', 'hello'); + + const dir = path.join(root, 'slack/channels/C123/messages'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + text: 'hello' + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('slack dm writes a user direct-message draft', async () => { + const root = await tempMount(); + try { + const client = createSlackClient({ relayfileMountRoot: root }); + await client.dm('U123', 'ping'); + + const dir = path.join(root, 'slack/users/U123/messages'); + const files = await readdir(dir); + assert.equal(files.length, 1); + assert.deepEqual(JSON.parse(await readFile(path.join(dir, files[0] ?? ''), 'utf8')), { + text: 'ping' + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('slack reply rejects malformed string thread refs', async () => { + const client = createSlackClient({ relayfileMountRoot: '/tmp/unused' }); + await assert.rejects( + () => client.reply('missing-ts', 'hello'), + (error) => error instanceof WorkforceIntegrationError && error.provider === 'slack' + ); +}); + +test('slack reply rejects malformed object thread refs', async () => { + const client = createSlackClient({ relayfileMountRoot: '/tmp/unused' }); + await assert.rejects( + () => client.reply({ channel: '', ts: '123.456' }, 'hello'), + (error) => error instanceof WorkforceIntegrationError && error.provider === 'slack' + ); + await assert.rejects( + () => client.reply({ channel: 'C123', ts: '' }, 'hello'), + (error) => error instanceof WorkforceIntegrationError && error.provider === 'slack' + ); +}); diff --git a/packages/runtime/src/clients/slack.ts b/packages/runtime/src/clients/slack.ts new file mode 100644 index 00000000..ee7776f0 --- /dev/null +++ b/packages/runtime/src/clients/slack.ts @@ -0,0 +1,83 @@ +import { WorkforceIntegrationError } from '../errors.js'; +import { + draftFile, + encodeSegment, + type IntegrationClientOptions, + writeJsonFile +} from './request.js'; + +export type SlackThreadRef = string | { channel: string; ts: string }; + +export interface SlackClient { + post(channel: string, text: string): Promise<{ channel: string; ts: string }>; + reply(threadTs: SlackThreadRef, text: string): Promise<{ channel: string; ts: string }>; + dm(user: string, text: string): Promise<{ channel: string; ts: string }>; +} + +function parseThreadRef(threadTs: SlackThreadRef): { channel: string; ts: string } { + if (typeof threadTs !== 'string') { + if (!threadTs.channel.trim() || !threadTs.ts.trim()) { + throw new WorkforceIntegrationError({ + provider: 'slack', + operation: 'reply', + cause: new Error('Slack reply threadTs must include non-empty channel and ts'), + retryable: false + }); + } + return threadTs; + } + + const [channel, ...rest] = threadTs.split(':'); + const ts = rest.join(':'); + if (!channel || !ts) { + throw new WorkforceIntegrationError({ + provider: 'slack', + operation: 'reply', + cause: new Error('Slack reply threadTs must be { channel, ts } or "channel:ts"'), + retryable: false + }); + } + return { channel, ts }; +} + +function tsPathSegment(ts: string): string { + return encodeSegment(ts.replace(/\./g, '_')); +} + +export function createSlackClient(opts: IntegrationClientOptions): SlackClient { + return { + async post(channel, text) { + const result = await writeJsonFile( + opts, + 'slack', + 'post', + `/slack/channels/${encodeSegment(channel)}/messages/${draftFile('create message')}`, + { text } + ); + return { channel, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; + }, + + async reply(threadTs, text) { + const thread = parseThreadRef(threadTs); + const result = await writeJsonFile( + opts, + 'slack', + 'reply', + `/slack/channels/${encodeSegment(thread.channel)}/messages/${tsPathSegment(thread.ts)}/replies/${draftFile('create reply')}`, + { text } + ); + return { channel: thread.channel, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; + }, + + async dm(user, text) { + const result = await writeJsonFile( + opts, + 'slack', + 'dm', + `/slack/users/${encodeSegment(user)}/messages/${draftFile('create dm')}`, + { text } + ); + return { channel: user, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; + } + }; +} diff --git a/packages/runtime/src/errors.ts b/packages/runtime/src/errors.ts new file mode 100644 index 00000000..1c6292cc --- /dev/null +++ b/packages/runtime/src/errors.ts @@ -0,0 +1,41 @@ +/** + * Error type every integration client throws when a remote write or read + * fails. Lives at the package root rather than inside `clients/` so the + * persona's handler can `import { WorkforceIntegrationError }` without + * reaching into a subpath that may evolve. + * + * The error carries enough metadata for the runtime's retry layer to + * make decisions without parsing message strings: + * + * - `provider` — `github` / `linear` / `slack` / … + * - `operation` — the client method that failed (`comment`, + * `upsertIssue.create`, …) + * - `retryable` — `true` for transient failures (the runtime resends + * the originating event); `false` for permanent shape/validation + * failures that won't change on retry + * - `cause` — the original thrown value, preserved for logs + */ +export interface WorkforceIntegrationErrorOptions { + provider: string; + operation: string; + cause?: unknown; + retryable?: boolean; +} + +export class WorkforceIntegrationError extends Error { + readonly provider: string; + readonly operation: string; + override readonly cause?: unknown; + readonly retryable: boolean; + + constructor(options: WorkforceIntegrationErrorOptions) { + super(`${options.provider}.${options.operation} failed${ + options.cause instanceof Error ? `: ${options.cause.message}` : '' + }`); + this.name = 'WorkforceIntegrationError'; + this.provider = options.provider; + this.operation = options.operation; + if (options.cause !== undefined) this.cause = options.cause; + this.retryable = options.retryable ?? false; + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b4473cb0..154c425f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -27,22 +27,34 @@ export type { WorkforceProviderEvent } from './types.js'; -// Integration clients — concrete today: github. Others are typed `unknown` -// in `WorkforceCtx` until they ship; importing them from here keeps -// handler-side imports stable when typed clients land. +// Integration clients — Relayfile-VFS-backed. All five Tier-1 providers +// ship typed clients on `WorkforceCtx`. Construct them with +// `IntegrationClientOptions` (mount root + writeback timing) — the +// runtime wires this up automatically when a persona declares the +// matching integration. export { createGithubClient, - WorkforceIntegrationError, - isRetryableStatus, + createLinearClient, + createNotionClient, + createJiraClient, + createSlackClient, type GithubClient, - type GithubClientOptions, - type GithubIssueRef, - type GithubIssueTarget, - type GithubPr, - type GithubRepoCoords, - type GithubReview, - type GithubReviewComment, - type GithubUpsertResult + type LinearClient, + type NotionClient, + type JiraClient, + type SlackClient, + type IntegrationClientOptions, + type WritebackReceipt, + type WritebackResult, + WorkforceIntegrationError, + draftFile, + encodeSegment, + listDirectoryEntries, + listJsonFiles, + readJsonFile, + readTextFile, + resolveMountRoot, + writeJsonFile } from './clients/index.js'; // Re-export persona-kit types personas commonly reference at the handler diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index b4c7cd8e..22580d52 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -3,6 +3,10 @@ import type { PersonaMemoryScope } from '@agentworkforce/persona-kit'; import type { GithubClient } from './clients/github.js'; +import type { LinearClient } from './clients/linear.js'; +import type { SlackClient } from './clients/slack.js'; +import type { NotionClient } from './clients/notion.js'; +import type { JiraClient } from './clients/jira.js'; /** * Source of an event delivered to a persona's `onEvent` handler. The @@ -153,19 +157,17 @@ export interface LlmContext { } /** - * Per-integration clients attached to the ctx. `github` is concrete today; - * `linear`/`slack`/`notion`/`jira` are typed as `unknown` until they ship - * — handlers narrow them with a runtime check (`if (ctx.linear)`) and - * cast against the future client interface. Adding a typed client is a - * one-file change here, no breaking change for personas already on the - * runtime. + * Per-integration clients attached to the ctx. All Tier-1 providers + * (github, linear, slack, notion, jira) ship typed VFS-backed clients; + * a persona only sees the fields its `integrations` block declared, so + * cron-only handlers get an undefined field across the board. */ export interface IntegrationClients { github?: GithubClient; - linear?: unknown; - slack?: unknown; - notion?: unknown; - jira?: unknown; + linear?: LinearClient; + slack?: SlackClient; + notion?: NotionClient; + jira?: JiraClient; } /** From 250b5c9960d080be60ff78c6293141697490d550 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 02:27:16 +0200 Subject: [PATCH 2/4] (rebase PR #92 onto post-Track-D main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track E1: Track E1 — rebase #92 (feat/integrations-vfs) See workforce/docs/plans/deploy-v1-schema-cascade-spec.md --- packages/runtime/src/clients/github.test.ts | 71 ++++++++++++++++++++- packages/runtime/src/clients/github.ts | 19 ++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/clients/github.test.ts b/packages/runtime/src/clients/github.test.ts index 54eb6c4a..7115a95d 100644 --- a/packages/runtime/src/clients/github.test.ts +++ b/packages/runtime/src/clients/github.test.ts @@ -59,7 +59,12 @@ test('github.upsertIssue updates an existing flat issue match', async () => { await mkdir(issueDir, { recursive: true }); await writeFile( path.join(issueDir, '7.json'), - JSON.stringify({ number: 7, title: 'Weekly digest — 2026-W20', html_url: 'https://github.com/o/r/issues/7' }) + JSON.stringify({ + number: 7, + state: 'open', + title: 'Weekly digest — 2026-W20', + html_url: 'https://github.com/o/r/issues/7' + }) ); const client = createGithubClient({ relayfileMountRoot: root }); @@ -80,6 +85,41 @@ test('github.upsertIssue updates an existing flat issue match', async () => { } }); +test('github.upsertIssue ignores a closed issue title match', async () => { + const root = await tempMount(); + try { + const issueDir = path.join(root, 'github/repos/o/r/issues'); + await mkdir(issueDir, { recursive: true }); + await writeFile( + path.join(issueDir, '7.json'), + JSON.stringify({ + number: 7, + state: 'closed', + title: 'Weekly digest — 2026-W20', + html_url: 'https://github.com/o/r/issues/7' + }) + ); + + const client = createGithubClient({ relayfileMountRoot: root }); + const result = await client.upsertIssue({ + owner: 'o', + repo: 'r', + title: 'Weekly digest — 2026-W20', + body: 'fresh open issue', + matchTitle: 'Weekly digest — 2026-W20' + }); + assert.equal(result.created, true); + + const files = await readdir(issueDir); + const drafts = files.filter((name) => name.endsWith('.json') && name !== '7.json'); + assert.equal(drafts.length, 1); + const closed = JSON.parse(await readFile(path.join(issueDir, '7.json'), 'utf8')); + assert.equal(closed.body, undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('github.upsertIssue creates a draft when no open match exists', async () => { const root = await tempMount(); try { @@ -130,6 +170,35 @@ test('github.getPr reads meta + diff from canonical paths', async () => { } }); +test('github.getPr reads a flat canonical pull request file', async () => { + const root = await tempMount(); + try { + const pullDir = path.join(root, 'github/repos/o/r/pulls'); + await mkdir(pullDir, { recursive: true }); + await writeFile( + path.join(pullDir, '42.json'), + JSON.stringify({ + title: 'Add deploy v1', + body: 'ships it', + head: { ref: 'feature' }, + base: { ref: 'main' }, + user: { login: 'kgnt' }, + diff: 'diff --git a/x b/x\n' + }) + ); + + const client = createGithubClient({ relayfileMountRoot: root }); + const pr = await client.getPr({ owner: 'o', repo: 'r', number: 42 }); + assert.equal(pr.title, 'Add deploy v1'); + assert.equal(pr.head, 'feature'); + assert.equal(pr.base, 'main'); + assert.equal(pr.author, 'kgnt'); + assert.match(pr.diff, /^diff --git/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('github.postReview writes a review draft under pulls//reviews/', async () => { const root = await tempMount(); try { diff --git a/packages/runtime/src/clients/github.ts b/packages/runtime/src/clients/github.ts index fe959803..7bf2d08d 100644 --- a/packages/runtime/src/clients/github.ts +++ b/packages/runtime/src/clients/github.ts @@ -58,6 +58,7 @@ interface GithubIssueFile { html_url?: string; url?: string; title?: string; + state?: string; } interface GithubPullRequestFile { @@ -81,7 +82,7 @@ async function findNumberSegment( repo: string, number: number ): Promise { - // Relayfile may emit canonical paths under either `.json` or + // Relayfile may emit canonical paths under either `/` or // `__/` directories depending on the adapter version. Probe // both shapes; fall back to the raw number so reads still surface a // useful WorkforceIntegrationError if neither exists. @@ -151,7 +152,9 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient Boolean(result?.value.title) ); const issues = [...flatIssues, ...nestedIssues]; - const existing = issues.find((issue) => issue.value.title === args.matchTitle && issue.value.number); + const existing = issues.find( + (issue) => issue.value.state === 'open' && issue.value.title === args.matchTitle && issue.value.number + ); if (existing?.value.number) { await writeJsonFile( opts, @@ -172,7 +175,8 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient async getPr(target) { const pullSegment = await findNumberSegment(opts, 'pulls', target.owner, target.repo, target.number); - const pullRoot = `${repoRoot(target.owner, target.repo)}/pulls/${encodeSegment(pullSegment)}`; + const pullsRoot = `${repoRoot(target.owner, target.repo)}/pulls`; + const pullRoot = `${pullsRoot}/${encodeSegment(pullSegment)}`; const pr = await readJsonFile( opts, 'github', @@ -183,7 +187,14 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient opts, 'github', 'getPr', - `${repoRoot(target.owner, target.repo)}/pulls/${encodeSegment(target.number)}/metadata.json` + `${pullRoot}/metadata.json` + ).catch(() => + readJsonFile( + opts, + 'github', + 'getPr', + `${pullsRoot}/${encodeSegment(target.number)}.json` + ) ) ); const diff = await readTextFile(opts, 'github', 'getPr.diff', `${pullRoot}/diff.patch`).catch(() => ''); From 8391fd07d68976cc603170805b3bf17cf851576e Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 08:31:17 +0200 Subject: [PATCH 3/4] fix(review): address CodeRabbit comments on PR #92 - github.upsertIssue update: preserve number/state/html_url/url so the next call still finds the canonical issue file by number. - github.getPr: use the discovered pull directory segment verbatim instead of re-encoding it (avoids double-escaping slug paths like `123__fix%2Fci`). - request.waitForReceipt: short-circuit in fire-and-forget mode (timeoutMs <= 0) before reading the just-written draft, so a draft payload carrying top-level id/path/created is never reinterpreted as a writeback receipt. - jira.transition: validate that the transition id is non-empty after trimming, throwing a non-retryable WorkforceIntegrationError. - notion.createPage: throw WorkforceIntegrationError instead of a generic Error when parent.database_id is missing, matching the rest of the integration error contract. - weekly-digest README: move RELAYFILE_MOUNT_ROOT in front of `node` (was scoped only to `echo`) and add a prerequisite note that real GitHub writes require the Relayfile writeback worker to be running. --- examples/weekly-digest/README.md | 10 +++++++--- packages/runtime/src/clients/github.ts | 15 +++++++++++++-- packages/runtime/src/clients/jira.ts | 9 +++++++++ packages/runtime/src/clients/notion.ts | 8 +++++++- packages/runtime/src/clients/request.ts | 7 ++++++- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/examples/weekly-digest/README.md b/examples/weekly-digest/README.md index 5ab381f6..62395bf7 100644 --- a/examples/weekly-digest/README.md +++ b/examples/weekly-digest/README.md @@ -54,12 +54,16 @@ workforce deploy ./examples/weekly-digest/persona.json --mode dev The runner reads NDJSON envelopes from stdin. To trigger the handler from the command line against a Relayfile mount you've already set up, drive -the bundle directly: +the bundle directly. The env assignment goes in front of `node` so the +runner — not the `echo` upstream of the pipe — sees `RELAYFILE_MOUNT_ROOT`. + +> **Prerequisite:** manual firing only produces real GitHub writes when +> the Relayfile writeback worker is active for that mount. Without it, +> drafts land on disk under the mount but no GitHub call is ever made. ```sh -RELAYFILE_MOUNT_ROOT=/path/to/mount \ echo '{"id":"manual-1","workspace":"ws_demo","type":"cron.tick","occurredAt":"2026-05-12T09:00:00Z","name":"weekly","cron":"0 9 * * 6"}' \ - | node /tmp/wf-weekly-digest/runner.mjs + | RELAYFILE_MOUNT_ROOT=/path/to/mount node /tmp/wf-weekly-digest/runner.mjs ``` The handler will: diff --git a/packages/runtime/src/clients/github.ts b/packages/runtime/src/clients/github.ts index 7bf2d08d..c87f9ad8 100644 --- a/packages/runtime/src/clients/github.ts +++ b/packages/runtime/src/clients/github.ts @@ -161,7 +161,15 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient 'github', 'upsertIssue.update', `${issueDir}/${encodeSegment(existing.value.number)}.json`, - { title: args.title, body: args.body, labels: args.labels } + { + number: existing.value.number, + state: existing.value.state, + html_url: existing.value.html_url, + url: existing.value.url, + title: args.title, + body: args.body, + labels: args.labels + } ); return { number: existing.value.number, @@ -176,7 +184,10 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient async getPr(target) { const pullSegment = await findNumberSegment(opts, 'pulls', target.owner, target.repo, target.number); const pullsRoot = `${repoRoot(target.owner, target.repo)}/pulls`; - const pullRoot = `${pullsRoot}/${encodeSegment(pullSegment)}`; + // `findNumberSegment` returns the actual on-disk entry name verbatim + // (e.g. `123__fix%2Fci`), so do NOT re-encode it — that would + // double-escape any percent sequences and miss the directory. + const pullRoot = `${pullsRoot}/${pullSegment}`; const pr = await readJsonFile( opts, 'github', diff --git a/packages/runtime/src/clients/jira.ts b/packages/runtime/src/clients/jira.ts index 4f8ee20c..e865eb92 100644 --- a/packages/runtime/src/clients/jira.ts +++ b/packages/runtime/src/clients/jira.ts @@ -1,3 +1,4 @@ +import { WorkforceIntegrationError } from '../errors.js'; import { draftFile, encodeSegment, @@ -53,6 +54,14 @@ export function createJiraClient(opts: IntegrationClientOptions): JiraClient { async transition(target, transition) { const id = typeof transition === 'string' ? transition.trim() : transition.id.trim(); + if (id.length === 0) { + throw new WorkforceIntegrationError({ + provider: 'jira', + operation: 'transition', + cause: new Error('Jira transition id must be a non-empty string'), + retryable: false + }); + } await writeJsonFile( opts, 'jira', diff --git a/packages/runtime/src/clients/notion.ts b/packages/runtime/src/clients/notion.ts index 1e1ed03d..74616964 100644 --- a/packages/runtime/src/clients/notion.ts +++ b/packages/runtime/src/clients/notion.ts @@ -1,3 +1,4 @@ +import { WorkforceIntegrationError } from '../errors.js'; import { draftFile, encodeSegment, @@ -29,7 +30,12 @@ export interface NotionClient { function readDatabaseId(parent: Record): string { const databaseId = parent.database_id ?? parent.databaseId; if (typeof databaseId !== 'string' || databaseId.trim().length === 0) { - throw new Error('Notion createPage file writeback requires parent.database_id'); + throw new WorkforceIntegrationError({ + provider: 'notion', + operation: 'createPage', + cause: new Error('Notion createPage requires parent.database_id'), + retryable: false + }); } return databaseId.trim(); } diff --git a/packages/runtime/src/clients/request.ts b/packages/runtime/src/clients/request.ts index c854ed14..7d167943 100644 --- a/packages/runtime/src/clients/request.ts +++ b/packages/runtime/src/clients/request.ts @@ -223,6 +223,12 @@ async function waitForReceipt( client: IntegrationClientOptions ): Promise { const timeoutMs = client.writebackTimeoutMs ?? 0; + // Fire-and-forget: never reinterpret the just-written draft as a + // receipt. The draft payload may legitimately carry top-level `id` / + // `path` / `created` fields (e.g. an upsert update writing back the + // canonical issue), and treating that as a receipt would surface a + // bogus identifier to callers. + if (timeoutMs <= 0) return undefined; const deadline = Date.now() + timeoutMs; do { const parsed = await readCurrentJson(absolutePath); @@ -234,7 +240,6 @@ async function waitForReceipt( ) { return parsed as WritebackReceipt; } - if (timeoutMs <= 0) return undefined; await new Promise((resolve) => setTimeout(resolve, client.writebackPollMs ?? 250)); } while (Date.now() < deadline); return undefined; From f2853965ece45f5e64ab1ca534da3bca30272c3b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 13 May 2026 10:16:36 +0200 Subject: [PATCH 4/4] feat(examples): add review-agent + linear-shipper (Relayfile-VFS clients) (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(examples): add review-agent + linear-shipper examples (VFS clients) Ports the two example agents from the closed codex/deploy-v1-pr branch to the Relayfile-VFS integration-client style introduced in #92. review-agent - GitHub PR opened: pulls the diff via ctx.github.getPr, runs the persona's harness on the diff body, posts a review via ctx.github.postReview. - @mention in an issue/review comment: harness with the comment thread as context, posts the reply via ctx.github.comment. - check_run.completed (failure): harness with the failed CI logs as context, proposes a fix in a comment. - Slack app_mention: conversational reply via ctx.slack. linear-shipper - Linear issue created: clones the target repo into the sandbox, runs ctx.harness.run on the issue body, opens a draft PR via ctx.github, comments back on the Linear issue with the PR link. - Headless (no traits in the persona); demonstrates the paraglide "Linear issue → ship" pattern. Both examples adapt to the WorkforceProviderEvent shape — they read the raw provider payload from event.payload rather than treating the event as the payload itself. Tests: typecheck clean across the workspace and against examples/tsconfig.json (which path-maps @agentworkforce/runtime to the workspace source). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(examples/linear-shipper): read event.payload, not event itself Same shape mismatch I fixed in review-agent: the agent was reading event.issue as if event were the raw Linear webhook body, but WorkforceProviderEvent.payload is where the provider payload lives. Without this fix, every linear.issue.created delivery to the shipper failed at the "Linear event is missing an issue id" guard because issueRef was always undefined. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(examples/linear-shipper): use a valid PERSONA_INTENT persona-kit's parser rejects unknown intents. "implementation" is in PERSONA_TAGS, not PERSONA_INTENTS, so the persona failed at parsePersonaSpec(...) with `persona[implementation].intent is invalid` before deploy could do anything. Swap to `implement-frontend` — the closest valid intent. Not a perfect domain match (the shipper isn't frontend-specific) but accurate enough to demonstrate the pattern; users will customize per their own routing taxonomy. Verified end-to-end: `workforce deploy ./examples/linear-shipper/persona.json --dry-run` now exits 0 with "persona linear-shipper: 2 integration(s)". Co-Authored-By: Claude Opus 4.7 (1M context) * fix(persona-kit): reject removed deploy v1 persona keys * (rebase PR #93 — strip traits/sandbox from examples) Track E2: Track E2 — rebase #93 (feat/integrations-vfs-examples) See workforce/docs/plans/deploy-v1-schema-cascade-spec.md * fix(examples/linear-shipper): honor env-var overrides in inputDefault inputDefault previously only returned the static persona JSON default, silently ignoring REPO_URL / GITHUB_OWNER / GITHUB_REPO env vars that the README instructs users to set. Mirror the precedence in resolvePersonaInputs (packages/persona-kit/src/inputs.ts): env var (spec.env ?? name) wins over spec.default. Addresses devin-ai-integration review comment on PR #93. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Ricky Schema Cascade --- docs/plans/deploy-v1.md | 64 +++--------- examples/linear-shipper/README.md | 17 ++++ examples/linear-shipper/agent.ts | 69 +++++++++++++ examples/linear-shipper/persona.json | 41 ++++++++ examples/review-agent/README.md | 23 +++++ examples/review-agent/agent.ts | 110 ++++++++++++++++++++ examples/review-agent/persona.json | 35 +++++++ examples/weekly-digest/persona.json | 1 - packages/deploy/src/modes/sandbox.ts | 13 +-- packages/persona-kit/src/index.ts | 5 - packages/persona-kit/src/parse.test.ts | 136 +++++++++++-------------- packages/persona-kit/src/parse.ts | 111 +++----------------- packages/persona-kit/src/types.ts | 77 ++++---------- packages/runtime/src/index.ts | 3 +- packages/runtime/src/types.ts | 2 +- 15 files changed, 400 insertions(+), 307 deletions(-) create mode 100644 examples/linear-shipper/README.md create mode 100644 examples/linear-shipper/agent.ts create mode 100644 examples/linear-shipper/persona.json create mode 100644 examples/review-agent/README.md create mode 100644 examples/review-agent/agent.ts create mode 100644 examples/review-agent/persona.json diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index a11ab435..fe82a490 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -38,7 +38,7 @@ One file. One command. One contract. ### In -- Persona JSON schema extension: `cloud`, `useSubscription`, `integrations`, `schedules`, `sandbox`, `memory`, `traits`, `onEvent`. +- Persona JSON schema extension: `cloud`, `useSubscription`, `integrations`, `schedules`, `memory`, `onEvent`. - New package `@agentworkforce/runtime` — thin facade exposing `handler(...)` that wraps `agent({...})` from `@agent-relay/agent` (cloud proactive-runtime M1 SDK). - New package `@agentworkforce/deploy` — the deploy CLI logic; the existing `cli.ts` gets a `deploy` case that dispatches to it. - Daytona sandbox launcher used in the `--sandbox` run mode. @@ -74,11 +74,11 @@ All new fields are optional. A persona that does not set any of them continues t | `useSubscription` | `boolean` | optional | When `true`, inference uses the user's connected LLM subscription via `@agent-relay/cloud`'s provider link (no workforce-billed tokens). Triggers a `connectProvider` step at deploy time. | | `integrations` | `Record` | when persona has event triggers | Declares which Relayfile providers this agent needs and what events fire its handler. See §3.2. | | `schedules` | `Schedule[]` | when persona runs on cron | One or more cron triggers, registered with the runtime's `ctx.schedule.every(...)`. Each schedule has a `name` echoed back to the handler. See §3.3. | -| `sandbox` | `boolean \| SandboxConfig` | optional | `true` (default) means agent runs inside a Daytona sandbox. `false` means the runner process owns its own filesystem. Object form lets you tune env / timeout. See §3.4. | -| `memory` | `boolean \| MemoryConfig` | optional | Enables the agent-assistant memory subsystem. Scopes and TTL configurable. See §3.5. | -| `traits` | `Traits` | optional, **only meaningful for interactive agents** | Mirrors `@agent-assistant/traits`: voice, formality, proactivity, etc. Applied when the agent posts to a chat surface (Slack, Relaycast). Headless agents (paraglide-style "Linear issue → ship") may omit this. See §3.6. | +| `memory` | `boolean \| MemoryConfig` | optional | Enables the agent-assistant memory subsystem. Scopes and TTL configurable. See §3.4. | | `onEvent` | `string` | when `cloud: true` and any trigger declared | Path to a TS file (relative to the persona JSON) whose default export is the event handler. Sub-file references like `./agent.ts` and `./handlers/index.ts` are supported. See §4. | +`traits` and `sandbox` were removed from the persona spec in v1. Personality belongs in the persona's prompt/sidecar and the persona-personality-builder flow. Sandbox behavior is deploy-time runtime configuration: sandbox mode is on by default for deploys, with opt-out handled by deploy flags or runtime config rather than persona JSON. + ### 3.2 `integrations` shape ```jsonc @@ -119,26 +119,13 @@ The act of stacking integrations is just declaring multiple keys. The act of lin - `cron` is a standard 5-field expression. `tz` defaults to `UTC`. - Multiple schedules are allowed. The runtime registers each with `ctx.schedule.every(cron, { tz, payload: { name } })`. -### 3.4 `sandbox` shape - -```jsonc -"sandbox": true // default -"sandbox": { "enabled": true, "timeoutSeconds": 1800, "env": { "FOO": "bar" } } -"sandbox": false // run in the runner process's fs -``` - -- Image is **not** user-configurable in v1. Workforce picks a standard image (`node-22` baseline) for the default Daytona sandbox. We can add `image` later if a real demand surfaces; eliminating the field keeps the v1 contract small. -- `timeoutSeconds` caps a single handler invocation. Default 1800s. -- `env` adds env vars on top of the auto-injected secrets (Relayfile connection tokens, harness inference creds, etc.). -- When `sandbox: false`, the agent's `ctx.sandbox` still exists but points at the runner's own process — useful for `--dev` iteration, **not** what we recommend for production. - -### 3.5 `memory` shape +### 3.4 `memory` shape ```jsonc "memory": true // sensible defaults "memory": { "enabled": true, - "scopes": ["session", "user", "workspace"], + "scopes": ["workspace", "user", "global"], "ttlDays": 30, "autoPromote": true, "dedupMs": 300000 @@ -146,29 +133,11 @@ The act of stacking integrations is just declaring multiple keys. The act of lin ``` - Implementation: the runtime wires `@agent-assistant/memory` with the supermemory adapter (matching sage today). API key is pulled from workforce-managed env, not declared in the persona. -- `scopes` is the only field with real semantic weight: session-only memory is wiped per handler; user-scope persists across the user's invocations of this agent; workspace persists across all users. +- `scopes` is the only field with real semantic weight: workspace memory persists across users in a workspace, user memory follows an individual user's invocations, and global memory is shared across the deployed agent. - `autoPromote` flips on the sage turn-recorder pattern — agent decides if session content is worth promoting. -- **No `memoryMd` file.** Memory is config, not prose. Personality goes in `traits` and `description`. - -### 3.6 `traits` shape - -Direct mapping to `@agent-assistant/traits`: - -```jsonc -"traits": { - "voice": "professional-warm", - "formality": "low", - "proactivity": "medium", - "riskPosture": "conservative", - "domain": "engineering", - "vocabulary": ["PR", "diff", "CI"], - "preferMarkdown": true -} -``` - -Only used when the runtime renders into a conversational surface (Slack message, Relaycast post, GitHub PR comment). Skip the field entirely for headless agents — saves the runtime a subsystem registration. +- **No `memoryMd` file.** Memory is config, not prose. Personality goes in prompt/sidecar content and the persona-personality-builder flow. -### 3.7 Trigger-name registry +### 3.5 Trigger-name registry `packages/persona-kit/src/triggers.ts` (new) ships a small registry of known trigger names per provider so the deploy CLI can lint them: @@ -217,7 +186,7 @@ interface WorkforceCtx { notion?: NotionClient; jira?: JiraClient; - // Daytona sandbox (or process fs if sandbox:false) + // Daytona sandbox or runtime-provided process fs sandbox: { cwd: string; // absolute path inside the sandbox exec(cmd: string, opts?: { cwd?: string; env?: Record }): Promise; @@ -242,7 +211,7 @@ interface WorkforceCtx { cancel(name: string): Promise; }; - // Persona metadata (id, traits, harness tier defaults, etc.) — read-only + // Persona metadata (id, harness defaults, listeners, etc.) — read-only persona: PersonaSpec; } @@ -254,7 +223,7 @@ export function handler( Implementation notes: - `handler(...)` reads the persona JSON adjacent to the entrypoint (workforce bundles them together). At cold-start it: 1. Calls `agent({ workspace, schedule, watch, inbox, onEvent: shim })` from `@agent-relay/agent`, mapping `persona.integrations` to `watch` and `persona.schedules` to `schedule`. - 2. Builds `ctx` once per agent boot: opens Daytona handle (if `sandbox: true`), wires Relayfile-derived clients, attaches memory adapter. + 2. Builds `ctx` once per agent boot: opens Daytona handle when deploy runs in sandbox mode, wires Relayfile-derived clients, attaches memory adapter. 3. The `shim` reshapes the raw envelope from `@agent-relay/agent` into the `WorkforceEvent` discriminated union and invokes the user's `fn(ctx, event)`. - The user never imports `@agent-relay/agent` directly. Workforce owns the ergonomics. If the underlying SDK churns, we absorb the diff here. - The SDK doors stay open for power users: we re-export `agent` from `@agentworkforce/runtime/raw` so anyone who wants the lower-level surface can drop down. This matters for nightcto-shaped projects that outgrow the persona contract. @@ -354,7 +323,6 @@ Direct port of the proactive-agents weekly-digest pattern. "cloud": true, "integrations": { "github": { "scope": { "repo": "AgentWorkforce/weekly-digest" } } }, "schedules": [{ "name": "weekly", "cron": "0 9 * * 6", "tz": "UTC" }], - "sandbox": true, "memory": { "enabled": true, "scopes": ["workspace"], "ttlDays": 90 }, "onEvent": "./agent.ts", "tiers": { ... standard codex/opencode tiers ... } @@ -385,9 +353,7 @@ Direct port of the proactive-agents weekly-digest pattern. }, "slack": { "triggers": [{ "on": "app_mention" }] } }, - "sandbox": true, - "memory": { "enabled": true, "scopes": ["session", "workspace"] }, - "traits": { "voice": "professional-warm", "formality": "low", "preferMarkdown": true }, + "memory": { "enabled": true, "scopes": ["user", "workspace"] }, "onEvent": "./agent.ts", "tiers": { ... } } @@ -409,7 +375,7 @@ workforce/ │ ├── cli/ # add `deploy`, `login` cases │ ├── persona-kit/ # extend PersonaSpec schema (§3) │ │ └── src/ -│ │ ├── types.ts # +CloudFields, +IntegrationConfig, +Schedule, +Sandbox, +Memory, +Traits +│ │ ├── types.ts # +CloudFields, +IntegrationConfig, +Schedule, +Memory │ │ ├── parse.ts # extend parsePersonaSpec to read new fields │ │ └── triggers.ts # NEW — known triggers registry (§3.7) │ ├── harness-kit/ # no changes for v1 @@ -494,7 +460,7 @@ If a track slips, §10's fallback applies: ship `--dev` end-to-end with `weekly- Tasks that are mechanical, well-specified, and don't gate on my decisions — perfect for a codex agent spawned via `workforce agent code-implementer` or a similar persona: 1. **Trigger registry expansion** — fill out `packages/persona-kit/src/triggers.ts` with the full set of known trigger names per Tier-1 provider (Linear, GitHub, Slack, Notion, Jira) by reading the Relayfile provider docs in `/Users/khaliqgant/Projects/AgentWorkforce/relayfile/docs/`. -2. **Test fixtures** — generate sample `persona.json` files exercising every optional combination (with/without traits, sandbox false, multi-schedule, etc.) into `packages/persona-kit/src/__fixtures__/`. +2. **Test fixtures** — generate sample `persona.json` files exercising deploy optional combinations (memory, multi-schedule, integrations, etc.) into `packages/persona-kit/src/__fixtures__/`. 3. **JSON Schema export** — emit a JSON Schema from the extended `PersonaSpec` for editor autocomplete. New script: `packages/persona-kit/scripts/emit-schema.mjs`. Wire to `pnpm run build` so it ships with the package. 4. **Example expansion** — write a third example, `examples/linear-shipper/` (the paraglide pattern: Linear issue created → drive to PR), purely against the runtime substrate I land in §9.1. 5. **README polish** — once the deploy command is real, codex agent rewrites the workforce README to lead with the deploy story. diff --git a/examples/linear-shipper/README.md b/examples/linear-shipper/README.md new file mode 100644 index 00000000..02fe5d45 --- /dev/null +++ b/examples/linear-shipper/README.md @@ -0,0 +1,17 @@ +# Linear Shipper + +This deployable persona follows the paraglide pattern: a Linear issue triggers a sandboxed implementation run, then the agent links the result back to Linear. + +## Setup + +Connect Linear and GitHub before deploying. + +```bash +workforce deploy ./examples/linear-shipper/persona.json --mode dev +``` + +Set the target repository through the persona inputs: `GITHUB_OWNER`, `GITHUB_REPO`, and `REPO_URL`. + +## Current GitHub Handoff + +The v1 client contract exposes `createIssue`, not `createPr`, so the example creates a draft handoff issue and includes a `TODO(human)` where `createPr` should be used once the runtime exposes it. diff --git a/examples/linear-shipper/agent.ts b/examples/linear-shipper/agent.ts new file mode 100644 index 00000000..a4659065 --- /dev/null +++ b/examples/linear-shipper/agent.ts @@ -0,0 +1,69 @@ +import { handler } from '@agentworkforce/runtime'; + +type LinearIssueEvent = { + issue?: { id?: string; identifier?: string; title?: string; url?: string }; +}; + +function inputDefault(ctx: Parameters[0]>[0], name: string): string { + // Mirror `resolvePersonaInputs` precedence (packages/persona-kit/src/inputs.ts): + // explicit env var (spec.env ?? input name) wins over the static JSON default. + const spec = ctx.persona.inputs?.[name]; + const envName = spec?.env ?? name; + const fromEnv = process.env[envName]; + const value = (fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined) ?? spec?.default; + if (!value) throw new Error(`${name} input is required`); + return value; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function safeRepoDirName(value: string): string { + if (!/^[A-Za-z0-9._-]+$/.test(value)) { + throw new Error('GITHUB_REPO must be a repository name, not a path or shell fragment'); + } + return value; +} + +export default handler(async (ctx, event) => { + if (event.source !== 'linear' || event.type !== 'issue.created') return; + if (!ctx.linear) throw new Error('linear-shipper requires the linear integration'); + if (!ctx.github) throw new Error('linear-shipper requires the github integration'); + + const payload = + typeof event.payload === 'object' && event.payload !== null + ? (event.payload as LinearIssueEvent) + : {}; + const issueRef = payload.issue; + const issueId = issueRef?.id ?? issueRef?.identifier; + if (!issueId) throw new Error('Linear event is missing an issue id'); + + const issue = await ctx.linear.getIssue(issueId); + const repoUrl = inputDefault(ctx, 'REPO_URL'); + const owner = inputDefault(ctx, 'GITHUB_OWNER'); + const repo = safeRepoDirName(inputDefault(ctx, 'GITHUB_REPO')); + const repoDir = `${ctx.sandbox.cwd}/${repo}`; + + await ctx.sandbox.exec(`git clone ${shellQuote(repoUrl)} ${shellQuote(repoDir)}`); + const result = await ctx.harness.run({ + prompt: `Implement this Linear issue. Create the smallest reviewable change and include verification notes.\n\nTitle: ${issue.title}\n\n${issue.description ?? ''}`, + cwd: repoDir + }); + + // TODO(human): createPr is not in the published GithubClient contract yet. + const created = await ctx.github.createIssue({ + owner, + repo, + title: `Draft PR needed: ${issue.title}`, + body: [ + `Linear issue: ${issue.url ?? issueId}`, + '', + 'The harness produced an implementation attempt, but GithubClient.createPr is not exposed yet.', + '', + result.output + ].join('\n') + }); + + await ctx.linear.comment(issueId, `Implementation attempt captured in GitHub issue: ${created.url}`); +}); diff --git a/examples/linear-shipper/persona.json b/examples/linear-shipper/persona.json new file mode 100644 index 00000000..4e5156d8 --- /dev/null +++ b/examples/linear-shipper/persona.json @@ -0,0 +1,41 @@ +{ + "id": "linear-shipper", + "intent": "implement-frontend", + "tags": ["implementation"], + "description": "Turns a new Linear issue into an implementation attempt and links the resulting GitHub work back to Linear.", + "cloud": true, + "integrations": { + "linear": { + "triggers": [{ "on": "issue.created" }] + }, + "github": { + "scope": { + "repo": "AgentWorkforce/workforce" + } + } + }, + "inputs": { + "GITHUB_OWNER": { + "description": "GitHub owner containing the target repository.", + "default": "AgentWorkforce" + }, + "GITHUB_REPO": { + "description": "Target repository name.", + "default": "workforce" + }, + "REPO_URL": { + "description": "Clone URL for the target repository.", + "default": "https://github.com/AgentWorkforce/workforce.git" + } + }, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5.4", + "systemPrompt": "Implement Linear issues with small, reviewable changes and clear handoff notes.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 1200, + "sandboxMode": "workspace-write", + "workspaceWriteNetworkAccess": true + } +} diff --git a/examples/review-agent/README.md b/examples/review-agent/README.md new file mode 100644 index 00000000..c98790d8 --- /dev/null +++ b/examples/review-agent/README.md @@ -0,0 +1,23 @@ +# Review Agent + +This deployable persona listens for GitHub pull request events and Slack mentions, delegates code reasoning to the configured harness, and posts the result back through the connected integration. + +## Setup + +Connect GitHub and Slack before deploying. Because `useSubscription` is enabled, deployment also connects the model provider derived from the persona's `model` field. + +⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). + +```bash +workforce deploy ./examples/review-agent/persona.json --mode dev +``` + +## Events + +The persona handles opened pull requests, issue comment mentions, pull request review comments, failed check runs, and Slack app mentions. + +## Run + +```bash +workforce deploy ./examples/review-agent/persona.json --mode sandbox +``` diff --git a/examples/review-agent/agent.ts b/examples/review-agent/agent.ts new file mode 100644 index 00000000..84167c9b --- /dev/null +++ b/examples/review-agent/agent.ts @@ -0,0 +1,110 @@ +import { handler } from '@agentworkforce/runtime'; + +type GithubTarget = { owner: string; repo: string; number: number }; + +function payloadOf(eventPayload: unknown): Record { + return typeof eventPayload === 'object' && eventPayload !== null + ? (eventPayload as Record) + : {}; +} + +function githubTarget(event: Record): GithubTarget { + const repository = event.repository as { + owner?: string | { login?: string }; + name?: string; + full_name?: string; + } | undefined; + const pullRequest = event.pull_request as { number?: number } | undefined; + const issue = event.issue as { number?: number } | undefined; + const checkRun = event.check_run as { pull_requests?: Array<{ number?: number }> } | undefined; + const owner = typeof repository?.owner === 'string' + ? repository.owner + : repository?.owner?.login ?? repository?.full_name?.split('/')[0]; + const repo = repository?.name ?? repository?.full_name?.split('/')[1]; + const checkRunPullRequest = checkRun?.pull_requests?.find((pr) => typeof pr.number === 'number'); + const number = pullRequest?.number ?? issue?.number ?? checkRunPullRequest?.number ?? Number(event.number); + if (!owner || !repo || !Number.isFinite(number)) { + throw new Error('GitHub event is missing owner, repo, or number'); + } + return { owner, repo, number }; +} + +async function reviewPullRequest(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.github) throw new Error('review-agent requires the github integration'); + const target = githubTarget(event); + const pr = await ctx.github.getPr(target); + const result = await ctx.harness.run({ + prompt: `Review this PR for correctness, risk, and missing tests.\n\nTitle: ${pr.title}\nAuthor: ${pr.author}\nBase: ${pr.base}\nHead: ${pr.head}\n\n${pr.diff}`, + cwd: ctx.sandbox.cwd + }); + await ctx.github.postReview(target, { event: 'COMMENT', body: result.output }); +} + +async function replyToGithubMention(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.github) throw new Error('review-agent requires the github integration'); + const target = githubTarget(event); + const comment = event.comment as { body?: string } | undefined; + const result = await ctx.harness.run({ + prompt: `Reply to this GitHub discussion in context. Keep it specific and actionable.\n\n${comment?.body ?? ''}`, + cwd: ctx.sandbox.cwd + }); + await ctx.github.comment(target, result.output); +} + +async function handleFailedCheck(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.github) throw new Error('review-agent requires the github integration'); + const checkRun = event.check_run as { conclusion?: string; output?: { title?: string; summary?: string } } | undefined; + if (checkRun?.conclusion !== 'failure') return; + const target = githubTarget(event); + const result = await ctx.harness.run({ + prompt: `CI failed. Inspect the failure and propose the smallest safe fix.\n\n${checkRun.output?.title ?? ''}\n\n${checkRun.output?.summary ?? ''}`, + cwd: ctx.sandbox.cwd + }); + await ctx.github.comment(target, result.output); +} + +async function replyInSlack(ctx: Parameters[0]>[0], event: Record) { + if (!ctx.slack) throw new Error('review-agent requires the slack integration'); + const text = typeof event.text === 'string' ? event.text : ''; + const channel = typeof event.channel === 'string' ? event.channel : ''; + const ts = typeof event.threadTs === 'string' + ? event.threadTs + : typeof event.thread_ts === 'string' + ? event.thread_ts + : typeof event.ts === 'string' + ? event.ts + : ''; + if (!channel || !ts) throw new Error('Slack app_mention event is missing channel or thread timestamp'); + const memories = await ctx.memory.recall(text, { limit: 5 }); + const result = await ctx.harness.run({ + prompt: `Answer this Slack mention using the remembered context when useful.\n\nContext:\n${JSON.stringify(memories)}\n\nMessage:\n${text}`, + cwd: ctx.sandbox.cwd + }); + await ctx.slack.reply({ channel, ts }, result.output); + await ctx.memory.save(`Slack mention handled: ${text.slice(0, 180)}`, { + tags: ['slack', 'review-agent'], + scope: 'workspace' + }); +} + +export default handler(async (ctx, event) => { + if (event.source === 'github') { + const payload = payloadOf(event.payload); + if (event.type === 'pull_request.opened') { + await reviewPullRequest(ctx, payload); + return; + } + if (event.type === 'issue_comment.created' || event.type === 'pull_request_review_comment.created') { + await replyToGithubMention(ctx, payload); + return; + } + if (event.type === 'check_run.completed') { + await handleFailedCheck(ctx, payload); + return; + } + } + + if (event.source === 'slack' && event.type === 'app_mention') { + await replyInSlack(ctx, payloadOf(event.payload)); + } +}); diff --git a/examples/review-agent/persona.json b/examples/review-agent/persona.json new file mode 100644 index 00000000..4c69399b --- /dev/null +++ b/examples/review-agent/persona.json @@ -0,0 +1,35 @@ +{ + "id": "review-agent", + "intent": "review", + "tags": ["review"], + "description": "Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.", + "cloud": true, + "useSubscription": true, + "integrations": { + "github": { + "triggers": [ + { "on": "pull_request.opened" }, + { "on": "issue_comment.created", "match": "@mention" }, + { "on": "pull_request_review_comment.created", "match": "@mention" }, + { "on": "check_run.completed", "where": "conclusion=failure" } + ] + }, + "slack": { + "triggers": [{ "on": "app_mention" }] + } + }, + "memory": { + "enabled": true, + "scopes": ["workspace"] + }, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5.4", + "systemPrompt": "Review pull requests for correctness, regression risk, security concerns, and missing tests. Be concise and concrete.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 1200, + "sandboxMode": "workspace-write", + "workspaceWriteNetworkAccess": true + } +} diff --git a/examples/weekly-digest/persona.json b/examples/weekly-digest/persona.json index 8f028f79..a127b252 100644 --- a/examples/weekly-digest/persona.json +++ b/examples/weekly-digest/persona.json @@ -35,7 +35,6 @@ "tz": "UTC" } ], - "sandbox": true, "memory": { "enabled": true, "scopes": [ diff --git a/packages/deploy/src/modes/sandbox.ts b/packages/deploy/src/modes/sandbox.ts index 56d7f47a..02f64576 100644 --- a/packages/deploy/src/modes/sandbox.ts +++ b/packages/deploy/src/modes/sandbox.ts @@ -59,8 +59,6 @@ export const sandboxLauncher: ModeLauncher = { throw err; } - const sandboxTimeoutSeconds = resolveTimeoutSeconds(input.persona.sandbox); - let stopping = false; const stop = async (): Promise => { if (stopping) return; @@ -77,8 +75,7 @@ export const sandboxLauncher: ModeLauncher = { const done = (async () => { try { const result = await client.exec(handle, 'node runner.mjs', { - cwd: SANDBOX_BUNDLE_DIR, - timeoutSeconds: sandboxTimeoutSeconds + cwd: SANDBOX_BUNDLE_DIR }); const output = result.output.trim(); if (output.length > 0) input.io.info(`[sandbox] ${output}`); @@ -149,14 +146,6 @@ export function resolveSandboxClient( }); } -function resolveTimeoutSeconds(sandbox: ModeLaunchInput['persona']['sandbox']): number | undefined { - if (sandbox === undefined || sandbox === true || sandbox === false) return undefined; - if (typeof sandbox.timeoutSeconds === 'number' && sandbox.timeoutSeconds > 0) { - return sandbox.timeoutSeconds; - } - return undefined; -} - // Re-exported for tests + power users wanting to compose the client manually. export { SANDBOX_BUNDLE_DIR, diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 97301547..f2ca1e30 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -32,14 +32,11 @@ export type { PersonaMemoryScope, PersonaMount, PersonaPermissions, - PersonaSandbox, - PersonaSandboxConfig, PersonaSchedule, PersonaSelection, PersonaSkill, PersonaSpec, PersonaTag, - PersonaTraits, SidecarMdMode, SkillInstall, SkillMaterializationOptions, @@ -69,13 +66,11 @@ export { parseOnEvent, parsePermissions, parsePersonaSpec, - parseSandbox, parseSchedules, parseSkills, parseStringList, parseStringMap, parseTags, - parseTraits, resolveSidecar, sidecarSelectionFields } from './parse.js'; diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index e201c443..0d9768af 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { assertInputName, assertSidecarPath, INPUT_NAME_RE, + isIntent, parseHarnessSettings, parseIntegrations, parseInputs, @@ -13,13 +15,11 @@ import { parseOnEvent, parsePermissions, parsePersonaSpec, - parseSandbox, parseSchedules, parseSkills, parseStringList, parseStringMap, - parseTags, - parseTraits + parseTags } from './parse.js'; function validSpec(over: Record = {}): Record { @@ -36,6 +36,15 @@ function validSpec(over: Record = {}): Record }; } +function parsePersonaFixture(path: string) { + const fixtureUrl = new URL(`../../../${path}`, import.meta.url); + const raw = JSON.parse(readFileSync(fixtureUrl, 'utf8')) as Record; + if (!isIntent(raw.intent)) { + throw new Error(`${path} declares an invalid intent`); + } + return parsePersonaSpec(raw, raw.intent); +} + test('parsePersonaSpec accepts a minimal valid flat spec', () => { const spec = parsePersonaSpec(validSpec(), 'documentation'); assert.equal(spec.id, 'p'); @@ -66,9 +75,7 @@ test('parsePersonaSpec accepts deploy-v1 optional fields', () => { } }, schedules: [{ name: 'weekly', cron: '0 9 * * 6', tz: 'UTC' }], - sandbox: { enabled: true, timeoutSeconds: 1800, env: { NODE_ENV: 'production' } }, memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 }, - traits: { voice: 'professional-warm', preferMarkdown: true }, onEvent: './agent.ts' }), 'documentation' @@ -77,16 +84,41 @@ test('parsePersonaSpec accepts deploy-v1 optional fields', () => { assert.equal(spec.cloud, true); assert.equal(spec.integrations?.github.triggers?.[0].on, 'pull_request.opened'); assert.equal(spec.schedules?.[0].name, 'weekly'); - assert.deepEqual(spec.sandbox, { - enabled: true, - timeoutSeconds: 1800, - env: { NODE_ENV: 'production' } - }); assert.deepEqual(spec.memory, { enabled: true, scopes: ['workspace'], ttlDays: 30 }); - assert.equal(spec.traits?.preferMarkdown, true); assert.equal(spec.onEvent, './agent.ts'); }); +test('parsePersonaSpec rejects removed deploy-v1 traits and sandbox keys', () => { + assert.throws( + () => parsePersonaSpec(validSpec({ traits: { voice: 'warm' } }), 'documentation'), + { + message: + 'traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md' + } + ); + assert.throws( + () => parsePersonaSpec(validSpec({ sandbox: true }), 'documentation'), + { + message: + "sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md" + } + ); +}); + +test('parsePersonaSpec accepts the Relayfile-VFS example personas', () => { + const reviewAgent = parsePersonaFixture('examples/review-agent/persona.json'); + assert.equal(reviewAgent.id, 'review-agent'); + assert.equal(reviewAgent.intent, 'review'); + assert.equal(reviewAgent.integrations?.github.triggers?.length, 4); + assert.deepEqual(reviewAgent.memory, { enabled: true, scopes: ['workspace'] }); + + const linearShipper = parsePersonaFixture('examples/linear-shipper/persona.json'); + assert.equal(linearShipper.id, 'linear-shipper'); + assert.equal(linearShipper.intent, 'implement-frontend'); + assert.equal(linearShipper.integrations?.linear.triggers?.[0].on, 'issue.created'); + assert.equal(linearShipper.inputs?.GITHUB_OWNER.default, 'AgentWorkforce'); +}); + test('parsePersonaSpec throws when intent does not match the expected intent', () => { assert.throws( () => parsePersonaSpec(validSpec({ intent: 'review' }), 'documentation'), @@ -347,45 +379,24 @@ test('parsePersonaSpec rejects a non-object spec', () => { // --- deploy-v1 schema additions ---------------------------------------------- -test('parseSandbox accepts boolean shorthand and round-trips both forms', () => { - assert.equal(parseSandbox(true, 'sandbox'), true); - assert.equal(parseSandbox(false, 'sandbox'), false); - assert.equal(parseSandbox(undefined, 'sandbox'), undefined); - const obj = parseSandbox( - { enabled: true, timeoutSeconds: 600, env: { FOO: 'bar' } }, - 'sandbox' - ); - assert.deepEqual(obj, { enabled: true, timeoutSeconds: 600, env: { FOO: 'bar' } }); -}); - -test('parseSandbox rejects malformed objects with field-pointed errors', () => { - assert.throws(() => parseSandbox('on', 'sandbox'), /sandbox must be a boolean or an object/); - assert.throws( - () => parseSandbox({ enabled: 'yes' }, 'sandbox'), - /sandbox\.enabled must be a boolean/ - ); - assert.throws( - () => parseSandbox({ timeoutSeconds: -1 }, 'sandbox'), - /sandbox\.timeoutSeconds must be a positive number/ - ); - assert.throws( - () => parseSandbox({ timeoutSeconds: Number.POSITIVE_INFINITY }, 'sandbox'), - /sandbox\.timeoutSeconds must be a positive number/ - ); -}); - test('parseMemory accepts boolean + object forms and validates scopes', () => { assert.equal(parseMemory(true, 'memory'), true); assert.equal(parseMemory(false, 'memory'), false); assert.equal(parseMemory(undefined, 'memory'), undefined); const m = parseMemory( - { enabled: true, scopes: ['user', 'user', 'workspace'], ttlDays: 7, autoPromote: true, dedupMs: 0 }, + { + enabled: true, + scopes: ['user', 'user', 'workspace', 'global'], + ttlDays: 7, + autoPromote: true, + dedupMs: 0 + }, 'memory' ); // Duplicates are deduped while preserving first-seen order. assert.deepEqual(m, { enabled: true, - scopes: ['user', 'workspace'], + scopes: ['user', 'workspace', 'global'], ttlDays: 7, autoPromote: true, dedupMs: 0 @@ -395,47 +406,17 @@ test('parseMemory accepts boolean + object forms and validates scopes', () => { test('parseMemory rejects unknown scopes and non-positive ttl', () => { assert.throws( () => parseMemory({ scopes: ['planet'] }, 'memory'), - /memory\.scopes\[0\] must be one of: session, user, workspace, org, object/ + /memory\.scopes\[0\] must be one of: workspace, user, global/ + ); + assert.throws( + () => parseMemory({ scopes: ['session'] }, 'memory'), + /memory\.scopes\[0\] must be one of: workspace, user, global/ ); assert.throws(() => parseMemory({ scopes: [] }, 'memory'), /scopes must be a non-empty array/); assert.throws(() => parseMemory({ ttlDays: 0 }, 'memory'), /ttlDays must be a positive number/); assert.throws(() => parseMemory({ dedupMs: -1 }, 'memory'), /dedupMs must be a non-negative number/); }); -test('parseTraits keeps only supplied fields and validates enums', () => { - assert.equal(parseTraits(undefined, 'traits'), undefined); - assert.equal(parseTraits({}, 'traits'), undefined); // empty object collapses to undefined - const t = parseTraits( - { - voice: 'concise', - formality: 'low', - proactivity: 'high', - riskPosture: 'balanced', - domain: 'engineering', - vocabulary: ['PR', 'diff'], - preferMarkdown: true - }, - 'traits' - ); - assert.deepEqual(t, { - voice: 'concise', - formality: 'low', - proactivity: 'high', - riskPosture: 'balanced', - domain: 'engineering', - vocabulary: ['PR', 'diff'], - preferMarkdown: true - }); - assert.throws( - () => parseTraits({ formality: 'extreme' }, 'traits'), - /traits\.formality must be one of: low, medium, high/ - ); - assert.throws( - () => parseTraits({ riskPosture: 'wild' }, 'traits'), - /traits\.riskPosture must be one of: conservative, balanced, aggressive/ - ); -}); - test('parseSchedules validates cron, requires unique names, preserves tz when set', () => { const s = parseSchedules( [ @@ -568,11 +549,10 @@ test('parsePersonaSpec rejects non-boolean cloud / useSubscription', () => { ); }); -test('parsePersonaSpec keeps boolean shorthand sandbox / memory through round-trip', () => { +test('parsePersonaSpec keeps boolean shorthand memory through round-trip', () => { const spec = parsePersonaSpec( - validSpec({ cloud: true, sandbox: true, memory: false }), + validSpec({ cloud: true, memory: false }), 'documentation' ); - assert.equal(spec.sandbox, true); assert.equal(spec.memory, false); }); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index d5c12094..0a308c9d 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -23,14 +23,11 @@ import type { PersonaMemoryScope, PersonaMount, PersonaPermissions, - PersonaSandbox, - PersonaSandboxConfig, PersonaSchedule, PersonaSelection, PersonaSkill, PersonaSpec, PersonaTag, - PersonaTraits, SidecarMdMode } from './types.js'; @@ -388,16 +385,11 @@ export function parseMcpServers( } const MEMORY_SCOPE_VALUES: readonly PersonaMemoryScope[] = [ - 'session', - 'user', 'workspace', - 'org', - 'object' + 'user', + 'global' ]; -const TRAIT_LEVEL_VALUES = ['low', 'medium', 'high'] as const; -const TRAIT_RISK_VALUES = ['conservative', 'balanced', 'aggressive'] as const; - const ONEVENT_EXT_RE = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/i; // Standard 5-field cron: minute hour day-of-month month day-of-week. Each @@ -572,39 +564,6 @@ export function parseSchedules( return out; } -export function parseSandbox(value: unknown, context: string): PersonaSandbox | undefined { - if (value === undefined) return undefined; - if (typeof value === 'boolean') return value; - if (!isObject(value)) { - throw new Error(`${context} must be a boolean or an object if provided`); - } - const { enabled, timeoutSeconds, env } = value; - const out: PersonaSandboxConfig = {}; - if (enabled !== undefined) { - if (typeof enabled !== 'boolean') { - throw new Error(`${context}.enabled must be a boolean if provided`); - } - out.enabled = enabled; - } - if (timeoutSeconds !== undefined) { - if ( - typeof timeoutSeconds !== 'number' || - !Number.isFinite(timeoutSeconds) || - timeoutSeconds <= 0 - ) { - throw new Error(`${context}.timeoutSeconds must be a positive number if provided`); - } - out.timeoutSeconds = timeoutSeconds; - } - if (env !== undefined) { - const parsedEnv = parseStringMap(env, `${context}.env`); - if (parsedEnv && Object.keys(parsedEnv).length > 0) { - out.env = parsedEnv; - } - } - return out; -} - export function parseMemory(value: unknown, context: string): PersonaMemory | undefined { if (value === undefined) return undefined; if (typeof value === 'boolean') return value; @@ -658,56 +617,6 @@ export function parseMemory(value: unknown, context: string): PersonaMemory | un return out; } -export function parseTraits(value: unknown, context: string): PersonaTraits | undefined { - if (value === undefined) return undefined; - if (!isObject(value)) { - throw new Error(`${context} must be an object if provided`); - } - const { voice, formality, proactivity, riskPosture, domain, vocabulary, preferMarkdown } = value; - const out: PersonaTraits = {}; - if (voice !== undefined) { - if (typeof voice !== 'string' || !voice.trim()) { - throw new Error(`${context}.voice must be a non-empty string if provided`); - } - out.voice = voice; - } - if (formality !== undefined) { - if (typeof formality !== 'string' || !TRAIT_LEVEL_VALUES.includes(formality as 'low')) { - throw new Error(`${context}.formality must be one of: ${TRAIT_LEVEL_VALUES.join(', ')}`); - } - out.formality = formality as PersonaTraits['formality']; - } - if (proactivity !== undefined) { - if (typeof proactivity !== 'string' || !TRAIT_LEVEL_VALUES.includes(proactivity as 'low')) { - throw new Error(`${context}.proactivity must be one of: ${TRAIT_LEVEL_VALUES.join(', ')}`); - } - out.proactivity = proactivity as PersonaTraits['proactivity']; - } - if (riskPosture !== undefined) { - if (typeof riskPosture !== 'string' || !TRAIT_RISK_VALUES.includes(riskPosture as 'balanced')) { - throw new Error(`${context}.riskPosture must be one of: ${TRAIT_RISK_VALUES.join(', ')}`); - } - out.riskPosture = riskPosture as PersonaTraits['riskPosture']; - } - if (domain !== undefined) { - if (typeof domain !== 'string' || !domain.trim()) { - throw new Error(`${context}.domain must be a non-empty string if provided`); - } - out.domain = domain; - } - if (vocabulary !== undefined) { - const parsed = parseStringList(vocabulary, `${context}.vocabulary`); - if (parsed) out.vocabulary = parsed; - } - if (preferMarkdown !== undefined) { - if (typeof preferMarkdown !== 'boolean') { - throw new Error(`${context}.preferMarkdown must be a boolean if provided`); - } - out.preferMarkdown = preferMarkdown; - } - return Object.keys(out).length > 0 ? out : undefined; -} - export function parseOnEvent(value: unknown, context: string): string | undefined { if (value === undefined) return undefined; return assertOnEventPath(value, context); @@ -717,6 +626,16 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): if (!isObject(value)) { throw new Error(`persona[${expectedIntent}] must be an object`); } + if ('traits' in value) { + throw new Error( + 'traits was removed in v1; personality is handled by the persona-personality-builder tool (out of scope for v1). See docs/plans/deploy-v1.md' + ); + } + if ('sandbox' in value) { + throw new Error( + "sandbox was removed in v1; sandbox is on by default at deploy time. Use 'workforce deploy --no-sandbox' or runtime config to opt out. See docs/plans/deploy-v1.md" + ); + } const { id, @@ -743,9 +662,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): useSubscription, integrations, schedules, - sandbox, memory, - traits, onEvent } = value; @@ -826,9 +743,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): `persona[${expectedIntent}].integrations` ); const parsedSchedules = parseSchedules(schedules, `persona[${expectedIntent}].schedules`); - const parsedSandbox = parseSandbox(sandbox, `persona[${expectedIntent}].sandbox`); const parsedMemory = parseMemory(memory, `persona[${expectedIntent}].memory`); - const parsedTraits = parseTraits(traits, `persona[${expectedIntent}].traits`); const parsedOnEvent = parseOnEvent(onEvent, `persona[${expectedIntent}].onEvent`); return { @@ -856,9 +771,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): ...(typeof useSubscription === 'boolean' ? { useSubscription } : {}), ...(parsedIntegrations ? { integrations: parsedIntegrations } : {}), ...(parsedSchedules ? { schedules: parsedSchedules } : {}), - ...(parsedSandbox !== undefined ? { sandbox: parsedSandbox } : {}), ...(parsedMemory !== undefined ? { memory: parsedMemory } : {}), - ...(parsedTraits ? { traits: parsedTraits } : {}), ...(parsedOnEvent !== undefined ? { onEvent: parsedOnEvent } : {}) }; } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index fff25a5a..b231c827 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -153,12 +153,12 @@ export interface PersonaIntegrationTrigger { } /** - * Per-provider integration configuration. The map key is the Relayfile - * provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` - * is provider-specific filter metadata (e.g. `{ repo: "org/repo" }` for - * github, `{ database: "" }` for notion). `triggers` are flat — all - * trigger events for this provider fan into the same `onEvent` handler, - * which discriminates on `event.source` + `event.type`. + * Radio listener configuration for a RelayFile provider. The map key is + * the provider slug (`github`, `linear`, `slack`, `notion`, `jira`). + * `scope` is provider-specific filter metadata (e.g. `{ repo: "org/repo" }` + * for github, `{ database: "" }` for notion). `triggers` are flat — + * all radio listener events for this provider fan into the same `onEvent` + * handler, which discriminates on `event.source` + `event.type`. */ export interface PersonaIntegrationConfig { scope?: Record; @@ -166,10 +166,10 @@ export interface PersonaIntegrationConfig { } /** - * A cron-style schedule. `name` is unique within the persona and surfaces - * to the handler as `event.name`. `cron` is a standard 5-field expression. - * `tz` defaults to `UTC` at the runtime layer (the parser keeps it - * optional so the spec stays close to what the author wrote). + * Clock listener configuration. `name` is unique within the persona and + * surfaces to the handler as `event.name`. `cron` is a standard 5-field + * expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps + * it optional so the spec stays close to what the author wrote). */ export interface PersonaSchedule { name: string; @@ -177,31 +177,8 @@ export interface PersonaSchedule { tz?: string; } -/** - * Long-form sandbox configuration. `enabled` defaults to true when the - * object form is present; supply the boolean shorthand `sandbox: false` - * to opt out entirely. `timeoutSeconds` caps a single handler invocation - * (default 1800s in the runtime). `env` is merged on top of auto-injected - * secrets at sandbox-create time. - * - * Image selection is intentionally not user-configurable in v1 — workforce - * picks a standard image. Add `image` later if a real demand surfaces. - */ -export interface PersonaSandboxConfig { - enabled?: boolean; - timeoutSeconds?: number; - env?: Record; -} - -/** - * Sandbox can be specified as `true` / `false` shorthand or as the full - * config object. The parser preserves whichever form the author wrote so - * round-trips stay lossless; consumers normalize when reading. - */ -export type PersonaSandbox = boolean | PersonaSandboxConfig; - /** Memory scope semantics, mirroring @agent-assistant/memory. */ -export type PersonaMemoryScope = 'session' | 'user' | 'workspace' | 'org' | 'object'; +export type PersonaMemoryScope = 'workspace' | 'user' | 'global'; /** * Long-form memory configuration. Defaults are applied by the runtime, @@ -219,21 +196,12 @@ export interface PersonaMemoryConfig { export type PersonaMemory = boolean | PersonaMemoryConfig; /** - * Conversational traits, applied only when the agent posts to a chat - * surface (Slack, Relaycast, GitHub PR comment). Headless agents — the - * paraglide "Linear issue → PR" pattern — should omit this field. Mirrors - * the trait shape in `@agent-assistant/traits`. + * A persona listens for events. Three listener kinds: clock (cron schedules + * through `schedules[]`), radio (RelayFile integration events through + * `integrations..triggers[]`), and inbox (RelayCast targeted + * messages, not yet modeled in v1). The current shape predates the + * listeners framing; semantics are equivalent. */ -export interface PersonaTraits { - voice?: string; - formality?: 'low' | 'medium' | 'high'; - proactivity?: 'low' | 'medium' | 'high'; - riskPosture?: 'conservative' | 'balanced' | 'aggressive'; - domain?: string; - vocabulary?: string[]; - preferMarkdown?: boolean; -} - export interface PersonaSpec { id: string; intent: string; @@ -333,25 +301,14 @@ export interface PersonaSpec { * for each provider not yet connected to the active workspace. */ integrations?: Record; - /** Cron-style schedules. Each `name` is unique within the persona. */ + /** Cron-style clock listeners. Each `name` is unique within the persona. */ schedules?: PersonaSchedule[]; - /** - * Sandbox preference. `true` (default for cloud personas) means the - * agent runs inside a Daytona sandbox at deploy time; `false` runs it in - * the runner process. The object form lets the author tune timeout / env. - */ - sandbox?: PersonaSandbox; /** * Memory subsystem opt-in. Wires the agent-assistant memory adapter at * runtime; the persona spec only declares intent, not implementation * details (api keys, adapter type, etc. come from workforce env). */ memory?: PersonaMemory; - /** - * Conversational traits, applied only when the agent posts to a chat - * surface. Omit for headless agents. - */ - traits?: PersonaTraits; /** * Relative POSIX path to the TypeScript (or compiled .js / .mjs) file * whose default export is the deploy-time event handler. Resolved diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 154c425f..395ef413 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -65,6 +65,5 @@ export type { PersonaIntegrationTrigger, PersonaMemoryScope, PersonaSchedule, - PersonaSpec, - PersonaTraits + PersonaSpec } from '@agentworkforce/persona-kit'; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 22580d52..113b9eab 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -177,7 +177,7 @@ export interface IntegrationClients { * integration fields undefined. */ export interface WorkforceCtx extends IntegrationClients { - /** Read-only persona metadata, useful for branching on traits. */ + /** Read-only persona metadata for handler decisions. */ readonly persona: PersonaSpec; /** Workspace the agent is deployed into. */ readonly workspaceId: string;