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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 1 addition & 80 deletions linear/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,8 @@ import {
type WorkforceProviderEvent,
} from '@agentworkforce/runtime';
import { linearClient } from '@relayfile/relay-helpers';
import { LINEAR_CREATE_PR_SCRIPT } from './create-pr.script.js';

const IMPLEMENT_WORKFLOW_NAME = 'linear-chat-lead';
const IMPLEMENT_WORKFLOW_PATH = `workflows/${IMPLEMENT_WORKFLOW_NAME}.ts`;
const CREATE_PR_SCRIPT_PATH = `/tmp/${IMPLEMENT_WORKFLOW_NAME}-create-pr.cjs`;
const CREATE_PR_ARGS_PATH = `/tmp/${IMPLEMENT_WORKFLOW_NAME}-open-pr.args.json`;
const MEMORY_TAG = 'linear-agent-session';

interface LinearIssue {
Expand Down Expand Up @@ -246,13 +242,11 @@ async function delegateImplementation(
prompt: eventContext.body,
repo,
});
await ctx.files.write(IMPLEMENT_WORKFLOW_PATH, workflowSource(workflowArgs));
const run = await ctx.workflow.run(IMPLEMENT_WORKFLOW_NAME, {
...workflowArgs,
issueId: issue.id ?? eventContext.issueId,
issueIdentifier: issue.identifier,
issueTitle: issue.title,
issueUrl: issue.url,
repo,
});
const completion = await run.completion();
return findPrUrl(String(completion.output ?? ''));
Expand All @@ -264,8 +258,6 @@ function workflowInputs(args: {
repo: string;
}): {
repo: string;
repoOwner: string;
repoName: string;
branch: string;
issueTitle: string;
issueBody: string;
Expand All @@ -289,8 +281,6 @@ function workflowInputs(args: {
].filter(Boolean).join('\n\n');
return {
repo: args.repo,
repoOwner: owner ?? 'AgentWorkforce',
repoName: name ?? 'cloud',
branch,
issueTitle: args.issue.title,
issueBody: args.issue.description ?? '',
Expand All @@ -306,75 +296,6 @@ function workflowInputs(args: {
};
}

function workflowSource(args: ReturnType<typeof workflowInputs>): string {
const createPrScriptB64 = Buffer.from(LINEAR_CREATE_PR_SCRIPT, 'utf8').toString('base64');
const createPrArgsB64 = Buffer.from(JSON.stringify(args.openPrArgs, null, 2), 'utf8').toString('base64');
return `
import { workflow } from '@agent-relay/sdk/workflows';

const REPO_DIR = './repo';
const REPO = ${JSON.stringify(args.repo)};
const BRANCH = ${JSON.stringify(args.branch)};
const ISSUE_TITLE = ${JSON.stringify(args.issueTitle)};
const ISSUE_BODY = ${JSON.stringify(args.issueBody)};
const USER_PROMPT = ${JSON.stringify(args.userPrompt)};
const CREATE_PR_SCRIPT_PATH = ${JSON.stringify(CREATE_PR_SCRIPT_PATH)};
const CREATE_PR_ARGS_PATH = ${JSON.stringify(CREATE_PR_ARGS_PATH)};
const CREATE_PR_SCRIPT_B64 = ${JSON.stringify(createPrScriptB64)};
const CREATE_PR_ARGS_B64 = ${JSON.stringify(createPrArgsB64)};

await workflow('linear-chat-lead')
.description('Implement a Linear issue delegated by the chat lead')
.pattern('dag')
.timeout(4_500_000)
.agent('impl', { cli: 'codex', preset: 'worker', role: 'Implement the Linear issue and prepare a PR.', retries: 1, maxTokens: 32000 })
.step('clone', {
type: 'deterministic',
command: [
'set -e',
'rm -rf ' + REPO_DIR,
'git clone --filter=blob:none https://github.com/' + REPO + '.git ' + REPO_DIR,
'cd ' + REPO_DIR + ' && git checkout -B ' + BRANCH,
'cd ' + REPO_DIR + ' && git config user.email linear-chat-lead@agentworkforce.local',
'cd ' + REPO_DIR + ' && git config user.name linear-chat-lead',
'cd ' + REPO_DIR + ' && git rev-parse HEAD > .linear-chat-base-sha'
].join(' && '),
captureOutput: true,
failOnError: true,
timeoutMs: 900000,
})
.step('implement', {
agent: 'impl',
dependsOn: ['clone'],
task: [
'Work in ' + REPO_DIR + ' on branch ' + BRANCH + '.',
'Linear issue: ' + ISSUE_TITLE,
'User prompt: ' + USER_PROMPT,
'Issue body:\\n' + ISSUE_BODY,
'Make the code changes needed to fully satisfy the Linear request.',
'Do not commit, push, or open a PR; the final deterministic step handles that.'
].join('\\n\\n'),
verification: { type: 'exit_code' },
timeoutMs: 1_800_000,
retries: 1,
})
.step('open-pr', {
type: 'deterministic',
dependsOn: ['implement'],
command: [
'set -e',
'printf %s ' + CREATE_PR_SCRIPT_B64 + ' | base64 -d > ' + CREATE_PR_SCRIPT_PATH,
'printf %s ' + CREATE_PR_ARGS_B64 + ' | base64 -d > ' + CREATE_PR_ARGS_PATH,
'node ' + CREATE_PR_SCRIPT_PATH + ' ' + CREATE_PR_ARGS_PATH
].join(' && '),
captureOutput: true,
failOnError: true,
timeoutMs: 300000,
})
.run();
`;
}

async function recallSessionThread(ctx: WorkforceCtx, sessionId: string): Promise<string[]> {
const items = await ctx.memory.recall(`Linear agent session ${sessionId}`, {
scope: 'workspace',
Expand Down
98 changes: 0 additions & 98 deletions linear/create-pr.script.ts

This file was deleted.

84 changes: 57 additions & 27 deletions tests/linear-agent.test.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';

import { linearClient as relayLinearClient } from '@relayfile/relay-helpers';

import linearAgent, { handleLinearEvent } from '../.test-build/linear/agent.js';
import { LINEAR_CREATE_PR_SCRIPT } from '../.test-build/linear/create-pr.script.js';

function ctx(overrides = {}) {
const logs = [];
Expand Down Expand Up @@ -183,31 +182,62 @@ test('AgentSessionEvent implement request delegates to workflow and posts PR lin

assert.equal(runtime.workflowRuns.length, 1);
assert.equal(runtime.workflowRuns[0].name, 'linear-chat-lead');
const workflowWrite = runtime.fileWrites.find((entry) => entry.path === 'workflows/linear-chat-lead.ts');
assert.ok(workflowWrite);
assert.match(workflowWrite.contents, /git clone --filter=blob:none/);
assert.match(workflowWrite.contents, /const CREATE_PR_SCRIPT_PATH = "\/tmp\/linear-chat-lead-create-pr\.cjs"/);
assert.match(workflowWrite.contents, /const CREATE_PR_ARGS_PATH = "\/tmp\/linear-chat-lead-open-pr\.args\.json"/);
assert.match(workflowWrite.contents, /printf %s ' \+ CREATE_PR_SCRIPT_B64 \+ ' \| base64 -d > ' \+ CREATE_PR_SCRIPT_PATH/);
assert.match(workflowWrite.contents, /printf %s ' \+ CREATE_PR_ARGS_B64 \+ ' \| base64 -d > ' \+ CREATE_PR_ARGS_PATH/);
assert.match(workflowWrite.contents, /node ' \+ CREATE_PR_SCRIPT_PATH \+ ' ' \+ CREATE_PR_ARGS_PATH/);
assert.doesNotMatch(workflowWrite.contents, /node -e/);
assert.doesNotMatch(workflowWrite.contents, /String\.raw/);
assert.doesNotMatch(workflowWrite.contents, /OPEN_PR_SCRIPT/);
assert.doesNotMatch(workflowWrite.contents, /shellQuote/);
assert.doesNotMatch(workflowWrite.contents, /PR_TITLE/);
assert.doesNotMatch(workflowWrite.contents, /PR_BODY/);
assert.doesNotMatch(workflowWrite.contents, /Resolve AR-70/);
assert.match(LINEAR_CREATE_PR_SCRIPT, /\/api\/v1\/github\/pull-request/);
assert.match(LINEAR_CREATE_PR_SCRIPT, /WORKFORCE_WORKSPACE_TOKEN/);
assert.doesNotMatch(LINEAR_CREATE_PR_SCRIPT, /gh pr create/);
const scriptB64 = workflowWrite.contents.match(/const CREATE_PR_SCRIPT_B64 = "([^"]+)";/)?.[1];
const argsB64 = workflowWrite.contents.match(/const CREATE_PR_ARGS_B64 = "([^"]+)";/)?.[1];
assert.ok(scriptB64);
assert.ok(argsB64);
assert.equal(Buffer.from(scriptB64, 'base64').toString('utf8'), LINEAR_CREATE_PR_SCRIPT);
const args = JSON.parse(Buffer.from(argsB64, 'base64').toString('utf8'));
assert.deepEqual(args, {
assert.deepEqual(runtime.fileWrites, []);
assert.deepEqual(runtime.workflowRuns[0].args, {
repo: 'AgentWorkforce/cloud',
branch: 'codex/linear-ar-70',
issueTitle: 'Fix the failing Linear implementer',
issueBody: 'The chat lead should answer and delegate implementation when asked.',
userPrompt: 'Please implement this.',
issueId: 'issue-1',
issueIdentifier: 'AR-70',
issueUrl: 'https://linear.app/agentrelay/issue/AR-70',
openPrArgs: {
repoDir: './repo',
owner: 'AgentWorkforce',
repo: 'cloud',
branch: 'codex/linear-ar-70',
title: 'Resolve AR-70: Fix the failing Linear implementer',
body: [
'Linear issue: https://linear.app/agentrelay/issue/AR-70',
'Prompt:\nPlease implement this.',
'Implemented by linear-chat-lead delegation.',
].join('\n\n'),
},
});

const workflowSource = await readFile('workflows/linear-chat-lead.ts', 'utf8');
assert.match(workflowSource, /process\.env\.invocationArgs/);
assert.match(workflowSource, /git clone --filter=blob:none/);
assert.match(workflowSource, /CREATE_PR_SCRIPT_SOURCE/);
assert.match(workflowSource, /create-pr-\$\{randomUUID\(\)\}\.cjs/);
assert.match(workflowSource, /randomUUID/);
assert.match(workflowSource, /printf %s/);
assert.match(workflowSource, /rm -f "\$args_path" "\$script_path"/);
assert.match(workflowSource, /node "\$script_path" "\$args_path"/);
assert.doesNotMatch(workflowSource, /fileURLToPath/);
assert.doesNotMatch(workflowSource, /CREATE_PR_SCRIPT_B64/);
assert.doesNotMatch(workflowSource, /CREATE_PR_ARGS_B64/);
assert.doesNotMatch(workflowSource, /base64 -d/);
assert.doesNotMatch(workflowSource, /node -e/);
assert.doesNotMatch(workflowSource, /String\.raw/);

const createPrScript = await readFile('workflows/linear-create-pr.cjs', 'utf8');
assert.match(createPrScript, /\/api\/v1\/github\/pull-request/);
assert.match(createPrScript, /WORKFORCE_WORKSPACE_TOKEN/);
assert.match(createPrScript, /lstatSync/);
assert.match(createPrScript, /isSymbolicLink/);
assert.match(createPrScript, /refs\/remotes\/origin\/HEAD/);
assert.match(createPrScript, /baseBranch/);
assert.doesNotMatch(createPrScript, /baseBranch: 'main'/);
assert.doesNotMatch(createPrScript, /gh pr create/);
const embeddedScript = workflowSource.match(/const CREATE_PR_SCRIPT_SOURCE = `([\s\S]*?)`;\n\ntype OpenPrArgs/)?.[1];
assert.ok(embeddedScript);
const embeddedScriptSource = Function(`return \`${embeddedScript}\`;`)();
assert.equal(embeddedScriptSource.trim(), createPrScript.trim());
assert.doesNotThrow(() => Function(embeddedScriptSource));

assert.deepEqual(runtime.workflowRuns[0].args.openPrArgs, {
repoDir: './repo',
owner: 'AgentWorkforce',
repo: 'cloud',
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["*/persona.ts", "*/agent.ts", "*/*.script.ts"],
"include": ["*/persona.ts", "*/agent.ts", "workflows/*.ts", "workflows/*.d.ts"],
"exclude": ["node_modules"]
}
22 changes: 22 additions & 0 deletions workflows/agent-relay-workflows.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
declare module '@agent-relay/sdk/workflows' {
type AgentOptions = {
cli: string;
preset?: string;
role?: string;
retries?: number;
maxTokens?: number;
};

type StepOptions = Record<string, unknown>;

type WorkflowBuilder = {
description(value: string): WorkflowBuilder;
pattern(value: string): WorkflowBuilder;
timeout(value: number): WorkflowBuilder;
agent(name: string, options: AgentOptions): WorkflowBuilder;
step(name: string, options: StepOptions): WorkflowBuilder;
run(): Promise<unknown>;
};

export function workflow(name: string): WorkflowBuilder;
}
Loading