@agent-native/core 0.114.9 → 0.114.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/engine/ai-sdk-engine.ts +31 -3
- package/corpus/core/src/agent/engine/anthropic-engine.ts +20 -3
- package/corpus/core/src/agent/engine/builder-engine.ts +56 -9
- package/corpus/core/src/agent/engine/first-event-timeout.ts +64 -0
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +2 -3
- package/corpus/core/src/client/AssistantChat.tsx +7 -3
- package/corpus/core/src/client/resources/ResourcesPanel.tsx +1 -1
- package/corpus/core/src/integrations/computer-supervision-store.ts +4 -4
- package/corpus/core/src/integrations/pending-tasks-store.ts +1 -1
- package/corpus/core/src/integrations/remote-commands-store.ts +5 -5
- package/corpus/core/src/integrations/remote-devices-store.ts +3 -3
- package/corpus/core/src/integrations/remote-push-store.ts +3 -3
- package/corpus/core/src/observability/traces.ts +38 -1
- package/corpus/core/src/server/action-routes.ts +23 -1
- package/corpus/core/src/server/http-response-telemetry.ts +6 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +1 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
- package/corpus/templates/analytics/actions/account-deep-dive.ts +41 -6
- package/corpus/templates/analytics/changelog/2026-07-20-account-deep-dives-finish-with-partial-data-when-hubspot-lo.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-20-dashboard-email-reports-now-include-an-image-when-a-single-p.md +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +11 -3
- package/corpus/templates/calendar/actions/create-event.ts +5 -0
- package/corpus/templates/calendar/app/components/ThemeToggle.tsx +4 -1
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +0 -1
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +309 -421
- package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +349 -0
- package/corpus/templates/calendar/app/i18n-data.ts +110 -0
- package/corpus/templates/calendar/app/lib/event-form-utils.ts +6 -0
- package/corpus/templates/calendar/app/pages/CalendarView.tsx +3 -0
- package/corpus/templates/calendar/app/root.tsx +4 -2
- package/corpus/templates/calendar/changelog/2026-07-20-events-can-be-updated-inline-with-date-time-timezone-and-rep.md +6 -0
- package/corpus/templates/calendar/changelog/2026-07-20-public-booking-pages-no-longer-hydrate-with-a-theme-mismatch.md +6 -0
- package/corpus/templates/calendar/shared/api.ts +1 -0
- package/corpus/templates/clips/AGENTS.md +7 -0
- package/corpus/templates/clips/actions/create-recording.ts +51 -10
- package/corpus/templates/clips/actions/get-recording-player-data.ts +34 -5
- package/corpus/templates/clips/actions/lib/transcript-preview.ts +48 -0
- package/corpus/templates/clips/actions/list-recordings.ts +15 -4
- package/corpus/templates/clips/actions/regenerate-summary.ts +32 -0
- package/corpus/templates/clips/actions/search-recordings.ts +28 -6
- package/corpus/templates/clips/actions/view-screen.ts +54 -47
- package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -5
- package/corpus/templates/clips/app/components/library/recording-card.tsx +29 -32
- package/corpus/templates/clips/app/components/meetings/quick-ask-sidebar.tsx +13 -0
- package/corpus/templates/clips/changelog/2026-07-20-agent-summaries-use-bounded-transcript-context.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-clip-summaries-now-finish-without-sending-a-long-transcript-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-clips-agents-only-discover-recordings-you-own-or-have-alread.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-hosted-recordings-no-longer-start-with-an-impossible-buffered-upload.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-meeting-recordings-now-offer-a-cleaner-transcript-view-with-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-shared-clips-can-be-shared-with-limited-access-and-cards-sho.md +6 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +12 -8
- package/corpus/templates/clips/desktop/src/overlays/pill-logo.tsx +2 -15
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +107 -340
- package/corpus/templates/clips/desktop/src/styles.css +27 -145
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +6 -7
- package/corpus/templates/clips/server/lib/agent-recording-access.ts +73 -0
- package/corpus/templates/clips/server/plugins/agent-chat.ts +5 -2
- package/corpus/templates/plan/app/lib/plan-local-bridge.ts +18 -0
- package/corpus/templates/plan/app/pages/PlansPage.tsx +10 -11
- package/corpus/templates/plan/changelog/2026-07-20-bridged-local-plans-now-load-comments-from-the-local-workspace.md +6 -0
- package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.js +27 -4
- package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
- package/dist/agent/engine/anthropic-engine.js +19 -4
- package/dist/agent/engine/anthropic-engine.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +40 -7
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/agent/engine/first-event-timeout.d.ts +25 -0
- package/dist/agent/engine/first-event-timeout.d.ts.map +1 -0
- package/dist/agent/engine/first-event-timeout.js +49 -0
- package/dist/agent/engine/first-event-timeout.js.map +1 -0
- package/dist/cli/pr-visual-recap-workflow.d.ts +2 -2
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +2 -2
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +7 -3
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/resources/ResourcesPanel.js +1 -1
- package/dist/client/resources/ResourcesPanel.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/integrations/computer-supervision-store.js +4 -4
- package/dist/integrations/computer-supervision-store.js.map +1 -1
- package/dist/integrations/pending-tasks-store.js +1 -1
- package/dist/integrations/pending-tasks-store.js.map +1 -1
- package/dist/integrations/remote-commands-store.js +5 -5
- package/dist/integrations/remote-commands-store.js.map +1 -1
- package/dist/integrations/remote-devices-store.js +3 -3
- package/dist/integrations/remote-devices-store.js.map +1 -1
- package/dist/integrations/remote-push-store.js +3 -3
- package/dist/integrations/remote-push-store.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/traces.d.ts.map +1 -1
- package/dist/observability/traces.js +23 -1
- package/dist/observability/traces.js.map +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-routes.d.ts.map +1 -1
- package/dist/server/action-routes.js +23 -1
- package/dist/server/action-routes.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/http-response-telemetry.d.ts +2 -0
- package/dist/server/http-response-telemetry.d.ts.map +1 -1
- package/dist/server/http-response-telemetry.js +5 -1
- package/dist/server/http-response-telemetry.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +1 -1
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
- package/package.json +2 -2
- package/src/agent/engine/ai-sdk-engine.ts +31 -3
- package/src/agent/engine/anthropic-engine.ts +20 -3
- package/src/agent/engine/builder-engine.ts +56 -9
- package/src/agent/engine/first-event-timeout.ts +64 -0
- package/src/cli/pr-visual-recap-workflow.ts +2 -3
- package/src/client/AssistantChat.tsx +7 -3
- package/src/client/resources/ResourcesPanel.tsx +1 -1
- package/src/integrations/computer-supervision-store.ts +4 -4
- package/src/integrations/pending-tasks-store.ts +1 -1
- package/src/integrations/remote-commands-store.ts +5 -5
- package/src/integrations/remote-devices-store.ts +3 -3
- package/src/integrations/remote-push-store.ts +3 -3
- package/src/observability/traces.ts +38 -1
- package/src/server/action-routes.ts +23 -1
- package/src/server/http-response-telemetry.ts +6 -1
- package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +1 -1
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export
|
|
3
|
-
'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \'ubuntu-latest\') || \'ubuntu-latest\' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = \'ubuntu-latest\';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\');\n const hosted = typeof candidate === \'string\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\'self-hosted\') && candidate.every((label) => typeof label === \'string\' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error(\'unsupported runner value\');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push(\'invalid VISUAL_RECAP_RUNS_ON JSON\');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push(\'self-hosted runner mode requires a trusted same-repository PR author\');\n }\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || \'claude\').toLowerCase();\n const agent = [\'deepseek\', \'kimi\', \'moonshot\', \'custom\'].includes(rawAgent) ? \'openai-compatible\' : rawAgent;\n if (![\'claude\', \'codex\', \'openai-compatible\'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else if (agent === \'claude\') {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n } else {\n if (process.env.HAS_COMPATIBLE !== \'true\') reasons.push(\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\');\n if (!(process.env.VISUAL_RECAP_MODEL || \'\').trim()) reasons.push(\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \'\';\n try {\n const parsed = new URL(baseUrl);\n if (![\'http:\', \'https:\'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\');\n }\n } catch {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith(\'.claude/\') ||\n p === \'CLAUDE.md\' ||\n p === \'AGENTS.md\' ||\n p === \'.mcp.json\';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n core.setOutput(\'runs_on\', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'<!-- pr-visual-recap -->\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap — skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : \'\').match(/<!--\\s*plan-id:\\s*([A-Za-z0-9_-]{1,64})\\s*-->/);\n const planIdMarker = planIdMatch ? `\\n\\n<!-- plan-id: ${planIdMatch[1]} -->` : \'\';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n defaults:\n run:\n shell: bash\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/recap-cli@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.cli.outputs.local != \'true\'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n CORE_VERSION="$CORE_CLI_VERSION"\n if [ "$CORE_VERSION" = "latest" ]; then\n CORE_VERSION="$(npm view @agent-native/core@latest version)"\n fi\n npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"\n echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"\n echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' claude-result.json claude-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Build one-shot recap repair prompt\n id: repair_prompt\n if: steps.publish.outputs.repairable == \'true\'\n run: |\n set -euo pipefail\n cp recap-source.json recap-source.initial.json\n $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md\n\n - name: Repair recap source (Claude Code)\n id: claude_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'claude\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write"\n CLAUDE_ARGS=(-p "$(cat recap-repair-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f claude-repair-result.json claude-repair-stderr.log\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-repair-result.json 2> claude-repair-stderr.log\n CLAUDE_REPAIR_STATUS="$?"\n set -e\n echo "$CLAUDE_REPAIR_STATUS" > claude-repair-exit-code.txt\n if [ "$CLAUDE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Repair recap source (Codex)\n id: codex_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'codex\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f codex-repair-events.jsonl codex-repair-stderr.log\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-repair-prompt.md)" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl\n CODEX_REPAIR_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_REPAIR_STATUS" > codex-repair-exit-code.txt\n if [ "$CODEX_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Repair recap source (OpenAI-compatible)\n id: openai_compatible_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'openai-compatible\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-repair-prompt.md)" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log\n OPENAI_COMPATIBLE_REPAIR_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_REPAIR_STATUS" > openai-compatible-repair-exit-code.txt\n if [ "$OPENAI_COMPATIBLE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Validate repaired recap source\n id: repaired_source\n if: steps.publish.outputs.repairable == \'true\'\n env:\n REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }}\n run: |\n set -uo pipefail\n REPAIR_REASON=""\n if [ "${REPAIR_AGENT_OK:-false}" != "true" ]; then\n echo "ok=false" >> "$GITHUB_OUTPUT"\n REPAIR_REASON="Repair agent exited unsuccessfully; repaired source was not published."\n else\n VALIDATION_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)"\n REPAIR_OK="$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$VALIDATION_JSON")"\n REPAIR_REASON="$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("Repair validation returned invalid output.")}\' "$VALIDATION_JSON")"\n echo "ok=$REPAIR_OK" >> "$GITHUB_OUTPUT"\n fi\n if [ -n "$REPAIR_REASON" ]; then\n echo "$REPAIR_REASON" > recap-url-reason.txt\n fi\n {\n echo \'reason<<__RECAP_REPAIR_REASON_EOF__\'\n echo "$REPAIR_REASON"\n echo \'__RECAP_REPAIR_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Publish repaired recap source\n id: publish_repair\n if: steps.repaired_source.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n URL_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}\' "$URL_RESULT")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == \'success\' }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n if [ "$RECAP_REPAIR_ATTEMPTED" = "true" ]; then\n RESULT=claude-repair-result.json\n STDERR=claude-repair-stderr.log\n EXIT_CODE=claude-repair-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-repair-events.jsonl\n STDERR=codex-repair-stderr.log\n EXIT_CODE=codex-repair-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-repair-result.txt\n STDERR=openai-compatible-repair-stderr.log\n EXIT_CODE=openai-compatible-repair-exit-code.txt\n fi\n fi\n SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo \'{}\')"\n SUMMARY="$(node -e \'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}\' "$SUMMARY_JSON")"\n if [ -n "$SUMMARY" ]; then\n {\n echo \'summary<<__RECAP_AGENT_SUMMARY_EOF__\'\n echo "$SUMMARY"\n echo \'__RECAP_AGENT_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == \'success\' }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi\n if [ "$RECAP_REPAIR_SUCCEEDED" = "true" ]; then\n RESULT=claude-repair-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-repair-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-repair-usage.json; fi\n fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"\n LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e \'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<\'NODE\'\n const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };\n const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$SHOT_STATUS")\n SHOT_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}\' "$SHOT_STATUS")\n if [ "$SHOT_OK" != "true" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"\n {\n echo \'shot_reason<<__RECAP_SHOT_REASON_EOF__\'\n echo "$SHOT_REASON"\n echo \'__RECAP_SHOT_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n recap-source.initial.json\n recap-url-reason.txt\n recap-repair-prompt.md\n claude-result.json\n claude-stderr.log\n claude-repair-result.json\n claude-repair-stderr.log\n claude-repair-exit-code.txt\n codex-events.jsonl\n codex-stderr.log\n codex-repair-events.jsonl\n codex-repair-stderr.log\n codex-repair-exit-code.txt\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n openai-compatible-repair-result.txt\n openai-compatible-repair-usage.json\n openai-compatible-repair-stderr.log\n openai-compatible-repair-exit-code.txt\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n';
|
|
1
|
+
/** Compatibility re-export for the workflow bundled by the recap CLI package. */
|
|
2
|
+
export { PR_VISUAL_RECAP_WORKFLOW_YML } from "@agent-native/recap-cli";
|
|
@@ -3237,7 +3237,9 @@ const AssistantChatInner = forwardRef<
|
|
|
3237
3237
|
const reconnectErrorCode =
|
|
3238
3238
|
reconnectTerminalReason === "run_timeout"
|
|
3239
3239
|
? "run_timeout"
|
|
3240
|
-
: "
|
|
3240
|
+
: reconnectTerminalReason === "stream_ended"
|
|
3241
|
+
? "reconnect_stream_ended"
|
|
3242
|
+
: "reconnect_no_progress";
|
|
3241
3243
|
captureError(new Error(`agent-chat:${reconnectErrorCode}`), {
|
|
3242
3244
|
tags: {
|
|
3243
3245
|
context: "agent-native-chat",
|
|
@@ -3309,9 +3311,11 @@ const AssistantChatInner = forwardRef<
|
|
|
3309
3311
|
}
|
|
3310
3312
|
setRunErrorInfo({
|
|
3311
3313
|
message:
|
|
3312
|
-
|
|
3314
|
+
reconnectErrorCode === "run_timeout"
|
|
3313
3315
|
? "The previous background agent run reached its time limit before finishing. The partial work was preserved; continue or retry to pick up from here."
|
|
3314
|
-
:
|
|
3316
|
+
: reconnectErrorCode === "reconnect_stream_ended"
|
|
3317
|
+
? "The previous agent stream ended while the run was recovering. Continue or retry to reconnect to the run."
|
|
3318
|
+
: "The previous agent run stopped producing visible progress during recovery, so it was stopped before it could keep looping.",
|
|
3315
3319
|
errorCode: reconnectErrorCode,
|
|
3316
3320
|
recoverable: true,
|
|
3317
3321
|
runId,
|
|
@@ -613,7 +613,7 @@ The result should be a reusable agent profile, not a one-off task response.`,
|
|
|
613
613
|
icon: <IconBulb className="h-3.5 w-3.5" />,
|
|
614
614
|
label: "Create Skill",
|
|
615
615
|
desc: "Teach the agent a new ability",
|
|
616
|
-
action: openSkillFlyout,
|
|
616
|
+
action: () => openSkillFlyout(),
|
|
617
617
|
hoverAction: openSkillFlyout,
|
|
618
618
|
},
|
|
619
619
|
{
|
|
@@ -175,7 +175,7 @@ export async function decideComputerApproval(input: {
|
|
|
175
175
|
SET status = ?, decision_result_json = ?, decided_by = ?,
|
|
176
176
|
decided_at = ?, updated_at = ?
|
|
177
177
|
WHERE id = ? AND owner_email = ?
|
|
178
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
178
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
179
179
|
AND action_hash = ? AND status = 'pending' AND expires_at > ?`,
|
|
180
180
|
args: [
|
|
181
181
|
input.decision,
|
|
@@ -204,7 +204,7 @@ export async function getComputerApprovalForOwner(input: {
|
|
|
204
204
|
const { rows } = await getDbExec().execute({
|
|
205
205
|
sql: `SELECT * FROM integration_computer_approvals
|
|
206
206
|
WHERE id = ? AND owner_email = ?
|
|
207
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
207
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
208
208
|
LIMIT 1`,
|
|
209
209
|
args: [
|
|
210
210
|
input.id,
|
|
@@ -228,7 +228,7 @@ export async function listComputerApprovalsForOwner(input: {
|
|
|
228
228
|
await ensureComputerApprovalStore();
|
|
229
229
|
const clauses = [
|
|
230
230
|
"owner_email = ?",
|
|
231
|
-
"((org_id IS NULL AND ? IS NULL) OR org_id = ?)",
|
|
231
|
+
"((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)",
|
|
232
232
|
];
|
|
233
233
|
const args: Array<string | number | null> = [
|
|
234
234
|
input.ownerEmail,
|
|
@@ -295,7 +295,7 @@ export async function authorizeComputerOperation(
|
|
|
295
295
|
const { rows } = await client.execute({
|
|
296
296
|
sql: `SELECT * FROM integration_computer_approvals
|
|
297
297
|
WHERE id = ? AND owner_email = ?
|
|
298
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
298
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
299
299
|
AND device_id = ? AND task_id = ? AND run_id = ?
|
|
300
300
|
LIMIT 1`,
|
|
301
301
|
args: [
|
|
@@ -316,7 +316,7 @@ export async function resolveIntegrationSourceContext(
|
|
|
316
316
|
FROM integration_pending_tasks
|
|
317
317
|
WHERE id = ?
|
|
318
318
|
AND owner_email = ?
|
|
319
|
-
AND (org_id = ? OR (org_id IS NULL AND ? IS NULL))
|
|
319
|
+
AND (org_id = ? OR (org_id IS NULL AND CAST(? AS TEXT) IS NULL))
|
|
320
320
|
AND platform = 'slack'
|
|
321
321
|
LIMIT 1`,
|
|
322
322
|
args: [id, ownerEmail, orgId, orgId],
|
|
@@ -368,7 +368,7 @@ export async function listRemoteCommandsForOwner(input: {
|
|
|
368
368
|
const { rows } = await getDbExec().execute({
|
|
369
369
|
sql: `SELECT * FROM integration_remote_commands
|
|
370
370
|
WHERE owner_email = ?
|
|
371
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
371
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
372
372
|
ORDER BY updated_at DESC
|
|
373
373
|
LIMIT ?`,
|
|
374
374
|
args: [input.ownerEmail, input.orgId ?? null, input.orgId ?? null, limit],
|
|
@@ -435,7 +435,7 @@ export async function claimNextComputerCommand(input: {
|
|
|
435
435
|
const { rows } = await client.execute({
|
|
436
436
|
sql: `SELECT * FROM integration_remote_commands
|
|
437
437
|
WHERE device_id = ? AND owner_email = ?
|
|
438
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
438
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
439
439
|
AND kind = 'computer-operation' AND status = 'pending'
|
|
440
440
|
AND next_check_at <= ?${operationClassClause}
|
|
441
441
|
ORDER BY computer_sequence ASC, created_at ASC
|
|
@@ -477,7 +477,7 @@ export async function claimNextComputerCommand(input: {
|
|
|
477
477
|
sql: `UPDATE integration_remote_commands
|
|
478
478
|
SET status = 'failed', error_message = ?, completed_at = ?, updated_at = ?
|
|
479
479
|
WHERE id = ? AND device_id = ? AND owner_email = ?
|
|
480
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
480
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
481
481
|
AND status = 'pending'`,
|
|
482
482
|
args: [
|
|
483
483
|
error instanceof Error
|
|
@@ -500,13 +500,13 @@ export async function claimNextComputerCommand(input: {
|
|
|
500
500
|
? `UPDATE integration_remote_commands
|
|
501
501
|
SET status = 'claimed', attempts = attempts + 1, claimed_at = ?, updated_at = ?
|
|
502
502
|
WHERE id = ? AND device_id = ? AND owner_email = ?
|
|
503
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
503
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
504
504
|
AND status = 'pending' AND lease_expires_at > ?
|
|
505
505
|
RETURNING *`
|
|
506
506
|
: `UPDATE integration_remote_commands
|
|
507
507
|
SET status = 'claimed', attempts = attempts + 1, claimed_at = ?, updated_at = ?
|
|
508
508
|
WHERE id = ? AND device_id = ? AND owner_email = ?
|
|
509
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
509
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
510
510
|
AND status = 'pending' AND lease_expires_at > ?`,
|
|
511
511
|
args: [
|
|
512
512
|
now,
|
|
@@ -243,7 +243,7 @@ export async function getRemoteDeviceForOwner(input: {
|
|
|
243
243
|
sql: `SELECT * FROM integration_remote_devices
|
|
244
244
|
WHERE id = ?
|
|
245
245
|
AND owner_email = ?
|
|
246
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
246
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
247
247
|
LIMIT 1`,
|
|
248
248
|
args: [
|
|
249
249
|
input.id,
|
|
@@ -287,7 +287,7 @@ export async function listRemoteDevicesForOwner(input: {
|
|
|
287
287
|
const { rows } = await getDbExec().execute({
|
|
288
288
|
sql: `SELECT * FROM integration_remote_devices
|
|
289
289
|
WHERE owner_email = ?
|
|
290
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)${statusClause}
|
|
290
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)${statusClause}
|
|
291
291
|
ORDER BY COALESCE(last_seen_at, updated_at) DESC
|
|
292
292
|
LIMIT ?`,
|
|
293
293
|
args,
|
|
@@ -379,7 +379,7 @@ export async function revokeRemoteDeviceForOwner(input: {
|
|
|
379
379
|
updated_at = ?
|
|
380
380
|
WHERE id = ?
|
|
381
381
|
AND owner_email = ?
|
|
382
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)`,
|
|
382
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)`,
|
|
383
383
|
args: [
|
|
384
384
|
now,
|
|
385
385
|
now,
|
|
@@ -330,7 +330,7 @@ export async function listRemotePushRegistrationsForOwner(input: {
|
|
|
330
330
|
const { rows } = await getDbExec().execute({
|
|
331
331
|
sql: `SELECT * FROM integration_remote_push_registrations
|
|
332
332
|
WHERE owner_email = ?
|
|
333
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)${statusClause}
|
|
333
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)${statusClause}
|
|
334
334
|
ORDER BY COALESCE(last_seen_at, updated_at) DESC
|
|
335
335
|
LIMIT ?`,
|
|
336
336
|
args: [input.ownerEmail, input.orgId ?? null, input.orgId ?? null, limit],
|
|
@@ -352,7 +352,7 @@ export async function unregisterRemotePushRegistrationForOwner(input: {
|
|
|
352
352
|
sql: `UPDATE integration_remote_push_registrations
|
|
353
353
|
SET status = 'inactive', updated_at = ?
|
|
354
354
|
WHERE owner_email = ?
|
|
355
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)
|
|
355
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
|
|
356
356
|
AND (${input.id ? "id = ?" : "0 = 1"} OR ${
|
|
357
357
|
tokenHash ? "token_hash = ?" : "0 = 1"
|
|
358
358
|
})`,
|
|
@@ -432,7 +432,7 @@ export async function listRemotePushNotificationsForOwner(input: {
|
|
|
432
432
|
const { rows } = await getDbExec().execute({
|
|
433
433
|
sql: `SELECT * FROM integration_remote_push_notifications
|
|
434
434
|
WHERE owner_email = ?
|
|
435
|
-
AND ((org_id IS NULL AND ? IS NULL) OR org_id = ?)${statusClause}
|
|
435
|
+
AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)${statusClause}
|
|
436
436
|
ORDER BY created_at DESC
|
|
437
437
|
LIMIT ?`,
|
|
438
438
|
args,
|
|
@@ -27,6 +27,9 @@ function costUsdFromCenticents(value: number): number {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const MAX_TRACKED_GENERATION_TOOL_CALLS = 50;
|
|
30
|
+
const MAX_TOOL_ERROR_MESSAGE_LENGTH = 500;
|
|
31
|
+
const STANDALONE_API_KEY_PATTERN =
|
|
32
|
+
/\b(?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{8,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|AIza[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,})\b/g;
|
|
30
33
|
|
|
31
34
|
type GenerationToolCall = {
|
|
32
35
|
name: string;
|
|
@@ -34,8 +37,35 @@ type GenerationToolCall = {
|
|
|
34
37
|
duration_ms: number;
|
|
35
38
|
status: "success" | "error";
|
|
36
39
|
error_class: "tool_error" | "legacy_inferred_error" | "interrupted" | null;
|
|
40
|
+
error_message?: string;
|
|
37
41
|
};
|
|
38
42
|
|
|
43
|
+
function truncateToolErrorMessage(value: string): string {
|
|
44
|
+
return value.length > MAX_TOOL_ERROR_MESSAGE_LENGTH
|
|
45
|
+
? `${value.slice(0, MAX_TOOL_ERROR_MESSAGE_LENGTH)}…`
|
|
46
|
+
: value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function redactToolErrorMessage(value: string): string {
|
|
50
|
+
const credentialName =
|
|
51
|
+
"authorization|cookie|api[_ -]?key|password|secret|token|access[_ -]?token|refresh[_ -]?token";
|
|
52
|
+
const labeledCredential = `(["']?\\b(?:${credentialName})\\b["']?\\s*[:=]\\s*["']?)`;
|
|
53
|
+
return value
|
|
54
|
+
.replace(
|
|
55
|
+
new RegExp(
|
|
56
|
+
`${labeledCredential}(?:Bearer|Basic)\\s+[^"'\\s,;)}\\]]+`,
|
|
57
|
+
"gi",
|
|
58
|
+
),
|
|
59
|
+
"$1[REDACTED]",
|
|
60
|
+
)
|
|
61
|
+
.replace(
|
|
62
|
+
new RegExp(`${labeledCredential}[^"'\\s,;)}\\[\\]]+`, "gi"),
|
|
63
|
+
"$1[REDACTED]",
|
|
64
|
+
)
|
|
65
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED]")
|
|
66
|
+
.replace(STANDALONE_API_KEY_PATTERN, "[REDACTED]");
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
function emitLlmGenerationTrackingEvent(args: {
|
|
40
70
|
runId: string;
|
|
41
71
|
threadId: string | null;
|
|
@@ -439,6 +469,10 @@ export async function instrumentAgentLoop(opts: {
|
|
|
439
469
|
: explicitError
|
|
440
470
|
? "tool_error"
|
|
441
471
|
: "legacy_inferred_error",
|
|
472
|
+
error_message:
|
|
473
|
+
isError && config.captureToolResults
|
|
474
|
+
? truncateToolErrorMessage(redactToolErrorMessage(event.result))
|
|
475
|
+
: undefined,
|
|
442
476
|
});
|
|
443
477
|
}
|
|
444
478
|
|
|
@@ -532,6 +566,7 @@ export async function instrumentAgentLoop(opts: {
|
|
|
532
566
|
for (const [counter, pending] of pendingTools) {
|
|
533
567
|
toolCallCount += 1;
|
|
534
568
|
failedTools += 1;
|
|
569
|
+
const interruptedMessage = "Tool call interrupted before completion";
|
|
535
570
|
if (counter < MAX_TRACKED_GENERATION_TOOL_CALLS) {
|
|
536
571
|
generationToolCalls.set(counter, {
|
|
537
572
|
name: pending.toolName,
|
|
@@ -539,9 +574,11 @@ export async function instrumentAgentLoop(opts: {
|
|
|
539
574
|
duration_ms: Math.max(0, runEnd - pending.startMs),
|
|
540
575
|
status: "error",
|
|
541
576
|
error_class: "interrupted",
|
|
577
|
+
error_message: config.captureToolResults
|
|
578
|
+
? interruptedMessage
|
|
579
|
+
: undefined,
|
|
542
580
|
});
|
|
543
581
|
}
|
|
544
|
-
const interruptedMessage = "Tool call interrupted before completion";
|
|
545
582
|
if (pending.otelSpan) {
|
|
546
583
|
openOtelToolSpans.delete(pending.otelSpan);
|
|
547
584
|
endAgentSpan(pending.otelSpan, {
|
|
@@ -26,10 +26,12 @@ import {
|
|
|
26
26
|
seedAgentRunOwnerContext,
|
|
27
27
|
type AgentRunOwnerContext,
|
|
28
28
|
} from "./agent-run-context.js";
|
|
29
|
+
import { captureError } from "./capture-error.js";
|
|
29
30
|
import {
|
|
30
31
|
getAllowedCorsOrigin as resolveAllowedCorsOrigin,
|
|
31
32
|
readCorsAllowedOrigins,
|
|
32
33
|
} from "./cors-origins.js";
|
|
34
|
+
import { getHttpRequestTelemetryId } from "./http-response-telemetry.js";
|
|
33
35
|
|
|
34
36
|
declare const __AGENT_NATIVE_BUILD_ID__: string | undefined;
|
|
35
37
|
declare const __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__: string | undefined;
|
|
@@ -640,7 +642,27 @@ export function mountActionRoutes(
|
|
|
640
642
|
if (isUserFacing) {
|
|
641
643
|
return { error: msg };
|
|
642
644
|
}
|
|
643
|
-
|
|
645
|
+
const requestId = getHttpRequestTelemetryId(event);
|
|
646
|
+
const captureId = captureError(err, {
|
|
647
|
+
route: routePath,
|
|
648
|
+
method: reqMethod,
|
|
649
|
+
tags: {
|
|
650
|
+
action: name,
|
|
651
|
+
caller: resolvedCaller
|
|
652
|
+
? "a2a"
|
|
653
|
+
: isFrontendActionRequest(event)
|
|
654
|
+
? "frontend"
|
|
655
|
+
: "http",
|
|
656
|
+
status_code: String(status),
|
|
657
|
+
},
|
|
658
|
+
...(requestId ? { extra: { request_id: requestId } } : {}),
|
|
659
|
+
});
|
|
660
|
+
console.error(`[agent-native] action '${name}' failed:`, {
|
|
661
|
+
action: name,
|
|
662
|
+
...(requestId ? { requestId } : {}),
|
|
663
|
+
...(captureId ? { captureId } : {}),
|
|
664
|
+
error: err?.stack ?? String(err),
|
|
665
|
+
});
|
|
644
666
|
return { error: "Internal server error" };
|
|
645
667
|
}
|
|
646
668
|
},
|
|
@@ -284,11 +284,16 @@ function emitTelemetry(
|
|
|
284
284
|
function requestTelemetryState(
|
|
285
285
|
event: H3Event,
|
|
286
286
|
): HttpRequestTelemetryState | undefined {
|
|
287
|
-
return (event.context as Record<PropertyKey, unknown>)[
|
|
287
|
+
return (event.context as Record<PropertyKey, unknown> | undefined)?.[
|
|
288
288
|
REQUEST_TELEMETRY_KEY
|
|
289
289
|
] as HttpRequestTelemetryState | undefined;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/** Return the durable request id while a request is still being handled. */
|
|
293
|
+
export function getHttpRequestTelemetryId(event: H3Event): string | undefined {
|
|
294
|
+
return requestTelemetryState(event)?.requestId;
|
|
295
|
+
}
|
|
296
|
+
|
|
292
297
|
function appendServerTiming(
|
|
293
298
|
response: Response,
|
|
294
299
|
event: H3Event,
|
|
@@ -32,7 +32,7 @@ await putSetting("observability-config", {
|
|
|
32
32
|
enabled: true,
|
|
33
33
|
capturePrompts: false,
|
|
34
34
|
captureToolArgs: true, // capture action input args
|
|
35
|
-
captureToolResults: false,
|
|
35
|
+
captureToolResults: false, // include failed tool error text on tracked $ai_generation tool call entries
|
|
36
36
|
evalSampleRate: 0.05, // 5% of runs get LLM-as-judge eval
|
|
37
37
|
inferredSentimentEnabled: false,
|
|
38
38
|
inferredSentimentSampleRate: 0,
|
|
@@ -160,7 +160,7 @@ Other framework-level baseline events:
|
|
|
160
160
|
- `signup` from Better Auth user creation, with `auth_provider`, `auth_user_id`, and first-touch referral attribution (`referral_source`, `referrer_user`, `referral_medium`, `referral_campaign`, `utm_*`, `first_touch_path`, `landing_referrer` — see "Referral / viral attribution" above)
|
|
161
161
|
- `builder connect clicked` and `builder connect popup blocked` from browser Connect Builder CTAs
|
|
162
162
|
- `builder connect started`, `builder connect succeeded`, `builder connect failed`, `builder disconnect succeeded`, and `builder disconnect failed` from the Builder connection routes, with LLM connection context when resolvable
|
|
163
|
-
- `$ai_generation` from instrumented agent loops, with PostHog AI Observability fields such as `$ai_trace_id`, `$ai_session_id`, `$ai_model`, `$ai_provider`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_total_cost_usd`, and mirrored Agent Native query fields such as `run_id`, `thread_id`, `cost_cents_x100`, `duration_ms`, `tool_calls`, and `status`. A bounded `tools` array contains names, start offsets, durations, statuses, and coarse error classes only; interrupted tools and failed runs remain visible, and delegated runs include protocol/task/parent-run/parent-turn correlation. Prompt, tool argument, result, and output content is excluded.
|
|
163
|
+
- `$ai_generation` from instrumented agent loops, with PostHog AI Observability fields such as `$ai_trace_id`, `$ai_session_id`, `$ai_model`, `$ai_provider`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_total_cost_usd`, and mirrored Agent Native query fields such as `run_id`, `thread_id`, `cost_cents_x100`, `duration_ms`, `tool_calls`, and `status`. A bounded `tools` array contains names, start offsets, durations, statuses, and coarse error classes only; interrupted tools and failed runs remain visible, and delegated runs include protocol/task/parent-run/parent-turn correlation. Prompt, tool argument, result, and output content is excluded unless `captureToolResults` is opted in (see the `observability` skill), in which case each failed tool call also carries a `error_message` string truncated to 500 characters and already scrubbed of bearer tokens, API keys, and key/value secret patterns.
|
|
164
164
|
|
|
165
165
|
For new lifecycle events, call `track()` server-side when the server is the source of truth, and `trackEvent()` client-side only for browser interactions.
|
|
166
166
|
|