carthagent 0.0.0-stage → 1.0.7
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/LICENSE +21 -0
- package/README.md +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import type { Context } from '@opencode/plugin/promise/plugin';
|
|
2
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from 'node:fs';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { resolve, join } from 'node:path';
|
|
7
|
+
import { listTasks } from '../../../../lib/opencode/workspace.mjs';
|
|
8
|
+
import { localReviewSnapshot } from '../../../../lib/opencode/review.mjs';
|
|
9
|
+
import { PullRequests, type PullRequestRecord } from '../pull-request-rpc';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const MAX_CACHE_BYTES = 256 * 1024;
|
|
13
|
+
const MAX_ASSOCIATIONS = 100;
|
|
14
|
+
const LOCK_WAIT_MS = 5_000;
|
|
15
|
+
const LOCK_RETRY_MS = 25;
|
|
16
|
+
const LOCK_STALE_MS = 30_000;
|
|
17
|
+
type FailureCode = 'invalid_request' | 'not_owned' | 'not_associated' | 'github_unavailable' | 'github_invalid' | 'cache_invalid' | 'cache_limit' | 'cache_busy' | 'worktree_unavailable' | 'canceled' | 'internal';
|
|
18
|
+
class PullRequestFailure extends Error {
|
|
19
|
+
constructor(readonly code: FailureCode, message: string) { super(message); }
|
|
20
|
+
}
|
|
21
|
+
function failure(call: { error: (type: 'pull_request_error', message: string, data: { code: FailureCode }) => unknown }, error: unknown) {
|
|
22
|
+
if (error instanceof PullRequestFailure) return call.error('pull_request_error', publicFailureMessage(error.code), { code: error.code });
|
|
23
|
+
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('cancel'))) return call.error('pull_request_error', publicFailureMessage('canceled'), { code: 'canceled' });
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
const code: FailureCode = /Unexpected property|Expected property|JSON|Unexpected end/i.test(message) ? 'github_invalid'
|
|
26
|
+
: /not owned|another workspace|does not own|foreign-task/i.test(message) ? 'not_owned'
|
|
27
|
+
: /not associated/i.test(message) ? 'not_associated'
|
|
28
|
+
: /cache is invalid|invalid schema|invalid record|foreign-task|symbolic link/i.test(message) ? 'cache_invalid'
|
|
29
|
+
: /cache.*limit|maximum of|100 association/i.test(message) ? 'cache_limit'
|
|
30
|
+
: /cache is busy/i.test(message) ? 'cache_busy'
|
|
31
|
+
: /GitHub returned|pull request repository|GitHub pull request URL|base repository/i.test(message) ? 'github_invalid'
|
|
32
|
+
: /gh unavailable|ENOENT.*gh|timed out/i.test(message) ? 'github_unavailable'
|
|
33
|
+
: /worktree is unavailable|task worktree/i.test(message) ? 'worktree_unavailable'
|
|
34
|
+
: 'internal';
|
|
35
|
+
return call.error('pull_request_error', publicFailureMessage(code), { code });
|
|
36
|
+
}
|
|
37
|
+
function publicFailureMessage(code: FailureCode): string {
|
|
38
|
+
const messages: Record<FailureCode, string> = {
|
|
39
|
+
invalid_request: 'A valid pull request number is required',
|
|
40
|
+
not_owned: 'The task/session association is not owned by this workspace',
|
|
41
|
+
not_associated: 'The pull request is not associated with this task',
|
|
42
|
+
github_unavailable: 'GitHub could not be reached; retry later',
|
|
43
|
+
github_invalid: 'GitHub returned invalid pull request data',
|
|
44
|
+
cache_invalid: 'The task pull request cache is invalid or inaccessible',
|
|
45
|
+
cache_limit: 'The task pull request cache has reached its configured limit',
|
|
46
|
+
cache_busy: 'The pull request cache is busy; retry later',
|
|
47
|
+
worktree_unavailable: 'The task worktree is unavailable',
|
|
48
|
+
canceled: 'The pull request operation was canceled',
|
|
49
|
+
internal: 'The pull request operation failed',
|
|
50
|
+
};
|
|
51
|
+
return messages[code];
|
|
52
|
+
}
|
|
53
|
+
type TaskRecord = { id: string; sessionID: string; worktree: string };
|
|
54
|
+
/** Resolve symlinked ancestors (such as macOS /tmp -> /private/tmp) while requiring
|
|
55
|
+
* the requested directory itself to be the real directory, not a symlink leaf. */
|
|
56
|
+
export function canonicalDirectory(path: string): string {
|
|
57
|
+
const absolute = resolve(path);
|
|
58
|
+
if (lstatSync(absolute).isSymbolicLink()) throw new PullRequestFailure('not_owned', 'Directory path is not an exact owned directory');
|
|
59
|
+
return realpathSync(absolute);
|
|
60
|
+
}
|
|
61
|
+
function hasDirtySource(directory: string): boolean {
|
|
62
|
+
const result = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', '.', ':(exclude).harness/**'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000 });
|
|
63
|
+
return result.trim().length > 0;
|
|
64
|
+
}
|
|
65
|
+
type PRJson = {
|
|
66
|
+
number?: unknown; html_url?: unknown; state?: unknown; draft?: unknown;
|
|
67
|
+
head?: { sha?: unknown; ref?: unknown; repo?: { full_name?: unknown } | null };
|
|
68
|
+
base?: { sha?: unknown; ref?: unknown; repo?: { full_name?: unknown } | null };
|
|
69
|
+
};
|
|
70
|
+
export type PullRequestOptions = {
|
|
71
|
+
/** Injectable process boundary for deterministic offline tests. */
|
|
72
|
+
run?: (command: string, args: string[], cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
async function acquireLock(lockPath: string, signal?: AbortSignal): Promise<() => void> {
|
|
76
|
+
mkdirSync(resolve(lockPath, '..'), { recursive: true });
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
while (true) {
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
const recoveryPath = `${lockPath}.recovering`;
|
|
81
|
+
if (existsSync(recoveryPath)) {
|
|
82
|
+
recoverDeadOwner(recoveryPath);
|
|
83
|
+
if (existsSync(recoveryPath)) { await waitLock(started, signal); continue; }
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const token = randomUUID();
|
|
87
|
+
const fd = publishExclusive(lockPath, { pid: process.pid, token, createdAt: new Date().toISOString() });
|
|
88
|
+
const identity = fstatSync(fd);
|
|
89
|
+
return () => {
|
|
90
|
+
try {
|
|
91
|
+
const current = lstatSync(lockPath);
|
|
92
|
+
const owner = JSON.parse(readBoundedFile(lockPath, 1024).toString('utf8')) as { token?: string };
|
|
93
|
+
if (current.dev === identity.dev && current.ino === identity.ino && owner.token === token) unlinkSync(lockPath);
|
|
94
|
+
} catch { /* Never remove a lock unless this owner still has the same inode and token. */ }
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
99
|
+
if (recoverDeadOwner(lockPath)) continue;
|
|
100
|
+
await waitLock(started, signal);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Publish a complete owner record atomically at its exclusive name. A crash while
|
|
106
|
+
* writing the private temporary file can never leave a nameless/partial owner that
|
|
107
|
+
* blocks future acquisitions. Hard-link publication fails rather than replacing. */
|
|
108
|
+
export function publishExclusive(path: string, record: { pid: number; token: string; createdAt: string }, onStage?: (stage: 'empty' | 'partial' | 'published') => void): number {
|
|
109
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
110
|
+
let fd: number | undefined;
|
|
111
|
+
try {
|
|
112
|
+
fd = openSync(temporary, 'wx', 0o600);
|
|
113
|
+
onStage?.('empty');
|
|
114
|
+
const contents = JSON.stringify(record);
|
|
115
|
+
const midpoint = Math.max(1, Math.floor(contents.length / 2));
|
|
116
|
+
writeFileSync(fd, contents.slice(0, midpoint));
|
|
117
|
+
onStage?.('partial');
|
|
118
|
+
writeFileSync(fd, contents.slice(midpoint));
|
|
119
|
+
fsyncSync(fd);
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
fd = undefined;
|
|
122
|
+
linkSync(temporary, path);
|
|
123
|
+
const published = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
124
|
+
onStage?.('published');
|
|
125
|
+
return published;
|
|
126
|
+
} finally {
|
|
127
|
+
if (fd !== undefined) closeSync(fd);
|
|
128
|
+
try { unlinkSync(temporary); } catch { /* A crash may leave harmless uniquely named temp debris. */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function waitLock(started: number, signal?: AbortSignal) {
|
|
133
|
+
if (Date.now() - started >= LOCK_WAIT_MS) throw new PullRequestFailure('cache_busy', 'Pull request cache is busy; retry later');
|
|
134
|
+
await new Promise<void>((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, LOCK_RETRY_MS);
|
|
136
|
+
const abort = () => { clearTimeout(timer); reject(new PullRequestFailure('canceled', 'Pull request cache lock wait was canceled')); };
|
|
137
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Only remove a sufficiently old lock with a provably dead PID. A marker serializes
|
|
142
|
+
* recovery contenders, and inode/token checks ensure neither recovery nor release
|
|
143
|
+
* unlinks a replacement lock. */
|
|
144
|
+
export function recoverDeadOwner(lockPath: string, afterMarker?: () => void): boolean {
|
|
145
|
+
let observed;
|
|
146
|
+
let owner: { pid?: unknown; token?: unknown };
|
|
147
|
+
try {
|
|
148
|
+
observed = lstatSync(lockPath);
|
|
149
|
+
if (!observed.isFile() || observed.size > 1024 || Date.now() - observed.mtimeMs < LOCK_STALE_MS) return false;
|
|
150
|
+
owner = JSON.parse(readBoundedFile(lockPath, 1024).toString('utf8')) as { pid?: unknown; token?: unknown };
|
|
151
|
+
} catch { return false; }
|
|
152
|
+
if (!Number.isSafeInteger(owner.pid) || typeof owner.token !== 'string' || !owner.token) return false;
|
|
153
|
+
try { process.kill(owner.pid as number, 0); return false; }
|
|
154
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return false; }
|
|
155
|
+
const recoveryPath = `${lockPath}.recovering`;
|
|
156
|
+
const recoveryToken = randomUUID();
|
|
157
|
+
let recoveryFD: number;
|
|
158
|
+
try { recoveryFD = publishExclusive(recoveryPath, { pid: process.pid, token: recoveryToken, createdAt: new Date().toISOString() }); }
|
|
159
|
+
catch { return false; }
|
|
160
|
+
const recoveryStat = fstatSync(recoveryFD);
|
|
161
|
+
try {
|
|
162
|
+
afterMarker?.();
|
|
163
|
+
const current = lstatSync(lockPath);
|
|
164
|
+
const currentOwner = JSON.parse(readBoundedFile(lockPath, 1024).toString('utf8')) as { pid?: unknown; token?: unknown };
|
|
165
|
+
if (current.dev !== observed.dev || current.ino !== observed.ino || currentOwner.pid !== owner.pid || currentOwner.token !== owner.token || Date.now() - current.mtimeMs < LOCK_STALE_MS) return false;
|
|
166
|
+
try { process.kill(owner.pid as number, 0); return false; }
|
|
167
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return false; }
|
|
168
|
+
unlinkSync(lockPath);
|
|
169
|
+
return true;
|
|
170
|
+
} catch { return false; }
|
|
171
|
+
finally {
|
|
172
|
+
closeSync(recoveryFD);
|
|
173
|
+
try {
|
|
174
|
+
const marker = lstatSync(recoveryPath);
|
|
175
|
+
if (marker.dev === recoveryStat.dev && marker.ino === recoveryStat.ino) unlinkSync(recoveryPath);
|
|
176
|
+
} catch { /* Do not remove a marker replaced by another process. */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function readBoundedFile(path: string, maximum: number, afterOpen?: () => void): Buffer {
|
|
181
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
182
|
+
const fd = openSync(path, constants.O_RDONLY | noFollow);
|
|
183
|
+
try {
|
|
184
|
+
const opened = fstatSync(fd);
|
|
185
|
+
afterOpen?.();
|
|
186
|
+
const named = lstatSync(path);
|
|
187
|
+
if (!opened.isFile() || named.isSymbolicLink() || opened.dev !== named.dev || opened.ino !== named.ino) throw new PullRequestFailure('cache_invalid', 'Pull request cache target changed while opening');
|
|
188
|
+
if (opened.size > maximum) throw new PullRequestFailure('cache_limit', `Pull request cache exceeds the ${maximum} byte read limit`);
|
|
189
|
+
const buffer = Buffer.alloc(maximum + 1);
|
|
190
|
+
let count = 0;
|
|
191
|
+
while (count < buffer.length) {
|
|
192
|
+
const read = readSync(fd, buffer, count, buffer.length - count, count);
|
|
193
|
+
if (read === 0) break;
|
|
194
|
+
count += read;
|
|
195
|
+
}
|
|
196
|
+
if (count > maximum) throw new PullRequestFailure('cache_limit', `Pull request cache exceeds the ${maximum} byte read limit`);
|
|
197
|
+
return buffer.subarray(0, count);
|
|
198
|
+
} finally { closeSync(fd); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validPR(value: unknown, expectedNumber: number): PRJson {
|
|
202
|
+
if (!value || typeof value !== 'object') throw new Error('GitHub returned malformed pull request metadata');
|
|
203
|
+
const pr = value as PRJson;
|
|
204
|
+
if (pr.number !== expectedNumber || typeof pr.html_url !== 'string' || !/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(pr.html_url) || !['open', 'closed'].includes(String(pr.state)) || typeof pr.draft !== 'boolean' || typeof pr.head?.sha !== 'string' || !/^[a-f0-9]{40,64}$/i.test(pr.head.sha) || typeof pr.head.ref !== 'string' || typeof pr.base?.sha !== 'string' || !/^[a-f0-9]{40,64}$/i.test(pr.base.sha) || typeof pr.base.ref !== 'string' || typeof pr.head.repo?.full_name !== 'string' || typeof pr.base.repo?.full_name !== 'string') {
|
|
205
|
+
throw new Error('GitHub returned incomplete or malformed pull request metadata');
|
|
206
|
+
}
|
|
207
|
+
return pr;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function defaultRun(command: string, args: string[], cwd: string, signal?: AbortSignal): Promise<string> {
|
|
211
|
+
const result = await execFileAsync(command, args, { cwd, encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, timeout: 30_000, signal });
|
|
212
|
+
return result.stdout;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Explicit-only GitHub reads and a task-worktree scoped durable cache. */
|
|
216
|
+
export async function registerPullRequests(ctx: Context, options: PullRequestOptions = {}) {
|
|
217
|
+
const root = canonicalDirectory(ctx.location.project.canonical);
|
|
218
|
+
const projectID = ctx.location.project.id;
|
|
219
|
+
const run = options.run ?? defaultRun;
|
|
220
|
+
const taskRecords = () => listTasks({ canonical: root, includeOperations: false }) as unknown as TaskRecord[];
|
|
221
|
+
const taskFor = async (sessionID: string, taskID: string, signal?: AbortSignal) => {
|
|
222
|
+
signal?.throwIfAborted();
|
|
223
|
+
const task = taskRecords().find(item => item.id === taskID && item.sessionID === sessionID);
|
|
224
|
+
if (!task) throw new PullRequestFailure('not_owned', 'Task/session association is not owned by this workspace');
|
|
225
|
+
const session = await ctx.session.get({ sessionID }, { signal });
|
|
226
|
+
let directory: string;
|
|
227
|
+
let sessionDirectory: string;
|
|
228
|
+
try {
|
|
229
|
+
directory = canonicalDirectory(task.worktree);
|
|
230
|
+
sessionDirectory = canonicalDirectory(session.location.directory);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new PullRequestFailure('worktree_unavailable', 'Task worktree is unavailable');
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
if (session.projectID !== projectID || sessionDirectory !== directory) throw new PullRequestFailure('not_owned', 'Session does not own the recorded task worktree');
|
|
236
|
+
if (directory === root) throw new PullRequestFailure('not_owned', 'Task worktree path is not an exact owned directory');
|
|
237
|
+
const journalPath = join(directory, '.harness', 'pull-requests.json');
|
|
238
|
+
const lockPath = join(directory, '.harness', 'pull-requests.lock');
|
|
239
|
+
if (existsSync(join(directory, '.harness')) && realpathSync(join(directory, '.harness')) !== join(directory, '.harness')) throw new PullRequestFailure('cache_invalid', 'Task PR cache directory resolves outside its owned worktree');
|
|
240
|
+
if (existsSync(journalPath) && lstatSync(journalPath).isSymbolicLink()) throw new PullRequestFailure('cache_invalid', 'Task PR cache file cannot be a symbolic link');
|
|
241
|
+
if (existsSync(lockPath) && lstatSync(lockPath).isSymbolicLink()) throw new PullRequestFailure('cache_invalid', 'Task PR lock file cannot be a symbolic link');
|
|
242
|
+
return { task, directory, journalPath, lockPath };
|
|
243
|
+
};
|
|
244
|
+
const read = (path: string, taskID: string): PullRequestRecord[] => {
|
|
245
|
+
if (!existsSync(path)) return [];
|
|
246
|
+
let value: unknown;
|
|
247
|
+
try {
|
|
248
|
+
const contents = readBoundedFile(path, MAX_CACHE_BYTES);
|
|
249
|
+
value = JSON.parse(contents.toString('utf8'));
|
|
250
|
+
} catch (error) { if (error instanceof PullRequestFailure) throw error; throw new PullRequestFailure('cache_invalid', 'Pull request cache is invalid JSON'); }
|
|
251
|
+
if (!value || typeof value !== 'object' || (value as { version?: unknown }).version !== 1 || !Array.isArray((value as { pullRequests?: unknown }).pullRequests)) throw new PullRequestFailure('cache_invalid', 'Pull request cache has an invalid schema');
|
|
252
|
+
const records = (value as { pullRequests: unknown[] }).pullRequests;
|
|
253
|
+
if (records.length > MAX_ASSOCIATIONS) throw new PullRequestFailure('cache_limit', 'Pull request cache exceeds the 100 association limit');
|
|
254
|
+
for (const value of records) {
|
|
255
|
+
if (!value || typeof value !== 'object') throw new PullRequestFailure('cache_invalid', 'Pull request cache contains an invalid record');
|
|
256
|
+
const pr = value as Partial<PullRequestRecord>;
|
|
257
|
+
if (pr.taskID !== taskID || pr.taskID.length > 128 || !Number.isSafeInteger(pr.number) || typeof pr.url !== 'string' || pr.url.length > 2048 || !/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/.test(pr.url) || !['open', 'closed'].includes(String(pr.state)) || typeof pr.draft !== 'boolean' || typeof pr.head !== 'string' || !/^[a-f0-9]{40,64}$/i.test(pr.head) || typeof pr.headRef !== 'string' || !pr.headRef || pr.headRef.length > 255 || typeof pr.headRepository !== 'string' || pr.headRepository.length > 511 || !/^[\w.-]+\/[\w.-]+$/.test(pr.headRepository) || typeof pr.base !== 'string' || !/^[a-f0-9]{40,64}$/i.test(pr.base) || typeof pr.baseRef !== 'string' || !pr.baseRef || pr.baseRef.length > 255 || !['passed', 'failed', 'pending', 'partial', 'none'].includes(String(pr.checks)) || !['approved-unverified', 'changes-requested', 'unverified', 'unreviewed'].includes(String(pr.review)) || !['current', 'stale-source'].includes(String(pr.source)) || typeof pr.sourceSnapshot !== 'string' || !/^[a-f0-9]{40,64}$/i.test(pr.sourceSnapshot) || typeof pr.updatedAt !== 'string' || pr.updatedAt.length > 40 || !Number.isFinite(Date.parse(pr.updatedAt)) || !pr.url.endsWith(`/pull/${pr.number}`)) throw new PullRequestFailure(pr.taskID !== taskID ? 'not_owned' : 'cache_invalid', 'Pull request cache contains an invalid or foreign-task record');
|
|
258
|
+
}
|
|
259
|
+
return records as PullRequestRecord[];
|
|
260
|
+
};
|
|
261
|
+
const write = (path: string, pullRequests: PullRequestRecord[]) => {
|
|
262
|
+
if (pullRequests.length > MAX_ASSOCIATIONS) throw new PullRequestFailure('cache_limit', 'Task already has the maximum of 100 associated pull requests');
|
|
263
|
+
const directory = resolve(path, '..');
|
|
264
|
+
mkdirSync(directory, { recursive: true });
|
|
265
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
266
|
+
const serialized = `${JSON.stringify({ version: 1, pullRequests }, null, 2)}\n`;
|
|
267
|
+
if (Buffer.byteLength(serialized) > MAX_CACHE_BYTES) throw new PullRequestFailure('cache_limit', 'Pull request cache would exceed the 256 KiB limit');
|
|
268
|
+
writeFileSync(temporary, serialized, { mode: 0o600, flag: 'wx' });
|
|
269
|
+
renameSync(temporary, path);
|
|
270
|
+
};
|
|
271
|
+
const repository = async (directory: string) => {
|
|
272
|
+
const remote = (await run('git', ['remote', 'get-url', 'origin'], directory)).trim();
|
|
273
|
+
const match = remote.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
274
|
+
if (!match) throw new PullRequestFailure('github_invalid', 'Task worktree origin must be a GitHub repository');
|
|
275
|
+
return match[1];
|
|
276
|
+
};
|
|
277
|
+
const fetch = async (directory: string, repo: string, number: number, taskID: string, signal?: AbortSignal): Promise<PullRequestRecord> => {
|
|
278
|
+
signal?.throwIfAborted();
|
|
279
|
+
const requestedAt = new Date().toISOString();
|
|
280
|
+
const api = async (endpoint: string, paginate = false) => {
|
|
281
|
+
signal?.throwIfAborted();
|
|
282
|
+
let output: string;
|
|
283
|
+
try { output = await run('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), endpoint], directory, signal); }
|
|
284
|
+
catch (error) {
|
|
285
|
+
signal?.throwIfAborted();
|
|
286
|
+
throw new PullRequestFailure('github_unavailable', error instanceof Error ? error.message : 'GitHub CLI request failed');
|
|
287
|
+
}
|
|
288
|
+
signal?.throwIfAborted();
|
|
289
|
+
return JSON.parse(output) as unknown;
|
|
290
|
+
};
|
|
291
|
+
const pr = validPR(await api(`repos/${repo}/pulls/${number}`), number);
|
|
292
|
+
const baseRepo = String(pr.base!.repo!.full_name);
|
|
293
|
+
const headRepo = String(pr.head!.repo!.full_name);
|
|
294
|
+
if (baseRepo.toLowerCase() !== repo.toLowerCase() || !/^[\w.-]+\/[\w.-]+$/.test(headRepo)) throw new PullRequestFailure('github_invalid', 'Pull request base repository must match the task origin and head must be a GitHub repository');
|
|
295
|
+
if (pr.html_url !== `https://github.com/${baseRepo}/pull/${number}`) throw new PullRequestFailure('github_invalid', 'GitHub pull request URL does not match its repository and number');
|
|
296
|
+
const [checksPages, statusesPages, reviewsPages, currentHead] = await Promise.all([
|
|
297
|
+
api(`repos/${repo}/commits/${pr.head!.sha}/check-runs?per_page=100`, true),
|
|
298
|
+
api(`repos/${repo}/commits/${pr.head!.sha}/status?per_page=100`, true),
|
|
299
|
+
api(`repos/${repo}/pulls/${number}/reviews?per_page=100`, true),
|
|
300
|
+
run('git', ['rev-parse', 'HEAD'], directory),
|
|
301
|
+
]);
|
|
302
|
+
const pages = (value: unknown, label: string): unknown[] => {
|
|
303
|
+
if (!Array.isArray(value)) throw new Error(`GitHub returned malformed ${label} metadata`);
|
|
304
|
+
const unwrapped = value.every(Array.isArray) ? value.flat() : value;
|
|
305
|
+
if (!unwrapped.every(page => page && typeof page === 'object' && !Array.isArray(page))) throw new Error(`GitHub returned malformed ${label} metadata`);
|
|
306
|
+
return unwrapped;
|
|
307
|
+
};
|
|
308
|
+
const checks = pages(checksPages, 'checks').map(page => page as { check_runs?: unknown; total_count?: unknown });
|
|
309
|
+
const statuses = pages(statusesPages, 'statuses').map(page => page as { statuses?: unknown; state?: unknown; total_count?: unknown });
|
|
310
|
+
const reviewPages = pages(reviewsPages, 'reviews');
|
|
311
|
+
if (!checks.every(page => Array.isArray(page.check_runs) && Number.isInteger(page.total_count)) || !statuses.every(page => Array.isArray(page.statuses) && Number.isInteger(page.total_count))) throw new Error('GitHub returned malformed checks/status metadata');
|
|
312
|
+
const runs = checks.flatMap(page => page.check_runs as Array<{ status?: unknown; conclusion?: unknown }>);
|
|
313
|
+
const commitStatuses = statuses.flatMap(page => page.statuses as Array<{ state?: unknown }>);
|
|
314
|
+
const totalChecks = Number(checks[0]?.total_count ?? 0) + Number(statuses[0]?.total_count ?? 0);
|
|
315
|
+
const observedChecks = runs.length + commitStatuses.length;
|
|
316
|
+
const checkStatus = observedChecks < totalChecks ? 'partial' : observedChecks === 0 ? 'none' : runs.some(check => check.status !== 'completed') || commitStatuses.some(check => check.state === 'pending') ? 'pending' : runs.some(check => check.conclusion !== 'success') || commitStatuses.some(check => check.state !== 'success') ? 'failed' : 'passed';
|
|
317
|
+
const reviews = reviewPages as Array<{ state?: unknown; submitted_at?: unknown; commit_id?: unknown; user?: { login?: unknown } }>;
|
|
318
|
+
const sha = String(pr.head!.sha);
|
|
319
|
+
const latestByReviewer = new Map<string, typeof reviews[number]>();
|
|
320
|
+
for (const review of reviews.filter(item => item.commit_id === sha && typeof item.user?.login === 'string' && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(String(item.state))).sort((a, b) => String(a.submitted_at).localeCompare(String(b.submitted_at)))) latestByReviewer.set(review.user!.login as string, review);
|
|
321
|
+
const headReviews = [...latestByReviewer.values()];
|
|
322
|
+
const reviewStatus = headReviews.some(review => review.state === 'CHANGES_REQUESTED') ? 'changes-requested' : headReviews.some(review => review.state === 'APPROVED') ? 'approved-unverified' : reviews.some(review => ['APPROVED', 'CHANGES_REQUESTED'].includes(String(review.state))) ? 'unverified' : 'unreviewed';
|
|
323
|
+
const currentSource = localReviewSnapshot(directory);
|
|
324
|
+
const localHead = currentHead.trim();
|
|
325
|
+
return {
|
|
326
|
+
taskID, number, url: String(pr.html_url), state: String(pr.state), draft: pr.draft as boolean,
|
|
327
|
+
head: sha, headRef: String(pr.head!.ref), headRepository: headRepo, base: String(pr.base!.sha), baseRef: String(pr.base!.ref), checks: checkStatus, review: reviewStatus,
|
|
328
|
+
source: localHead === sha && currentSource.snapshot === localReviewSnapshot(directory).snapshot && !hasDirtySource(directory) ? 'current' : 'stale-source',
|
|
329
|
+
sourceSnapshot: currentSource.snapshot, updatedAt: requestedAt,
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const registration = await ctx.rpc.register(PullRequests, {
|
|
333
|
+
list: async (value, call) => {
|
|
334
|
+
try {
|
|
335
|
+
const input = value as { sessionID: string; taskID: string };
|
|
336
|
+
const owned = await taskFor(input.sessionID, input.taskID, call.signal);
|
|
337
|
+
const current = localReviewSnapshot(owned.directory);
|
|
338
|
+
const dirty = hasDirtySource(owned.directory);
|
|
339
|
+
const pullRequests = read(owned.journalPath, owned.task.id);
|
|
340
|
+
return { pullRequests: pullRequests.map(pr => ({ ...pr, taskID: owned.task.id, source: !dirty && current.snapshot === pr.sourceSnapshot && current.head === pr.head ? 'current' : 'stale-source' })) };
|
|
341
|
+
} catch (error) { return failure(call, error); }
|
|
342
|
+
},
|
|
343
|
+
associate: async (value, call) => {
|
|
344
|
+
try {
|
|
345
|
+
const input = value as { sessionID: string; taskID: string; number?: number };
|
|
346
|
+
if (!Number.isSafeInteger(input.number) || input.number! < 1) throw new PullRequestFailure('invalid_request', 'A positive pull request number is required');
|
|
347
|
+
const owned = await taskFor(input.sessionID, input.taskID, call.signal);
|
|
348
|
+
const repo = await repository(owned.directory);
|
|
349
|
+
const record = await fetch(owned.directory, repo, input.number!, owned.task.id, call.signal);
|
|
350
|
+
const unlock = await acquireLock(owned.lockPath, call.signal);
|
|
351
|
+
try {
|
|
352
|
+
const records = read(owned.journalPath, owned.task.id);
|
|
353
|
+
const previous = records.find(item => item.number === record.number);
|
|
354
|
+
const chosen = previous && Date.parse(previous.updatedAt) >= Date.parse(record.updatedAt) ? previous : record;
|
|
355
|
+
const merged = records.filter(item => item.number !== record.number);
|
|
356
|
+
if (!previous && merged.length >= MAX_ASSOCIATIONS) throw new PullRequestFailure('cache_limit', 'Task already has the maximum of 100 associated pull requests');
|
|
357
|
+
merged.push(chosen);
|
|
358
|
+
write(owned.journalPath, merged);
|
|
359
|
+
return { pullRequest: chosen };
|
|
360
|
+
} finally { unlock(); }
|
|
361
|
+
} catch (error) { return failure(call, error); }
|
|
362
|
+
},
|
|
363
|
+
refresh: async (value, call) => {
|
|
364
|
+
try {
|
|
365
|
+
const input = value as { sessionID: string; taskID: string; number?: number };
|
|
366
|
+
const owned = await taskFor(input.sessionID, input.taskID, call.signal);
|
|
367
|
+
const before = read(owned.journalPath, owned.task.id);
|
|
368
|
+
const number = input.number ?? before.at(-1)?.number;
|
|
369
|
+
if (!Number.isSafeInteger(number) || number! < 1 || !before.some(item => item.number === number)) throw new PullRequestFailure('not_associated', 'Pull request is not associated with this task');
|
|
370
|
+
const repo = await repository(owned.directory);
|
|
371
|
+
const record = await fetch(owned.directory, repo, number!, owned.task.id, call.signal);
|
|
372
|
+
const unlock = await acquireLock(owned.lockPath, call.signal);
|
|
373
|
+
try {
|
|
374
|
+
const records = read(owned.journalPath, owned.task.id);
|
|
375
|
+
const index = records.findIndex(item => item.number === number);
|
|
376
|
+
if (index < 0) throw new PullRequestFailure('not_associated', 'Pull request association was removed during refresh');
|
|
377
|
+
const current = records[index]!;
|
|
378
|
+
const chosen = Date.parse(current.updatedAt) >= Date.parse(record.updatedAt) ? current : record;
|
|
379
|
+
records[index] = chosen;
|
|
380
|
+
write(owned.journalPath, records);
|
|
381
|
+
return { pullRequest: chosen };
|
|
382
|
+
} finally { unlock(); }
|
|
383
|
+
} catch (error) { return failure(call, error); }
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
return () => registration.dispose();
|
|
387
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Context } from '@opencode/plugin/promise/plugin';
|
|
2
|
+
import { framePrompt, shouldFrame, unframe } from '../../../../lib/tui/framing.mjs';
|
|
3
|
+
import { SessionControls } from '../session-rpc';
|
|
4
|
+
import { effectiveSessionOptions } from '../../../../lib/opencode/session-options.mjs';
|
|
5
|
+
|
|
6
|
+
/** Admission framing is session-scoped; raw bypasses framing, never delivery policy. */
|
|
7
|
+
export async function registerSessionControls(ctx: Context) {
|
|
8
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
9
|
+
const pending = new Map<string, Promise<unknown>>();
|
|
10
|
+
const exclusive = async <T>(sessionID: string, action: () => Promise<T>): Promise<T> => {
|
|
11
|
+
const before = pending.get(sessionID) ?? Promise.resolve();
|
|
12
|
+
const current = before.catch(() => undefined).then(action);
|
|
13
|
+
pending.set(sessionID, current);
|
|
14
|
+
try { return await current; }
|
|
15
|
+
finally { if (pending.get(sessionID) === current) pending.delete(sessionID); }
|
|
16
|
+
};
|
|
17
|
+
const key = (sessionID: string) => `controls/${sessionID}`;
|
|
18
|
+
const settings = async (sessionID: string) => {
|
|
19
|
+
const value = await ctx.storage.get(key(sessionID));
|
|
20
|
+
if (value && typeof value === 'object' && 'framing' in value) return { framing: value.framing !== false };
|
|
21
|
+
const session = await ctx.session.get({ sessionID });
|
|
22
|
+
return { framing: effectiveSessionOptions(session, ctx.options).guide };
|
|
23
|
+
};
|
|
24
|
+
try {
|
|
25
|
+
const prompt = await ctx.session.hook('prompt', async (event) => {
|
|
26
|
+
const session = await ctx.session.get({ sessionID: event.sessionID });
|
|
27
|
+
const options = effectiveSessionOptions(session, ctx.options);
|
|
28
|
+
const saved = await settings(event.sessionID);
|
|
29
|
+
if (event.metadata?.['ctg.raw'] === true || event.metadata?.['ctg.framed'] === true) return;
|
|
30
|
+
if (!shouldFrame(event.prompt.text, {
|
|
31
|
+
enabled: saved.framing,
|
|
32
|
+
delivery: options.delivery,
|
|
33
|
+
}) || unframe(event.prompt.text) !== null) return;
|
|
34
|
+
const original = event.prompt.text;
|
|
35
|
+
const framed = framePrompt(original);
|
|
36
|
+
// Mentions refer to offsets in the original text. Drop only their display
|
|
37
|
+
// ranges after rewriting; keep resolved files/agents/skills attached.
|
|
38
|
+
for (const entries of [event.prompt.files, event.prompt.agents, event.prompt.skills]) {
|
|
39
|
+
for (const entry of entries ?? []) delete entry.mention;
|
|
40
|
+
}
|
|
41
|
+
event.prompt.text = framed;
|
|
42
|
+
event.metadata = { ...event.metadata, 'ctg.framed': true, 'ctg.original': original };
|
|
43
|
+
});
|
|
44
|
+
cleanups.push(() => prompt.dispose());
|
|
45
|
+
const registration = await ctx.rpc.register(SessionControls, {
|
|
46
|
+
framing: async (input, call) => {
|
|
47
|
+
const { sessionID, enabled } = input as { sessionID: string; enabled?: boolean };
|
|
48
|
+
return exclusive(sessionID, async () => {
|
|
49
|
+
call.signal.throwIfAborted();
|
|
50
|
+
const session = await ctx.session.get({ sessionID }, { signal: call.signal });
|
|
51
|
+
if (session.location.directory !== ctx.location.directory) return call.error('wrong_workspace', 'Session belongs to another workspace location', { sessionID });
|
|
52
|
+
const saved = await settings(sessionID);
|
|
53
|
+
const next = enabled ?? !saved.framing;
|
|
54
|
+
await ctx.storage.set(key(sessionID), { ...saved, framing: next });
|
|
55
|
+
return { enabled: next };
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
compact: async (input, call) => {
|
|
59
|
+
const { sessionID, notes } = input as { sessionID: string; notes?: string };
|
|
60
|
+
return exclusive(sessionID, async () => {
|
|
61
|
+
call.signal.throwIfAborted();
|
|
62
|
+
const session = await ctx.session.get({ sessionID }, { signal: call.signal });
|
|
63
|
+
if (session.location.directory !== ctx.location.directory) return call.error('wrong_workspace', 'Session belongs to another workspace location', { sessionID });
|
|
64
|
+
// The native compact API has no notes field. Queue a durable control
|
|
65
|
+
// message before it so notes survive disconnect and cannot leak to a
|
|
66
|
+
// different session through a process-global one-shot hook.
|
|
67
|
+
if (notes) await ctx.session.synthetic({
|
|
68
|
+
sessionID, delivery: 'queue', description: 'User compaction instructions',
|
|
69
|
+
text: `User instructions for the next context summary:\n${notes}`,
|
|
70
|
+
}, { signal: call.signal });
|
|
71
|
+
await ctx.session.compact({ sessionID, delivery: 'queue' }, { signal: call.signal });
|
|
72
|
+
return { admitted: true };
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
cleanups.push(() => registration.dispose());
|
|
77
|
+
} catch (error) {
|
|
78
|
+
await Promise.allSettled(cleanups.reverse().map((dispose) => dispose()));
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return async () => { for (const dispose of cleanups.reverse()) await dispose(); };
|
|
82
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Credential } from '@opencode/schema/credential';
|
|
3
|
+
import { packageVersion } from '../../../../lib/update.mjs';
|
|
4
|
+
import { shipInstallId } from '../../../../lib/ship-telemetry.mjs';
|
|
5
|
+
|
|
6
|
+
const RUN_MODES = new Set(['interactive', 'print', 'rpc']);
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
|
|
9
|
+
/** OpenCode session IDs are opaque strings, not necessarily UUIDs. Use a
|
|
10
|
+
* process-secret keyed digest to derive stable UUID-shaped CTG thread IDs
|
|
11
|
+
* without leaking session identifiers to the gateway. */
|
|
12
|
+
export function createSessionIdentity({ id = randomUUID, secret = randomUUID() } = {}) {
|
|
13
|
+
const threads = new Map<string, string>();
|
|
14
|
+
const requests = new Map<string, number>();
|
|
15
|
+
return {
|
|
16
|
+
thread(sessionID: string) {
|
|
17
|
+
const key = String(sessionID || '');
|
|
18
|
+
if (!key) return id();
|
|
19
|
+
let value = threads.get(key);
|
|
20
|
+
if (!value) {
|
|
21
|
+
const digest = createHash('sha256').update(`${secret}\0${key}`).digest('hex').slice(0, 32);
|
|
22
|
+
value = `${digest.slice(0, 8)}-${digest.slice(8, 12)}-4${digest.slice(13, 16)}-a${digest.slice(17, 20)}-${digest.slice(20, 32)}`;
|
|
23
|
+
threads.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
},
|
|
27
|
+
request(sessionID: string, kind: string) {
|
|
28
|
+
const key = String(sessionID || '');
|
|
29
|
+
const next = (requests.get(key) || 0) + 1;
|
|
30
|
+
requests.set(key, next);
|
|
31
|
+
return createHash('sha256').update(`${secret}\0${key}\0${kind}\0${next}`).digest('hex');
|
|
32
|
+
},
|
|
33
|
+
turn(sessionID: string) { return requests.get(String(sessionID || '')) || 0; },
|
|
34
|
+
forget(sessionID: string) {
|
|
35
|
+
const key = String(sessionID || '');
|
|
36
|
+
threads.delete(key);
|
|
37
|
+
requests.delete(key);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Stable fallback request key derived from session, kind, and serialized
|
|
43
|
+
* request. Native managed calls use the lifecycle-scoped identity below. */
|
|
44
|
+
export function managedRequestIdentity({ sessionID, kind, body, secret = '' }: {
|
|
45
|
+
sessionID: string; kind: string; body: string; secret?: string;
|
|
46
|
+
}) {
|
|
47
|
+
return createHash('sha256').update(`${secret}\0${sessionID}\0${kind}\0${body}`).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function shipTelemetryHeaders({
|
|
51
|
+
sessionID,
|
|
52
|
+
kind,
|
|
53
|
+
body,
|
|
54
|
+
env = process.env,
|
|
55
|
+
agentDir,
|
|
56
|
+
identity = createSessionIdentity(),
|
|
57
|
+
requestSecret = 'carthagent-opencode-request-v1',
|
|
58
|
+
requestID,
|
|
59
|
+
turn,
|
|
60
|
+
version = packageVersion(),
|
|
61
|
+
}: {
|
|
62
|
+
sessionID: string; kind: string; body: string; env?: NodeJS.ProcessEnv;
|
|
63
|
+
agentDir?: string; identity?: ReturnType<typeof createSessionIdentity>;
|
|
64
|
+
requestSecret?: string; version?: string;
|
|
65
|
+
requestID?: string;
|
|
66
|
+
turn?: number;
|
|
67
|
+
}) {
|
|
68
|
+
const headers: Record<string, string> = {
|
|
69
|
+
'x-carthagent-install-id': shipInstallId({ agentDir }),
|
|
70
|
+
'x-carthagent-version': version || '',
|
|
71
|
+
'x-carthagent-os': process.platform,
|
|
72
|
+
};
|
|
73
|
+
const generatedRequestID = requestID || identity.request(sessionID, kind);
|
|
74
|
+
headers['x-carthagent-thread-id'] = UUID.test(String(sessionID))
|
|
75
|
+
? String(sessionID).toLowerCase() : identity.thread(sessionID);
|
|
76
|
+
headers['x-carthagent-turn'] = String(Math.max(0, Math.trunc(turn ?? identity.turn(sessionID))));
|
|
77
|
+
const runMode = String(env.CARTHAGENT_RUN_MODE || '').trim();
|
|
78
|
+
if (RUN_MODES.has(runMode)) headers['x-carthagent-run-mode'] = runMode;
|
|
79
|
+
headers['x-carthagent-request-id'] = requestID || generatedRequestID || managedRequestIdentity({ sessionID, kind, body, secret: requestSecret });
|
|
80
|
+
return headers;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isManagedShipCredential(credential: Credential.Value | undefined) {
|
|
84
|
+
if (credential?.type !== 'oauth' || typeof credential.access !== 'string') return false;
|
|
85
|
+
const parts = credential.access.split('.');
|
|
86
|
+
if (parts.length !== 3) return false;
|
|
87
|
+
try {
|
|
88
|
+
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
89
|
+
return typeof claims.sub === 'string' && typeof claims.sid === 'string'
|
|
90
|
+
&& String(claims.scope || '').split(' ').includes('gateway:invoke');
|
|
91
|
+
} catch { return false; }
|
|
92
|
+
}
|