-
Notifications
You must be signed in to change notification settings - Fork 0
fix: rewrite agents to the real runtime VFS API #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,29 @@ | |
| * The repo is already in the sandbox: the cloud materializes the github | ||
| * integration's repo into ctx.sandbox.cwd via relayfile, so there's no clone. | ||
| */ | ||
| import { defineAgent, type WorkforceCtx } from '@agentworkforce/runtime'; | ||
| import { | ||
| defineAgent, | ||
| draftFile, | ||
| encodeSegment, | ||
| readJsonFile, | ||
| resolveMountRoot, | ||
| writeJsonFile, | ||
| type IntegrationClientOptions, | ||
| type WorkforceCtx | ||
| } from '@agentworkforce/runtime'; | ||
|
|
||
| interface LinearIssue { | ||
| id?: string; | ||
| identifier?: string; | ||
| title: string; | ||
| description: string | null; | ||
| url?: string; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| function vfsClient(): IntegrationClientOptions { | ||
| return { relayfileMountRoot: resolveMountRoot({}) }; | ||
| } | ||
|
|
||
| export default defineAgent({ | ||
| // Two Linear triggers — `on` autocompletes Linear's catalog events. | ||
|
|
@@ -21,7 +43,8 @@ export default defineAgent({ | |
| }, | ||
| handler: async (ctx, event) => { | ||
| if (event.source !== 'linear') return; | ||
| if (!ctx.linear) throw new Error('linear-implementer requires the linear integration'); | ||
|
|
||
| const client = vfsClient(); | ||
|
|
||
| // The comment path only fires when someone @-mentions the agent (configurable | ||
| // via MENTION, e.g. "@agentrelay") — and never on the agent's own reply. | ||
|
|
@@ -31,7 +54,12 @@ export default defineAgent({ | |
|
|
||
| const issueId = readIssueId(event.payload); | ||
| if (!issueId) return; | ||
| const issue = await ctx.linear.getIssue(issueId); | ||
| const issue = await readJsonFile<LinearIssue>( | ||
| client, | ||
| 'linear', | ||
| 'getIssue', | ||
| `/linear/issues/${encodeSegment(issueId)}.json` | ||
| ); | ||
|
Comment on lines
+57
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const issue = await readJsonFile<LinearIssue>(\n client,\n 'linear',\n 'getIssue',\n \`/linear/issues/${encodeSegment(issueId)}.json\`\n );\n if (!issue) {\n ctx.log('warn', 'linear-implementer.issue-not-found', { issueId });\n return;\n } |
||
|
|
||
| // The issue may name its own target repo (a github URL); if so, tell the agent | ||
| // to work there — otherwise it uses the materialized repo. | ||
|
|
@@ -50,9 +78,16 @@ export default defineAgent({ | |
| }); | ||
|
|
||
| const prUrl = findPrUrl(run.output); | ||
| await ctx.linear.comment( | ||
| issueId, | ||
| prUrl ? `:rocket: Opened a PR: ${prUrl}` : "I worked on this but couldn't open a PR — check the run logs." | ||
| await writeJsonFile( | ||
| client, | ||
| 'linear', | ||
| 'comment', | ||
| `/linear/issues/${encodeSegment(issueId)}/comments/${draftFile('comment')}`, | ||
| { | ||
| body: prUrl | ||
| ? `:rocket: Opened a PR: ${prUrl}` | ||
| : "I worked on this but couldn't open a PR — check the run logs." | ||
| } | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,13 +2,37 @@ | |
| * repo-hygiene handler. | ||
| * | ||
| * GitHub PR opened/synchronized | ||
| * -> read PR metadata + diff through Relayfile-backed ctx.github | ||
| * -> read PR metadata + diff through the Relayfile-backed github VFS | ||
| * -> run a read-only hygiene diagnosis in the materialized repo | ||
| * -> post a concise PR comment | ||
| * -> create a Notion journal page for the run | ||
| * -> optionally post a Slack summary | ||
| */ | ||
| import { defineAgent, type WorkforceCtx, type WorkforceProviderEvent } from '@agentworkforce/runtime'; | ||
| import { | ||
| defineAgent, | ||
| draftFile, | ||
| encodeSegment, | ||
| readJsonFile, | ||
| resolveMountRoot, | ||
| writeJsonFile, | ||
| type IntegrationClientOptions, | ||
| type WorkforceCtx, | ||
| type WorkforceProviderEvent | ||
| } from '@agentworkforce/runtime'; | ||
|
|
||
| function vfsClient(): IntegrationClientOptions { | ||
| return { relayfileMountRoot: resolveMountRoot({}) }; | ||
| } | ||
|
|
||
| interface GithubPrMeta { | ||
| title?: string; | ||
| body?: string; | ||
| author?: string; | ||
| base?: string; | ||
| head?: string; | ||
| diff?: string; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface PrRef { | ||
| owner: string; | ||
|
|
@@ -46,29 +70,45 @@ export default defineAgent({ | |
| handler: async (ctx, event) => { | ||
| if (event.source !== 'github') return; | ||
| if (event.type !== 'pull_request.opened' && event.type !== 'pull_request.synchronize') return; | ||
| if (!ctx.github) throw new Error('repo-hygiene requires the github integration'); | ||
| if (!ctx.notion) throw new Error('repo-hygiene requires the notion integration'); | ||
|
|
||
| const pr = readPr(event); | ||
| if (!pr) return; | ||
|
|
||
| const details = await ctx.github.getPr(pr); | ||
| const report = await diagnose(ctx, pr, details.diff); | ||
| const client = vfsClient(); | ||
| const details = await readJsonFile<GithubPrMeta>( | ||
| client, | ||
| 'github', | ||
| 'getPr', | ||
| `/github/repos/${encodeSegment(pr.owner)}/${encodeSegment(pr.repo)}/pulls/${pr.number}/meta.json` | ||
| ); | ||
|
Comment on lines
+78
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const details = await readJsonFile<GithubPrMeta>(\n client,\n 'github',\n 'getPr',\n \`/github/repos/${encodeSegment(pr.owner)}/${encodeSegment(pr.repo)}/pulls/${pr.number}/meta.json\`\n );\n if (!details) {\n ctx.log('warn', 'repo-hygiene.pr-details-not-found', { pr });\n return;\n } |
||
| const report = await diagnose(ctx, pr, details.diff ?? ''); | ||
| const body = renderPrComment(pr, report); | ||
|
|
||
| await ctx.github.comment(pr, body); | ||
| await writeJsonFile( | ||
| client, | ||
| 'github', | ||
| 'comment', | ||
| `/github/repos/${encodeSegment(pr.owner)}/${encodeSegment(pr.repo)}/issues/${pr.number}/comments/${draftFile('comment')}`, | ||
| { body } | ||
| ); | ||
| let notionUrl: string | undefined; | ||
| try { | ||
| const notionPage = await writeNotionJournal(ctx, pr, event, report, body); | ||
| const notionPage = await writeNotionJournal(ctx, client, pr, event, report, body); | ||
| notionUrl = notionPage.url; | ||
| await rememberRun(ctx, pr, event, report, notionUrl); | ||
| } catch (error) { | ||
| ctx.log('warn', 'repo-hygiene.journal-failed', { error: serializeError(error) }); | ||
| } | ||
|
|
||
| const channel = input(ctx, 'SLACK_CHANNEL'); | ||
| if (channel && ctx.slack) { | ||
| await ctx.slack.post(channel, renderSlackSummary(pr, report, notionUrl)); | ||
| if (channel) { | ||
| await writeJsonFile( | ||
| client, | ||
| 'slack', | ||
| 'post', | ||
| `/slack/channels/${encodeSegment(channel)}/messages/${draftFile('message')}`, | ||
| { text: renderSlackSummary(pr, report, notionUrl) } | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
|
|
@@ -249,41 +289,52 @@ function renderPrComment(pr: PrRef, report: HygieneReport): string { | |
|
|
||
| async function writeNotionJournal( | ||
| ctx: WorkforceCtx, | ||
| client: IntegrationClientOptions, | ||
| pr: PrRef, | ||
| event: WorkforceProviderEvent, | ||
| report: HygieneReport, | ||
| prComment: string | ||
| ): Promise<{ id: string; url?: string }> { | ||
| ): Promise<{ id?: string; url?: string }> { | ||
| const databaseId = input(ctx, 'NOTION_DATABASE_ID'); | ||
| if (!databaseId) throw new Error('NOTION_DATABASE_ID is required'); | ||
|
|
||
| const title = `${pr.owner}/${pr.repo}#${pr.number} hygiene - ${new Date(event.occurredAt).toISOString().slice(0, 10)}`; | ||
| const page = await ctx.notion!.createPage( | ||
| { database_id: databaseId }, | ||
| const page = await writeJsonFile( | ||
| client, | ||
| 'notion', | ||
| 'createPage', | ||
| `/notion/databases/${encodeSegment(databaseId)}/pages/${draftFile('page')}`, | ||
| { | ||
| Name: { title: [{ text: { content: title } }] }, | ||
| Repository: { rich_text: [{ text: { content: `${pr.owner}/${pr.repo}` } }] }, | ||
| PR: { number: pr.number }, | ||
| Confidence: { select: { name: report.confidence } }, | ||
| Findings: { number: report.findings.length }, | ||
| Trigger: { rich_text: [{ text: { content: event.type } }] } | ||
| }, | ||
| notionBlocks([ | ||
| `PR: ${pr.url}`, | ||
| `Commit: ${pr.headSha ?? 'unknown'}`, | ||
| `Summary: ${report.summary}`, | ||
| '', | ||
| 'Findings:', | ||
| ...(report.findings.length ? report.findings.map((f) => `- [${f.severity}] ${f.title}: ${f.recommendation}`) : ['- none']), | ||
| '', | ||
| 'Follow-ups:', | ||
| ...(report.followUps.length ? report.followUps.map((f) => `- ${f}`) : ['- none']), | ||
| '', | ||
| 'PR comment:', | ||
| prComment | ||
| ].join('\n')) | ||
| properties: { | ||
| Name: { title: [{ text: { content: title } }] }, | ||
| Repository: { rich_text: [{ text: { content: `${pr.owner}/${pr.repo}` } }] }, | ||
| PR: { number: pr.number }, | ||
| Confidence: { select: { name: report.confidence } }, | ||
| Findings: { number: report.findings.length }, | ||
| Trigger: { rich_text: [{ text: { content: event.type } }] } | ||
| }, | ||
| children: notionBlocks([ | ||
| `PR: ${pr.url}`, | ||
| `Commit: ${pr.headSha ?? 'unknown'}`, | ||
| `Summary: ${report.summary}`, | ||
| '', | ||
| 'Findings:', | ||
| ...(report.findings.length ? report.findings.map((f) => `- [${f.severity}] ${f.title}: ${f.recommendation}`) : ['- none']), | ||
| '', | ||
| 'Follow-ups:', | ||
| ...(report.followUps.length ? report.followUps.map((f) => `- ${f}`) : ['- none']), | ||
| '', | ||
| 'PR comment:', | ||
| prComment | ||
| ].join('\n')) | ||
| } | ||
| ); | ||
| return { id: page.id, url: page.url }; | ||
| // Only surface a real Notion URL when the writeback worker returned one; | ||
| // page.path is the in-mount draft, not a clickable Notion link. | ||
| if (!page.receipt?.url) { | ||
| ctx.log('warn', 'repo-hygiene.notion-page.no-receipt', { draftPath: page.path }); | ||
| } | ||
| return { id: page.receipt?.id, url: page.receipt?.url }; | ||
| } | ||
|
|
||
| function notionBlocks(markdown: string): Array<Record<string, unknown>> { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: PR-link comment can be dropped when
createIssuereceipt arrives late becauseissueIdis captured once and never refreshed.Prompt for AI agents