codeply-cli 0.5.1 → 0.5.3
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/THIRD_PARTY_NOTICES.md +65 -63
- package/bin/codeply.js +1 -1
- package/lib/agent.mjs +133 -24
- package/lib/ai.js +268 -9
- package/lib/ai.mjs +6 -6
- package/lib/apply-patch.js +217 -217
- package/lib/arity-table.json +138 -138
- package/lib/arity.js +70 -70
- package/lib/auth.mjs +6 -6
- package/lib/bots.js +82 -17
- package/lib/chatgpt.js +282 -0
- package/lib/cloud-browser.mjs +143 -143
- package/lib/cloud.mjs +1250 -1144
- package/lib/commands.js +103 -103
- package/lib/config.js +83 -3
- package/lib/design-library/assemble-artifact.mjs +21 -21
- package/lib/design-library/cleanup.mjs +45 -45
- package/lib/design-library/index.json +22379 -22379
- package/lib/design-library/make-table-data.mjs +39 -39
- package/lib/diagnostics.js +192 -192
- package/lib/edit-engine.js +364 -364
- package/lib/github-agent.mjs +311 -311
- package/lib/history.js +75 -75
- package/lib/mail-watch.js +504 -0
- package/lib/mcp.js +258 -258
- package/lib/model-sync.js +114 -0
- package/lib/native-tools.mjs +16 -1
- package/lib/oauth-connectors.js +146 -24
- package/lib/permissions.js +109 -109
- package/lib/plugins.js +353 -353
- package/lib/publish.js +916 -0
- package/lib/reach.js +67 -0
- package/lib/server.mjs +457 -457
- package/lib/session-db.js +220 -220
- package/lib/share.js +168 -168
- package/lib/skills.js +2 -0
- package/lib/snapshot.js +146 -146
- package/lib/telemetry.js +102 -0
- package/lib/terminal-hints.js +154 -154
- package/lib/tools.mjs +316 -14
- package/lib/typecheck-worker.js +147 -147
- package/lib/web-tools.js +209 -158
- package/package.json +49 -49
- package/skills/SOURCE.md +58 -58
- package/skills/accessibility/SKILL.md +147 -147
- package/skills/agent-architecture-audit/SKILL.md +257 -257
- package/skills/agent-eval/SKILL.md +147 -147
- package/skills/agent-harness-construction/SKILL.md +74 -74
- package/skills/agent-introspection-debugging/SKILL.md +154 -154
- package/skills/agent-payment-x402/SKILL.md +225 -225
- package/skills/agent-self-evaluation/SKILL.md +182 -182
- package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -71
- package/skills/agent-self-evaluation/references/hook-integration.md +64 -64
- package/skills/agent-sort/SKILL.md +216 -216
- package/skills/agentic-engineering/SKILL.md +64 -64
- package/skills/agentic-os/SKILL.md +388 -388
- package/skills/ai-first-engineering/SKILL.md +52 -52
- package/skills/ai-regression-testing/SKILL.md +386 -386
- package/skills/android-clean-architecture/SKILL.md +340 -340
- package/skills/angular-developer/SKILL.md +155 -155
- package/skills/angular-developer/references/angular-animations.md +160 -160
- package/skills/angular-developer/references/angular-aria.md +410 -410
- package/skills/angular-developer/references/cli.md +86 -86
- package/skills/angular-developer/references/component-harnesses.md +59 -59
- package/skills/angular-developer/references/component-styling.md +91 -91
- package/skills/angular-developer/references/components.md +117 -117
- package/skills/angular-developer/references/creating-services.md +97 -97
- package/skills/angular-developer/references/data-resolvers.md +69 -69
- package/skills/angular-developer/references/define-routes.md +67 -67
- package/skills/angular-developer/references/defining-providers.md +72 -72
- package/skills/angular-developer/references/di-fundamentals.md +120 -120
- package/skills/angular-developer/references/e2e-testing.md +56 -56
- package/skills/angular-developer/references/effects.md +83 -83
- package/skills/angular-developer/references/hierarchical-injectors.md +43 -43
- package/skills/angular-developer/references/host-elements.md +80 -80
- package/skills/angular-developer/references/injection-context.md +63 -63
- package/skills/angular-developer/references/inputs.md +101 -101
- package/skills/angular-developer/references/linked-signal.md +59 -59
- package/skills/angular-developer/references/loading-strategies.md +61 -61
- package/skills/angular-developer/references/mcp.md +108 -108
- package/skills/angular-developer/references/navigate-to-routes.md +69 -69
- package/skills/angular-developer/references/outputs.md +86 -86
- package/skills/angular-developer/references/reactive-forms.md +122 -122
- package/skills/angular-developer/references/rendering-strategies.md +44 -44
- package/skills/angular-developer/references/resource.md +77 -77
- package/skills/angular-developer/references/route-animations.md +56 -56
- package/skills/angular-developer/references/route-guards.md +52 -52
- package/skills/angular-developer/references/router-lifecycle.md +45 -45
- package/skills/angular-developer/references/router-testing.md +87 -87
- package/skills/angular-developer/references/show-routes-with-outlets.md +68 -68
- package/skills/angular-developer/references/signal-forms.md +795 -795
- package/skills/angular-developer/references/signals-overview.md +94 -94
- package/skills/angular-developer/references/tailwind-css.md +69 -69
- package/skills/angular-developer/references/template-driven-forms.md +114 -114
- package/skills/angular-developer/references/testing-fundamentals.md +65 -65
- package/skills/api-connector-builder/SKILL.md +121 -121
- package/skills/api-design/SKILL.md +524 -524
- package/skills/architecture-decision-records/SKILL.md +180 -180
- package/skills/article-writing/SKILL.md +80 -80
- package/skills/automation-audit-ops/SKILL.md +143 -143
- package/skills/autonomous-agent-harness/SKILL.md +274 -274
- package/skills/autonomous-loops/SKILL.md +611 -611
- package/skills/backend-patterns/SKILL.md +562 -562
- package/skills/benchmark/SKILL.md +95 -95
- package/skills/benchmark-methodology/SKILL.md +191 -191
- package/skills/benchmark-optimization-loop/SKILL.md +71 -71
- package/skills/blender-motion-state-inspection/SKILL.md +165 -165
- package/skills/blueprint/SKILL.md +106 -106
- package/skills/brand-discovery/SKILL.md +145 -145
- package/skills/brand-discovery/references/10_purpose-why.md +40 -40
- package/skills/brand-discovery/references/20_positioning.md +44 -44
- package/skills/brand-discovery/references/30_audience-niche.md +52 -52
- package/skills/brand-discovery/references/40_personality-archetype.md +57 -57
- package/skills/brand-discovery/references/50_voice-tone.md +59 -59
- package/skills/brand-discovery/references/60_narrative-story.md +50 -50
- package/skills/brand-discovery/references/70_founder-tension.md +49 -49
- package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -133
- package/skills/brand-voice/SKILL.md +98 -98
- package/skills/brand-voice/references/voice-profile-schema.md +55 -55
- package/skills/browser-qa/SKILL.md +105 -105
- package/skills/bun-runtime/SKILL.md +85 -85
- package/skills/canary-watch/SKILL.md +108 -108
- package/skills/carrier-relationship-management/SKILL.md +212 -212
- package/skills/cisco-ios-patterns/SKILL.md +164 -164
- package/skills/ck/SKILL.md +148 -148
- package/skills/claude-devfleet/SKILL.md +112 -112
- package/skills/click-path-audit/SKILL.md +245 -245
- package/skills/clickhouse-io/SKILL.md +445 -445
- package/skills/code-tour/SKILL.md +254 -254
- package/skills/codebase-onboarding/SKILL.md +234 -234
- package/skills/codehealth-mcp/SKILL.md +167 -167
- package/skills/coding-standards/SKILL.md +551 -551
- package/skills/competitive-platform-analysis/SKILL.md +214 -214
- package/skills/competitive-report-structure/SKILL.md +162 -162
- package/skills/compose-multiplatform-patterns/SKILL.md +300 -300
- package/skills/config-gc/SKILL.md +120 -120
- package/skills/configure-ecc/SKILL.md +385 -385
- package/skills/connections-optimizer/SKILL.md +190 -190
- package/skills/content-engine/SKILL.md +132 -132
- package/skills/content-hash-cache-pattern/SKILL.md +162 -162
- package/skills/context-budget/SKILL.md +136 -136
- package/skills/continuous-agent-loop/SKILL.md +46 -46
- package/skills/continuous-learning/SKILL.md +132 -132
- package/skills/continuous-learning-v2/SKILL.md +361 -361
- package/skills/contract-first/SKILL.md +287 -287
- package/skills/cost-aware-llm-pipeline/SKILL.md +184 -184
- package/skills/cost-tracking/SKILL.md +97 -97
- package/skills/council/SKILL.md +204 -204
- package/skills/cpp-coding-standards/SKILL.md +724 -724
- package/skills/cpp-testing/SKILL.md +325 -325
- package/skills/crosspost/SKILL.md +112 -112
- package/skills/csharp-testing/SKILL.md +322 -322
- package/skills/customer-billing-ops/SKILL.md +141 -141
- package/skills/customs-trade-compliance/SKILL.md +263 -263
- package/skills/dart-flutter-patterns/SKILL.md +564 -564
- package/skills/dashboard-builder/SKILL.md +109 -109
- package/skills/data-scraper-agent/SKILL.md +765 -765
- package/skills/data-throughput-accelerator/SKILL.md +74 -74
- package/skills/database-migrations/SKILL.md +430 -430
- package/skills/deep-research/SKILL.md +160 -160
- package/skills/defi-amm-security/SKILL.md +167 -167
- package/skills/delivery-gate/SKILL.md +126 -126
- package/skills/deployment-patterns/SKILL.md +428 -428
- package/skills/design-system/SKILL.md +83 -83
- package/skills/django-celery/SKILL.md +458 -458
- package/skills/django-patterns/SKILL.md +735 -735
- package/skills/django-security/SKILL.md +644 -644
- package/skills/django-tdd/SKILL.md +730 -730
- package/skills/django-verification/SKILL.md +470 -470
- package/skills/dmux-workflows/SKILL.md +192 -192
- package/skills/docker-patterns/SKILL.md +365 -365
- package/skills/documentation-lookup/SKILL.md +91 -91
- package/skills/dotnet-patterns/SKILL.md +322 -322
- package/skills/dynamic-workflow-mode/SKILL.md +124 -124
- package/skills/e2e-testing/SKILL.md +327 -327
- package/skills/ecc-guide/SKILL.md +190 -190
- package/skills/ecc-recipes/SKILL.md +149 -149
- package/skills/ecc-tools-cost-audit/SKILL.md +161 -161
- package/skills/email-ops/SKILL.md +122 -122
- package/skills/energy-procurement/SKILL.md +228 -228
- package/skills/enterprise-agent-ops/SKILL.md +51 -51
- package/skills/error-handling/SKILL.md +377 -377
- package/skills/eval-harness/SKILL.md +271 -271
- package/skills/evm-token-decimals/SKILL.md +131 -131
- package/skills/exa-search/SKILL.md +108 -108
- package/skills/fal-ai-media/SKILL.md +289 -289
- package/skills/fastapi-patterns/SKILL.md +514 -514
- package/skills/finance-billing-ops/SKILL.md +128 -128
- package/skills/flox-environments/SKILL.md +497 -497
- package/skills/flutter-dart-code-review/SKILL.md +436 -436
- package/skills/foundation-models-on-device/SKILL.md +243 -243
- package/skills/frontend-a11y/SKILL.md +446 -446
- package/skills/frontend-design-direction/SKILL.md +93 -93
- package/skills/frontend-patterns/SKILL.md +657 -657
- package/skills/frontend-slides/SKILL.md +185 -185
- package/skills/fsharp-testing/SKILL.md +281 -281
- package/skills/gan-style-harness/SKILL.md +279 -279
- package/skills/gateguard/SKILL.md +133 -133
- package/skills/generating-python-installer/SKILL.md +820 -820
- package/skills/git-workflow/SKILL.md +716 -716
- package/skills/github-ops/SKILL.md +145 -145
- package/skills/golang-patterns/SKILL.md +676 -676
- package/skills/golang-testing/SKILL.md +721 -721
- package/skills/google-workspace-ops/SKILL.md +96 -96
- package/skills/growth-log/SKILL.md +128 -128
- package/skills/healthcare-cdss-patterns/SKILL.md +246 -246
- package/skills/healthcare-emr-patterns/SKILL.md +160 -160
- package/skills/healthcare-eval-harness/SKILL.md +208 -208
- package/skills/healthcare-phi-compliance/SKILL.md +146 -146
- package/skills/hermes-imports/SKILL.md +89 -89
- package/skills/hexagonal-architecture/SKILL.md +277 -277
- package/skills/hipaa-compliance/SKILL.md +79 -79
- package/skills/homelab-network-readiness/SKILL.md +170 -170
- package/skills/homelab-network-setup/SKILL.md +130 -130
- package/skills/homelab-pihole-dns/SKILL.md +275 -275
- package/skills/homelab-vlan-segmentation/SKILL.md +312 -312
- package/skills/homelab-wireguard-vpn/SKILL.md +306 -306
- package/skills/hookify-rules/SKILL.md +128 -128
- package/skills/inherit-legacy-style/SKILL.md +157 -157
- package/skills/intent-driven-development/SKILL.md +360 -360
- package/skills/inventory-demand-planning/SKILL.md +247 -247
- package/skills/investor-materials/SKILL.md +97 -97
- package/skills/investor-outreach/SKILL.md +92 -92
- package/skills/ios-icon-gen/SKILL.md +158 -158
- package/skills/iterative-retrieval/SKILL.md +212 -212
- package/skills/ito-basket-compare/SKILL.md +64 -64
- package/skills/ito-compute/SKILL.md +129 -129
- package/skills/ito-data-atlas-agent/SKILL.md +64 -64
- package/skills/ito-market-intelligence/SKILL.md +61 -61
- package/skills/ito-trade-planner/SKILL.md +68 -68
- package/skills/java-coding-standards/SKILL.md +384 -384
- package/skills/jira-integration/SKILL.md +303 -303
- package/skills/jpa-patterns/SKILL.md +152 -152
- package/skills/knowledge-ops/SKILL.md +155 -155
- package/skills/kotlin-coroutines-flows/SKILL.md +285 -285
- package/skills/kotlin-exposed-patterns/SKILL.md +720 -720
- package/skills/kotlin-ktor-patterns/SKILL.md +690 -690
- package/skills/kotlin-patterns/SKILL.md +712 -712
- package/skills/kotlin-testing/SKILL.md +825 -825
- package/skills/kubernetes-patterns/SKILL.md +756 -756
- package/skills/laravel-patterns/SKILL.md +416 -416
- package/skills/laravel-plugin-discovery/SKILL.md +230 -230
- package/skills/laravel-security/SKILL.md +948 -948
- package/skills/laravel-tdd/SKILL.md +675 -675
- package/skills/laravel-verification/SKILL.md +180 -180
- package/skills/latency-critical-systems/SKILL.md +75 -75
- package/skills/lead-intelligence/SKILL.md +322 -322
- package/skills/liquid-glass-design/SKILL.md +279 -279
- package/skills/llm-trading-agent-security/SKILL.md +147 -147
- package/skills/logistics-exception-management/SKILL.md +222 -222
- package/skills/loop-design-check/SKILL.md +143 -143
- package/skills/mailtrap-email-integration/SKILL.md +77 -77
- package/skills/make-interfaces-feel-better/SKILL.md +152 -152
- package/skills/manim-video/SKILL.md +90 -90
- package/skills/market-research/SKILL.md +76 -76
- package/skills/marketing-campaign/SKILL.md +114 -114
- package/skills/mcp-server-patterns/SKILL.md +70 -70
- package/skills/messages-ops/SKILL.md +105 -105
- package/skills/ml-adoption-playbook/SKILL.md +57 -57
- package/skills/mle-workflow/SKILL.md +348 -348
- package/skills/motion-advanced/SKILL.md +596 -596
- package/skills/motion-foundations/SKILL.md +299 -299
- package/skills/motion-patterns/SKILL.md +434 -434
- package/skills/motion-ui/SKILL.md +576 -576
- package/skills/mysql-patterns/SKILL.md +413 -413
- package/skills/nanoclaw-repl/SKILL.md +34 -34
- package/skills/nestjs-patterns/SKILL.md +231 -231
- package/skills/netmiko-ssh-automation/SKILL.md +174 -174
- package/skills/network-bgp-diagnostics/SKILL.md +168 -168
- package/skills/network-config-validation/SKILL.md +211 -211
- package/skills/network-interface-health/SKILL.md +153 -153
- package/skills/nextjs-turbopack/SKILL.md +58 -58
- package/skills/nodejs-keccak256/SKILL.md +103 -103
- package/skills/nutrient-document-processing/SKILL.md +168 -168
- package/skills/nuxt4-patterns/SKILL.md +101 -101
- package/skills/openclaw-persona-forge/SKILL.md +289 -289
- package/skills/openclaw-persona-forge/references/avatar-style.md +124 -124
- package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -53
- package/skills/openclaw-persona-forge/references/error-handling.md +53 -53
- package/skills/openclaw-persona-forge/references/identity-tension.md +48 -48
- package/skills/openclaw-persona-forge/references/naming-system.md +39 -39
- package/skills/openclaw-persona-forge/references/output-template.md +166 -166
- package/skills/opensource-pipeline/SKILL.md +256 -256
- package/skills/orch-add-feature/SKILL.md +45 -45
- package/skills/orch-build-mvp/SKILL.md +49 -49
- package/skills/orch-change-feature/SKILL.md +43 -43
- package/skills/orch-fix-defect/SKILL.md +43 -43
- package/skills/orch-pipeline/SKILL.md +121 -121
- package/skills/orch-refine-code/SKILL.md +44 -44
- package/skills/parallel-execution-optimizer/SKILL.md +74 -74
- package/skills/perl-patterns/SKILL.md +505 -505
- package/skills/perl-security/SKILL.md +504 -504
- package/skills/perl-testing/SKILL.md +476 -476
- package/skills/plan-canvas/SKILL.md +153 -153
- package/skills/plan-orchestrate/SKILL.md +263 -263
- package/skills/plankton-code-quality/SKILL.md +237 -237
- package/skills/postgres-patterns/SKILL.md +148 -148
- package/skills/prediction-market-oracle-research/SKILL.md +64 -64
- package/skills/prediction-market-risk-review/SKILL.md +61 -61
- package/skills/premium-web-design/SKILL.md +154 -154
- package/skills/prisma-patterns/SKILL.md +401 -401
- package/skills/product-capability/SKILL.md +142 -142
- package/skills/product-lens/SKILL.md +93 -93
- package/skills/production-audit/SKILL.md +207 -207
- package/skills/production-scheduling/SKILL.md +238 -238
- package/skills/project-flow-ops/SKILL.md +112 -112
- package/skills/prompt-optimizer/SKILL.md +398 -398
- package/skills/publish-website/SKILL.md +76 -0
- package/skills/python-patterns/SKILL.md +751 -751
- package/skills/python-testing/SKILL.md +817 -817
- package/skills/pytorch-patterns/SKILL.md +397 -397
- package/skills/quality-nonconformance/SKILL.md +260 -260
- package/skills/quarkus-patterns/SKILL.md +723 -723
- package/skills/quarkus-security/SKILL.md +468 -468
- package/skills/quarkus-tdd/SKILL.md +812 -812
- package/skills/quarkus-verification/SKILL.md +480 -480
- package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -68
- package/skills/react-native-patterns/SKILL.md +326 -326
- package/skills/react-patterns/SKILL.md +342 -342
- package/skills/react-performance/SKILL.md +575 -575
- package/skills/react-testing/SKILL.md +424 -424
- package/skills/recsys-pipeline-architect/SKILL.md +115 -115
- package/skills/recursive-decision-ledger/SKILL.md +81 -81
- package/skills/redis-patterns/SKILL.md +404 -404
- package/skills/regex-vs-llm-structured-text/SKILL.md +221 -221
- package/skills/remotion-video-creation/SKILL.md +43 -43
- package/skills/repo-scan/SKILL.md +79 -79
- package/skills/research-ops/SKILL.md +113 -113
- package/skills/returns-reverse-logistics/SKILL.md +240 -240
- package/skills/rules-distill/SKILL.md +265 -265
- package/skills/rust-patterns/SKILL.md +500 -500
- package/skills/rust-testing/SKILL.md +501 -501
- package/skills/safety-guard/SKILL.md +76 -76
- package/skills/santa-method/SKILL.md +307 -307
- package/skills/scientific-db-pubmed-database/SKILL.md +176 -176
- package/skills/scientific-db-uspto-database/SKILL.md +178 -178
- package/skills/scientific-pkg-gget/SKILL.md +167 -167
- package/skills/scientific-thinking-literature-review/SKILL.md +193 -193
- package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -161
- package/skills/search-first/SKILL.md +183 -183
- package/skills/security-bounty-hunter/SKILL.md +100 -100
- package/skills/security-review/SKILL.md +504 -504
- package/skills/security-scan/SKILL.md +166 -166
- package/skills/seo/SKILL.md +155 -155
- package/skills/skill-comply/SKILL.md +59 -59
- package/skills/skill-scout/SKILL.md +141 -141
- package/skills/skill-stocktake/SKILL.md +195 -195
- package/skills/social-graph-ranker/SKILL.md +155 -155
- package/skills/social-publisher/SKILL.md +130 -130
- package/skills/springboot-patterns/SKILL.md +315 -315
- package/skills/springboot-security/SKILL.md +273 -273
- package/skills/springboot-tdd/SKILL.md +159 -159
- package/skills/springboot-verification/SKILL.md +232 -232
- package/skills/strategic-compact/SKILL.md +142 -142
- package/skills/swift-actor-persistence/SKILL.md +144 -144
- package/skills/swift-concurrency-6-2/SKILL.md +216 -216
- package/skills/swift-protocol-di-testing/SKILL.md +191 -191
- package/skills/swiftui-patterns/SKILL.md +259 -259
- package/skills/taste/SKILL.md +264 -264
- package/skills/taste/references/genre-taxonomy.md +87 -87
- package/skills/tdd-workflow/SKILL.md +583 -583
- package/skills/team-agent-orchestration/SKILL.md +111 -111
- package/skills/team-builder/SKILL.md +169 -169
- package/skills/terminal-ops/SKILL.md +110 -110
- package/skills/tinystruct-patterns/SKILL.md +279 -279
- package/skills/tinystruct-patterns/references/architecture.md +90 -90
- package/skills/tinystruct-patterns/references/data-handling.md +60 -60
- package/skills/tinystruct-patterns/references/database.md +99 -99
- package/skills/tinystruct-patterns/references/routing.md +64 -64
- package/skills/tinystruct-patterns/references/system-usage.md +97 -97
- package/skills/tinystruct-patterns/references/testing.md +72 -72
- package/skills/token-budget-advisor/SKILL.md +134 -134
- package/skills/ui-demo/SKILL.md +466 -466
- package/skills/ui-to-vue/SKILL.md +135 -135
- package/skills/uncloud/SKILL.md +344 -344
- package/skills/unified-memory/SKILL.md +170 -170
- package/skills/unified-notifications-ops/SKILL.md +188 -188
- package/skills/verification-loop/SKILL.md +129 -129
- package/skills/video-editing/SKILL.md +311 -311
- package/skills/videodb/SKILL.md +375 -375
- package/skills/visa-doc-translate/SKILL.md +117 -117
- package/skills/vite-patterns/SKILL.md +450 -450
- package/skills/vue-patterns/SKILL.md +471 -471
- package/skills/windows-desktop-e2e/SKILL.md +888 -888
- package/skills/workspace-surface-audit/SKILL.md +126 -126
- package/skills/x-api/SKILL.md +235 -235
package/lib/cloud.mjs
CHANGED
|
@@ -1,1144 +1,1250 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Craft Cloud: the agent keeps working while this PC is off, on GitHub's
|
|
3
|
-
* machines, in the user's own account. Nothing runs on Codeply servers.
|
|
4
|
-
*
|
|
5
|
-
* mirror A private repo (craft-workspace-<project>) that holds snapshots
|
|
6
|
-
* of the project. It is separate from the user's real repo, so a
|
|
7
|
-
* cloud run never touches production code.
|
|
8
|
-
* snapshot The working tree, pushed to the mirror's main branch through a
|
|
9
|
-
* git folder of Craft's own (~/.codeply/cloud/<id>.git). The
|
|
10
|
-
* project's own .git is never read or written. .gitignore is
|
|
11
|
-
* respected, .env files and keys are always left out, and files
|
|
12
|
-
* holding key-like strings are skipped.
|
|
13
|
-
* run A workflow_dispatch of craft-cloud.yml. The runner reports
|
|
14
|
-
* progress in a check run, pushes code changes to craft/task-<id>,
|
|
15
|
-
* and keeps the chat on the craft-sessions branch so the next run
|
|
16
|
-
* continues the same conversation.
|
|
17
|
-
* pull The task branch's changes, applied back onto the local project
|
|
18
|
-
* with a 3-way merge, so edits made meanwhile survive.
|
|
19
|
-
*
|
|
20
|
-
* The model key lives only in the mirror's encrypted Actions secrets.
|
|
21
|
-
*/
|
|
22
|
-
import fs from 'fs';
|
|
23
|
-
import os from 'os';
|
|
24
|
-
import path from 'path';
|
|
25
|
-
import crypto from 'crypto';
|
|
26
|
-
import { createRequire } from 'module';
|
|
27
|
-
import { runAgent } from './agent.mjs';
|
|
28
|
-
import { git, ciApprove, routeFromEnv } from './github-agent.mjs';
|
|
29
|
-
|
|
30
|
-
const require = createRequire(import.meta.url);
|
|
31
|
-
const nacl = require('tweetnacl');
|
|
32
|
-
const blake = require('blakejs');
|
|
33
|
-
|
|
34
|
-
export const WORKFLOW_FILE = 'craft-cloud.yml';
|
|
35
|
-
export const WORKFLOW_PATH = `.github/workflows/${WORKFLOW_FILE}`;
|
|
36
|
-
export const MIRROR_PREFIX = 'craft-workspace-';
|
|
37
|
-
export const MIRROR_DESCRIPTION = 'Craft workspace mirror: a private backup that Craft cloud runs work in.';
|
|
38
|
-
export const SESSIONS_BRANCH = 'craft-sessions';
|
|
39
|
-
export const ENGINE = 'codeply-cli@0.5';
|
|
40
|
-
export const taskBranch = (id) => `craft/task-${id}`;
|
|
41
|
-
export const checkName = (id) => `craft ${id}`;
|
|
42
|
-
|
|
43
|
-
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
44
|
-
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
|
45
|
-
const MAX_PROMPT = 20000;
|
|
46
|
-
const MAX_BOT_PROMPT = 30000;
|
|
47
|
-
const MAX_OUTPUT = 60000;
|
|
48
|
-
const HISTORY_TURNS = 40;
|
|
49
|
-
|
|
50
|
-
export const CLOUD_WORKFLOW = `# Written by Codeply Craft. Starts only from Craft (workflow_dispatch), which
|
|
51
|
-
# needs write access to this repo, so nobody else can spend its minutes.
|
|
52
|
-
name: craft-cloud
|
|
53
|
-
run-name: craft \${{ inputs.task_id }}
|
|
54
|
-
|
|
55
|
-
on:
|
|
56
|
-
workflow_dispatch:
|
|
57
|
-
inputs:
|
|
58
|
-
prompt:
|
|
59
|
-
description: What to do
|
|
60
|
-
required: true
|
|
61
|
-
type: string
|
|
62
|
-
mode:
|
|
63
|
-
description: Build, Plan or Ask
|
|
64
|
-
required: false
|
|
65
|
-
default: Build
|
|
66
|
-
type: string
|
|
67
|
-
task_id:
|
|
68
|
-
required: true
|
|
69
|
-
type: string
|
|
70
|
-
session_id:
|
|
71
|
-
required: false
|
|
72
|
-
default: ''
|
|
73
|
-
type: string
|
|
74
|
-
|
|
75
|
-
permissions:
|
|
76
|
-
contents: write
|
|
77
|
-
checks: write
|
|
78
|
-
|
|
79
|
-
concurrency:
|
|
80
|
-
group: craft-\${{ inputs.session_id || inputs.task_id }}
|
|
81
|
-
cancel-in-progress: false
|
|
82
|
-
|
|
83
|
-
jobs:
|
|
84
|
-
craft:
|
|
85
|
-
runs-on: ubuntu-latest
|
|
86
|
-
timeout-minutes: 60
|
|
87
|
-
steps:
|
|
88
|
-
- uses: actions/checkout@v4
|
|
89
|
-
with:
|
|
90
|
-
fetch-depth: 1
|
|
91
|
-
persist-credentials: false
|
|
92
|
-
- uses: actions/setup-node@v4
|
|
93
|
-
with:
|
|
94
|
-
node-version: 22
|
|
95
|
-
- name: Run Craft
|
|
96
|
-
run: npx -y --package="\${CRAFT_ENGINE:-${ENGINE}}" codeply cloud runner
|
|
97
|
-
env:
|
|
98
|
-
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
99
|
-
CODEPLY_API_KEY: \${{ secrets.CODEPLY_API_KEY }}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
['
|
|
125
|
-
['
|
|
126
|
-
['
|
|
127
|
-
['
|
|
128
|
-
['
|
|
129
|
-
['
|
|
130
|
-
['
|
|
131
|
-
['
|
|
132
|
-
['
|
|
133
|
-
['
|
|
134
|
-
]
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
nonceIn
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
e
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
*
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
if (job
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (/
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (step) return `
|
|
212
|
-
return
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
fs.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
tasks.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
await
|
|
275
|
-
await m.G(['config', 'core.
|
|
276
|
-
await m.G(['config', 'core.
|
|
277
|
-
await m.G(['
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
fs.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
await G(['
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
await
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
await s.
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
if (
|
|
389
|
-
return {
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
await api('
|
|
403
|
-
await
|
|
404
|
-
await setVariable(api, repo, '
|
|
405
|
-
await setVariable(api, repo, '
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
await api('
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
if (!
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
let
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const
|
|
749
|
-
await
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
task
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
};
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
if (
|
|
916
|
-
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
};
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
if (
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
await
|
|
1096
|
-
const
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Craft Cloud: the agent keeps working while this PC is off, on GitHub's
|
|
3
|
+
* machines, in the user's own account. Nothing runs on Codeply servers.
|
|
4
|
+
*
|
|
5
|
+
* mirror A private repo (craft-workspace-<project>) that holds snapshots
|
|
6
|
+
* of the project. It is separate from the user's real repo, so a
|
|
7
|
+
* cloud run never touches production code.
|
|
8
|
+
* snapshot The working tree, pushed to the mirror's main branch through a
|
|
9
|
+
* git folder of Craft's own (~/.codeply/cloud/<id>.git). The
|
|
10
|
+
* project's own .git is never read or written. .gitignore is
|
|
11
|
+
* respected, .env files and keys are always left out, and files
|
|
12
|
+
* holding key-like strings are skipped.
|
|
13
|
+
* run A workflow_dispatch of craft-cloud.yml. The runner reports
|
|
14
|
+
* progress in a check run, pushes code changes to craft/task-<id>,
|
|
15
|
+
* and keeps the chat on the craft-sessions branch so the next run
|
|
16
|
+
* continues the same conversation.
|
|
17
|
+
* pull The task branch's changes, applied back onto the local project
|
|
18
|
+
* with a 3-way merge, so edits made meanwhile survive.
|
|
19
|
+
*
|
|
20
|
+
* The model key lives only in the mirror's encrypted Actions secrets.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import os from 'os';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import crypto from 'crypto';
|
|
26
|
+
import { createRequire } from 'module';
|
|
27
|
+
import { runAgent } from './agent.mjs';
|
|
28
|
+
import { git, ciApprove, routeFromEnv } from './github-agent.mjs';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const nacl = require('tweetnacl');
|
|
32
|
+
const blake = require('blakejs');
|
|
33
|
+
|
|
34
|
+
export const WORKFLOW_FILE = 'craft-cloud.yml';
|
|
35
|
+
export const WORKFLOW_PATH = `.github/workflows/${WORKFLOW_FILE}`;
|
|
36
|
+
export const MIRROR_PREFIX = 'craft-workspace-';
|
|
37
|
+
export const MIRROR_DESCRIPTION = 'Craft workspace mirror: a private backup that Craft cloud runs work in.';
|
|
38
|
+
export const SESSIONS_BRANCH = 'craft-sessions';
|
|
39
|
+
export const ENGINE = 'codeply-cli@0.5';
|
|
40
|
+
export const taskBranch = (id) => `craft/task-${id}`;
|
|
41
|
+
export const checkName = (id) => `craft ${id}`;
|
|
42
|
+
|
|
43
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
44
|
+
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
|
45
|
+
const MAX_PROMPT = 20000;
|
|
46
|
+
const MAX_BOT_PROMPT = 30000;
|
|
47
|
+
const MAX_OUTPUT = 60000;
|
|
48
|
+
const HISTORY_TURNS = 40;
|
|
49
|
+
|
|
50
|
+
export const CLOUD_WORKFLOW = `# Written by Codeply Craft. Starts only from Craft (workflow_dispatch), which
|
|
51
|
+
# needs write access to this repo, so nobody else can spend its minutes.
|
|
52
|
+
name: craft-cloud
|
|
53
|
+
run-name: craft \${{ inputs.task_id }}
|
|
54
|
+
|
|
55
|
+
on:
|
|
56
|
+
workflow_dispatch:
|
|
57
|
+
inputs:
|
|
58
|
+
prompt:
|
|
59
|
+
description: What to do
|
|
60
|
+
required: true
|
|
61
|
+
type: string
|
|
62
|
+
mode:
|
|
63
|
+
description: Build, Plan or Ask
|
|
64
|
+
required: false
|
|
65
|
+
default: Build
|
|
66
|
+
type: string
|
|
67
|
+
task_id:
|
|
68
|
+
required: true
|
|
69
|
+
type: string
|
|
70
|
+
session_id:
|
|
71
|
+
required: false
|
|
72
|
+
default: ''
|
|
73
|
+
type: string
|
|
74
|
+
|
|
75
|
+
permissions:
|
|
76
|
+
contents: write
|
|
77
|
+
checks: write
|
|
78
|
+
|
|
79
|
+
concurrency:
|
|
80
|
+
group: craft-\${{ inputs.session_id || inputs.task_id }}
|
|
81
|
+
cancel-in-progress: false
|
|
82
|
+
|
|
83
|
+
jobs:
|
|
84
|
+
craft:
|
|
85
|
+
runs-on: ubuntu-latest
|
|
86
|
+
timeout-minutes: 60
|
|
87
|
+
steps:
|
|
88
|
+
- uses: actions/checkout@v4
|
|
89
|
+
with:
|
|
90
|
+
fetch-depth: 1
|
|
91
|
+
persist-credentials: false
|
|
92
|
+
- uses: actions/setup-node@v4
|
|
93
|
+
with:
|
|
94
|
+
node-version: 22
|
|
95
|
+
- name: Run Craft
|
|
96
|
+
run: npx -y --package="\${CRAFT_ENGINE:-${ENGINE}}" codeply cloud runner
|
|
97
|
+
env:
|
|
98
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
99
|
+
CODEPLY_API_KEY: \${{ secrets.CODEPLY_API_KEY }}
|
|
100
|
+
CODEPLY_TRACK_KEY: \${{ secrets.CODEPLY_TRACK_KEY }}
|
|
101
|
+
CODEPLY_MODEL_KIND: \${{ vars.CODEPLY_MODEL_KIND }}
|
|
102
|
+
CODEPLY_MODEL: \${{ vars.CODEPLY_MODEL }}
|
|
103
|
+
CODEPLY_BASE_URL: \${{ vars.CODEPLY_BASE_URL }}
|
|
104
|
+
CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
|
|
105
|
+
CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
|
|
106
|
+
CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
|
|
107
|
+
CRAFT_BASE: \${{ github.ref_name }}
|
|
108
|
+
CRAFT_PROMPT: \${{ inputs.prompt }}
|
|
109
|
+
CRAFT_MODE: \${{ inputs.mode }}
|
|
110
|
+
CRAFT_TASK_ID: \${{ inputs.task_id }}
|
|
111
|
+
CRAFT_SESSION_ID: \${{ inputs.session_id }}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
// Never mirrored, whatever .gitignore says.
|
|
115
|
+
export const ALWAYS_EXCLUDE = [
|
|
116
|
+
'.env', '.env.*', '!.env.example', '!.env.sample', '!.env.template',
|
|
117
|
+
'*.pem', '*.key', '*.p12', '*.pfx', '*.keystore', '*.jks', '*.mobileprovision',
|
|
118
|
+
'id_rsa*', 'id_dsa*', 'id_ecdsa*', 'id_ed25519*', '.netrc', '.pgpass',
|
|
119
|
+
'node_modules/', '.codeply/', '*.log',
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
// High-confidence key formats only: a false alarm leaves a real source file out of the mirror.
|
|
123
|
+
const SECRET_PATTERNS = [
|
|
124
|
+
['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
125
|
+
['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
|
|
126
|
+
['OpenAI key', /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/],
|
|
127
|
+
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
|
|
128
|
+
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
129
|
+
['AWS key', /\bAKIA[0-9A-Z]{16}\b/],
|
|
130
|
+
['Google key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
131
|
+
['Stripe key', /\b[sr]k_live_[A-Za-z0-9]{20,}/],
|
|
132
|
+
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
|
|
133
|
+
['Groq key', /\bgsk_[A-Za-z0-9]{40,}/],
|
|
134
|
+
['OpenRouter key', /\bsk-or-v1-[a-f0-9]{40,}/],
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
/** The first key-like string in a file's text, or null. Supabase anon keys are public; service_role keys are not. */
|
|
138
|
+
export function findSecret(text) {
|
|
139
|
+
for (const [label, re] of SECRET_PATTERNS) if (re.test(text)) return label;
|
|
140
|
+
const jwts = text.match(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g) || [];
|
|
141
|
+
for (const t of jwts) {
|
|
142
|
+
try { if (/service_role/.test(Buffer.from(t.split('.')[1], 'base64url').toString('utf8'))) return 'Supabase service_role key'; } catch {}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Encrypted secrets (libsodium sealed box, what GitHub expects) ─────────
|
|
148
|
+
|
|
149
|
+
export function sealSecret(publicKeyB64, value) {
|
|
150
|
+
const pk = new Uint8Array(Buffer.from(publicKeyB64, 'base64'));
|
|
151
|
+
const eph = nacl.box.keyPair();
|
|
152
|
+
const nonceIn = new Uint8Array(eph.publicKey.length + pk.length);
|
|
153
|
+
nonceIn.set(eph.publicKey); nonceIn.set(pk, eph.publicKey.length);
|
|
154
|
+
const nonce = blake.blake2b(nonceIn, undefined, nacl.box.nonceLength);
|
|
155
|
+
const boxed = nacl.box(new Uint8Array(Buffer.from(String(value), 'utf8')), nonce, pk, eph.secretKey);
|
|
156
|
+
return Buffer.concat([Buffer.from(eph.publicKey), Buffer.from(boxed)]).toString('base64');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Inverse of sealSecret, for tests. */
|
|
160
|
+
export function openSealed(sealedB64, publicKey, secretKey) {
|
|
161
|
+
const all = new Uint8Array(Buffer.from(sealedB64, 'base64'));
|
|
162
|
+
const epk = all.slice(0, 32);
|
|
163
|
+
const nonceIn = new Uint8Array(64); nonceIn.set(epk); nonceIn.set(publicKey, 32);
|
|
164
|
+
const nonce = blake.blake2b(nonceIn, undefined, nacl.box.nonceLength);
|
|
165
|
+
const out = nacl.box.open(all.slice(32), nonce, epk, secretKey);
|
|
166
|
+
return out ? Buffer.from(out).toString('utf8') : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── GitHub API ──────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export function githubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = fetch }) {
|
|
172
|
+
const call = async (method, route, body, { allow = [] } = {}) => {
|
|
173
|
+
const res = await fetchImpl(`${apiUrl.replace(/\/$/, '')}${route}`, {
|
|
174
|
+
method,
|
|
175
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'codeply-craft', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
176
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
177
|
+
});
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
let json = null;
|
|
180
|
+
try { json = text ? JSON.parse(text) : null; } catch {}
|
|
181
|
+
if (!res.ok && !allow.includes(res.status)) {
|
|
182
|
+
const e = new Error(`GitHub ${method} ${route.split('?')[0]}: ${res.status} ${(json && json.message) || text.slice(0, 200)}`);
|
|
183
|
+
e.status = res.status;
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
return { status: res.status, json, text, scopes: res.headers && res.headers.get ? res.headers.get('x-oauth-scopes') : null };
|
|
187
|
+
};
|
|
188
|
+
return call;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Why a run died before Craft could report back (install failed, time limit,
|
|
193
|
+
* permissions), from the job's steps and log. Plain words, or null.
|
|
194
|
+
*/
|
|
195
|
+
export async function diagnoseRun(api, repo, runId) {
|
|
196
|
+
try {
|
|
197
|
+
const jobs = ((await api('GET', `/repos/${repo}/actions/runs/${runId}/jobs`)).json || {}).jobs || [];
|
|
198
|
+
const job = jobs.find((j) => j.conclusion === 'failure' || j.conclusion === 'timed_out') || jobs[0];
|
|
199
|
+
if (!job) return null;
|
|
200
|
+
if (job.conclusion === 'timed_out') return 'The run hit its 60-minute limit and was stopped.';
|
|
201
|
+
const step = (job.steps || []).find((s) => s.conclusion === 'failure');
|
|
202
|
+
let log = '';
|
|
203
|
+
try { log = (await api('GET', `/repos/${repo}/actions/jobs/${job.id}/logs`)).text || ''; } catch {}
|
|
204
|
+
if (/No matching version found for codeply-cli|notarget[\s\S]{0,200}codeply-cli/i.test(log)) {
|
|
205
|
+
return `GitHub couldn't install Craft's cloud engine (${ENGINE}): that version isn't on npm yet. Publish it, then run again.`;
|
|
206
|
+
}
|
|
207
|
+
if (/Resource not accessible by integration/i.test(log)) return 'GitHub refused the run permission to save its work. Check Settings > Actions > General > Workflow permissions on the mirror repo.';
|
|
208
|
+
if (/ENOTFOUND|ECONNRESET|ETIMEDOUT|network/i.test(log) && /npm (error|ERR)/i.test(log)) return 'GitHub\'s runner could not download Craft\'s engine (a network hiccup on GitHub\'s side). Run it again.';
|
|
209
|
+
const lastError = log.split('\n').map((l) => l.replace(/^\S+Z\s/, '').replace(/\x1b\[[0-9;]*m/g, '').trim())
|
|
210
|
+
.filter((l) => /error|failed|fatal/i.test(l) && !/^##\[group\]/.test(l)).slice(-1)[0];
|
|
211
|
+
if (step && step.name === 'Run Craft') return `Craft stopped before it could report back${lastError ? `: ${lastError.replace(/^##\[error\]/, '').slice(0, 240)}` : '.'}`;
|
|
212
|
+
if (step) return `The "${step.name}" step failed on GitHub${lastError ? `: ${lastError.replace(/^##\[error\]/, '').slice(0, 240)}` : '.'}`;
|
|
213
|
+
return null;
|
|
214
|
+
} catch { return null; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Local state ─────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const defaultHome = () => path.join(os.homedir(), '.codeply');
|
|
220
|
+
const projectKey = (cwd) => path.resolve(cwd).replace(/\\/g, '/').replace(/\/$/, '').toLowerCase();
|
|
221
|
+
const projectId = (cwd) => crypto.createHash('sha1').update(projectKey(cwd)).digest('hex').slice(0, 16);
|
|
222
|
+
|
|
223
|
+
function stateFile(home) { return path.join(home, 'cloud.json'); }
|
|
224
|
+
export function readState(home = defaultHome()) {
|
|
225
|
+
try { const s = JSON.parse(fs.readFileSync(stateFile(home), 'utf8')); return s && typeof s === 'object' && s.projects ? s : { projects: {} }; } catch { return { projects: {} }; }
|
|
226
|
+
}
|
|
227
|
+
function writeState(home, state) {
|
|
228
|
+
fs.mkdirSync(home, { recursive: true });
|
|
229
|
+
const tmp = `${stateFile(home)}.tmp`;
|
|
230
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
231
|
+
fs.renameSync(tmp, stateFile(home));
|
|
232
|
+
}
|
|
233
|
+
export function getProject(cwd, home = defaultHome()) { return readState(home).projects[projectKey(cwd)] || null; }
|
|
234
|
+
function updateProject(cwd, home, fn) {
|
|
235
|
+
const state = readState(home);
|
|
236
|
+
const key = projectKey(cwd);
|
|
237
|
+
state.projects[key] = fn(state.projects[key] || { cwd: path.resolve(cwd), tasks: [] });
|
|
238
|
+
writeState(home, state);
|
|
239
|
+
return state.projects[key];
|
|
240
|
+
}
|
|
241
|
+
function saveTask(cwd, home, task) {
|
|
242
|
+
return updateProject(cwd, home, (p) => {
|
|
243
|
+
const tasks = (p.tasks || []).filter((t) => t.id !== task.id);
|
|
244
|
+
tasks.unshift(task);
|
|
245
|
+
return { ...p, tasks: tasks.slice(0, 100) };
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Per-project switches the app keeps: cloudOn (send new messages to the cloud), autoBackup. */
|
|
249
|
+
export function setProjectOptions(cwd, opts, home = defaultHome()) {
|
|
250
|
+
const allowed = {};
|
|
251
|
+
for (const k of ['cloudOn', 'autoBackup']) if (typeof opts[k] === 'boolean') allowed[k] = opts[k];
|
|
252
|
+
return updateProject(cwd, home, (p) => ({ ...p, ...allowed }));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function getTask(cwd, id, home = defaultHome()) {
|
|
256
|
+
const p = getProject(cwd, home);
|
|
257
|
+
return p && (p.tasks || []).find((t) => t.id === id) || null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
const BOT = ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com'];
|
|
263
|
+
|
|
264
|
+
function mirrorGit(cwd, home) {
|
|
265
|
+
const gitDir = path.join(home, 'cloud', `${projectId(cwd)}.git`);
|
|
266
|
+
const G = (args, opts) => git(cwd, ['--git-dir', gitDir, '--work-tree', cwd, ...args], opts);
|
|
267
|
+
return { gitDir, G };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function initMirrorGit(cwd, home) {
|
|
271
|
+
const m = mirrorGit(cwd, home);
|
|
272
|
+
if (!fs.existsSync(path.join(m.gitDir, 'HEAD'))) {
|
|
273
|
+
fs.mkdirSync(m.gitDir, { recursive: true });
|
|
274
|
+
await git(cwd, ['init', '-q', '--bare', m.gitDir]);
|
|
275
|
+
await m.G(['config', 'core.bare', 'false']);
|
|
276
|
+
await m.G(['config', 'core.autocrlf', 'false']);
|
|
277
|
+
await m.G(['config', 'core.quotepath', 'false']);
|
|
278
|
+
await m.G(['symbolic-ref', 'HEAD', 'refs/heads/main']);
|
|
279
|
+
}
|
|
280
|
+
fs.mkdirSync(path.join(m.gitDir, 'info'), { recursive: true });
|
|
281
|
+
fs.writeFileSync(path.join(m.gitDir, 'info', 'exclude'), `${ALWAYS_EXCLUDE.join('\n')}\n`);
|
|
282
|
+
return m;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Stage the working tree into Craft's own index: .gitignore and ALWAYS_EXCLUDE
|
|
287
|
+
* respected, the project's own workflows left out (they must not run on the
|
|
288
|
+
* mirror), big files and files holding keys dropped, craft-cloud.yml added.
|
|
289
|
+
*/
|
|
290
|
+
async function stageWorkingTree(cwd, home) {
|
|
291
|
+
const m = await initMirrorGit(cwd, home);
|
|
292
|
+
const { G } = m;
|
|
293
|
+
await G(['add', '-A', '--', '.']);
|
|
294
|
+
await G(['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', '.github/workflows']);
|
|
295
|
+
const hasHead = await G(['rev-parse', '-q', '--verify', 'refs/heads/main']).then(() => true, () => false);
|
|
296
|
+
const listed = hasHead
|
|
297
|
+
? await G(['diff', '--cached', '--name-only', '--diff-filter=AM', '-z', 'refs/heads/main'])
|
|
298
|
+
: await G(['ls-files', '-z', '--cached']);
|
|
299
|
+
const skipped = [];
|
|
300
|
+
for (const rel of listed.split('\0').filter(Boolean)) {
|
|
301
|
+
const full = path.join(cwd, rel);
|
|
302
|
+
let reason = null;
|
|
303
|
+
try {
|
|
304
|
+
const st = fs.statSync(full);
|
|
305
|
+
if (st.size > MAX_FILE_BYTES) reason = 'over 50 MB';
|
|
306
|
+
else if (st.size <= MAX_SCAN_BYTES) {
|
|
307
|
+
const buf = fs.readFileSync(full);
|
|
308
|
+
if (!buf.subarray(0, 8000).includes(0)) {
|
|
309
|
+
const found = findSecret(buf.toString('utf8'));
|
|
310
|
+
if (found) reason = `holds a ${found}`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch { continue; }
|
|
314
|
+
if (reason) {
|
|
315
|
+
await G(['rm', '-q', '--cached', '--', rel]);
|
|
316
|
+
skipped.push({ path: rel.replace(/\\/g, '/'), reason });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const blob = await git(cwd, ['--git-dir', m.gitDir, 'hash-object', '-w', '--stdin'], { input: CLOUD_WORKFLOW });
|
|
320
|
+
await G(['update-index', '--add', '--cacheinfo', `100644,${blob},${WORKFLOW_PATH}`]);
|
|
321
|
+
return { ...m, hasHead, skipped };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Commit the working tree to the local mirror git (no network). */
|
|
325
|
+
export async function snapshot({ cwd, home = defaultHome() }) {
|
|
326
|
+
const s = await stageWorkingTree(cwd, home);
|
|
327
|
+
const tree = await s.G(['write-tree']);
|
|
328
|
+
const head = s.hasHead ? await s.G(['rev-parse', 'refs/heads/main']) : null;
|
|
329
|
+
if (head && (await s.G(['rev-parse', `${head}^{tree}`])) === tree) return { gitDir: s.gitDir, sha: head, changed: false, skipped: s.skipped };
|
|
330
|
+
const sha = await git(cwd, ['--git-dir', s.gitDir, ...BOT, 'commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `Snapshot from ${os.hostname()} at ${new Date().toISOString()}`]);
|
|
331
|
+
await s.G(['update-ref', 'refs/heads/main', sha]);
|
|
332
|
+
return { gitDir: s.gitDir, sha, changed: true, skipped: s.skipped };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const authFor = (token, serverUrl = 'https://github.com') => ({ token, server: serverUrl.replace(/\/$/, '') });
|
|
336
|
+
|
|
337
|
+
/** Snapshot, then push it to the mirror. The mirror's main is Craft's, so it is force-pushed. */
|
|
338
|
+
export async function pushSnapshot({ cwd, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
|
|
339
|
+
const p = getProject(cwd, home);
|
|
340
|
+
if (!p || !p.repo) throw new Error('Cloud is not set up for this project yet.');
|
|
341
|
+
// A project that works in its own GitHub repo is never snapshotted: that would overwrite its real main.
|
|
342
|
+
if (p.kind === 'repo') throw new Error(`${p.repo} is this project's own repo; Craft does not back up over it.`);
|
|
343
|
+
const snap = await snapshot({ cwd, home });
|
|
344
|
+
if (snap.changed || !p.lastPush || p.lastPush.sha !== snap.sha) {
|
|
345
|
+
const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${p.repo}.git`;
|
|
346
|
+
await git(cwd, ['--git-dir', snap.gitDir, 'push', '-q', '--force', url, 'refs/heads/main:refs/heads/main'], { auth: authFor(token, serverUrl) });
|
|
347
|
+
updateProject(cwd, home, (q) => ({ ...q, lastPush: { sha: snap.sha, at: Date.now(), skipped: snap.skipped } }));
|
|
348
|
+
return { ...snap, pushed: true };
|
|
349
|
+
}
|
|
350
|
+
return { ...snap, pushed: false };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Setup ───────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 60) || 'project';
|
|
356
|
+
|
|
357
|
+
/** Find or create the private mirror repo for a project. */
|
|
358
|
+
export async function ensureMirror({ api, cwd, home = defaultHome() }) {
|
|
359
|
+
const existing = getProject(cwd, home);
|
|
360
|
+
if (existing && existing.repo) {
|
|
361
|
+
const r = await api('GET', `/repos/${existing.repo}`, null, { allow: [404] });
|
|
362
|
+
if (r.status === 200) return { repo: existing.repo, created: false };
|
|
363
|
+
}
|
|
364
|
+
const me = (await api('GET', '/user')).json;
|
|
365
|
+
const base = `${MIRROR_PREFIX}${slug(path.basename(path.resolve(cwd)))}`;
|
|
366
|
+
for (let i = 1; i <= 9; i++) {
|
|
367
|
+
const name = i === 1 ? base : `${base}-${i}`;
|
|
368
|
+
const r = await api('GET', `/repos/${me.login}/${name}`, null, { allow: [404] });
|
|
369
|
+
if (r.status === 200 && r.json.description === MIRROR_DESCRIPTION && r.json.private) {
|
|
370
|
+
updateProject(cwd, home, (p) => ({ ...p, repo: r.json.full_name }));
|
|
371
|
+
return { repo: r.json.full_name, created: false };
|
|
372
|
+
}
|
|
373
|
+
if (r.status === 404) {
|
|
374
|
+
const made = await api('POST', '/user/repos', { name, private: true, description: MIRROR_DESCRIPTION, auto_init: false, has_issues: false, has_wiki: false, has_projects: false });
|
|
375
|
+
updateProject(cwd, home, (p) => ({ ...p, repo: made.json.full_name, createdAt: Date.now() }));
|
|
376
|
+
return { repo: made.json.full_name, created: true };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
throw new Error(`Could not find a free name for the mirror (tried ${base} to ${base}-9).`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Which model the runner uses. Local models can't be reached from GitHub. */
|
|
383
|
+
export function cloudModelConfig(model) {
|
|
384
|
+
if (!model || !model.model) return { error: 'Pick one of your own models first. Cloud runs use your key, not Auto.' };
|
|
385
|
+
const kind = model.kind === 'ollama' ? 'ollama' : 'openai';
|
|
386
|
+
const baseUrl = kind === 'ollama' ? String(model.baseUrl || 'https://ollama.com').replace(/\/+$/, '').replace(/\/(api|v1)$/i, '') : String(model.baseUrl || '').trim();
|
|
387
|
+
if (!baseUrl) return { error: 'That model has no base URL.' };
|
|
388
|
+
if (/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(baseUrl)) return { error: `${model.name || model.model} runs on this PC, so GitHub can't reach it. Pick a cloud model for cloud runs.` };
|
|
389
|
+
if (!model.apiKey) return { error: `${model.name || model.model} has no API key.` };
|
|
390
|
+
return { kind, baseUrl, model: model.model, apiKey: model.apiKey, sig: crypto.createHash('sha256').update([kind, baseUrl, model.model, model.apiKey].join('\n')).digest('hex').slice(0, 16) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function setVariable(api, repo, name, value) {
|
|
394
|
+
const r = await api('PATCH', `/repos/${repo}/actions/variables/${name}`, { name, value }, { allow: [404] });
|
|
395
|
+
if (r.status === 404) await api('POST', `/repos/${repo}/actions/variables`, { name, value });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Put the model key in the mirror's encrypted secrets and the rest in variables. */
|
|
399
|
+
export async function configureModel({ api, repo, model }) {
|
|
400
|
+
const cfg = cloudModelConfig(model);
|
|
401
|
+
if (cfg.error) throw new Error(cfg.error);
|
|
402
|
+
const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
|
|
403
|
+
await api('PUT', `/repos/${repo}/actions/secrets/CODEPLY_API_KEY`, { encrypted_value: sealSecret(pk.key, cfg.apiKey), key_id: pk.key_id });
|
|
404
|
+
await setVariable(api, repo, 'CODEPLY_MODEL_KIND', cfg.kind);
|
|
405
|
+
await setVariable(api, repo, 'CODEPLY_BASE_URL', cfg.baseUrl);
|
|
406
|
+
await setVariable(api, repo, 'CODEPLY_MODEL', cfg.model);
|
|
407
|
+
return cfg;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Check the token can create repos and push workflow files. */
|
|
411
|
+
export async function checkToken(api) {
|
|
412
|
+
const r = await api('GET', '/user');
|
|
413
|
+
const scopes = String(r.scopes || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
414
|
+
// Fine-grained and app tokens send no scope header; let GitHub decide on those.
|
|
415
|
+
if (r.scopes != null && scopes.length && !(scopes.includes('repo') && scopes.includes('workflow'))) {
|
|
416
|
+
return { ok: false, login: r.json.login, error: 'GitHub needs to be reconnected so Craft may create private repos and workflow files (repo and workflow access).' };
|
|
417
|
+
}
|
|
418
|
+
return { ok: true, login: r.json.login };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ─── Targets: the project's own GitHub repo, or a private mirror ─────────
|
|
422
|
+
|
|
423
|
+
/** owner/name of the project's GitHub `origin`, or null. */
|
|
424
|
+
export async function githubOrigin(cwd) {
|
|
425
|
+
try {
|
|
426
|
+
const url = await git(cwd, ['remote', 'get-url', 'origin']);
|
|
427
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url);
|
|
428
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
429
|
+
} catch { return null; }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The repos this login can push to (for the phone's and the app's repo picker). */
|
|
433
|
+
export async function listMyRepos(api) {
|
|
434
|
+
const r = await api('GET', '/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100');
|
|
435
|
+
return (r.json || []).filter((x) => x.permissions && x.permissions.push)
|
|
436
|
+
.map((x) => ({ repo: x.full_name, private: x.private, base: x.default_branch, mirror: x.description === MIRROR_DESCRIPTION, pushedAt: x.pushed_at }));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Commit craft-cloud.yml to the repo's default branch (only when missing or out of date). */
|
|
440
|
+
export async function installWorkflowInRepo({ api, repo, base }) {
|
|
441
|
+
const files = branchFiles(api, repo, base);
|
|
442
|
+
const cur = await api('GET', `/repos/${repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(base)}`, null, { allow: [404] });
|
|
443
|
+
const want = Buffer.from(CLOUD_WORKFLOW).toString('base64');
|
|
444
|
+
if (cur.status === 200 && String(cur.json.content || '').replace(/\s/g, '') === want) return false;
|
|
445
|
+
const ok = await files.writeBytes(WORKFLOW_PATH, Buffer.from(CLOUD_WORKFLOW), 'Add Craft Cloud workflow');
|
|
446
|
+
if (!ok) throw new Error(`Could not add ${WORKFLOW_PATH} to ${repo}. GitHub needs the workflow permission for that.`);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The project's environment for running and testing (KEY=value lines), as the CRAFT_ENV secret. */
|
|
451
|
+
export async function setProjectEnv({ api, repo, envText }) {
|
|
452
|
+
const text = String(envText || '').trim();
|
|
453
|
+
if (!text) { await api('DELETE', `/repos/${repo}/actions/secrets/CRAFT_ENV`, null, { allow: [404] }); return 0; }
|
|
454
|
+
const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
|
|
455
|
+
await api('PUT', `/repos/${repo}/actions/secrets/CRAFT_ENV`, { encrypted_value: sealSecret(pk.key, text), key_id: pk.key_id });
|
|
456
|
+
return text.split('\n').filter((l) => /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(l)).length;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Usage counts for the admin dashboard from cloud runs: a run has no Codeply
|
|
461
|
+
* sign-in, so Craft asks Codeply for a usage key for the signed-in user and puts
|
|
462
|
+
* it in the repo's encrypted secrets (CODEPLY_TRACK_KEY). Counts only, like the
|
|
463
|
+
* desktop apps. Never fails a setup or a run: without a sign-in, or with
|
|
464
|
+
* telemetry off, it does nothing.
|
|
465
|
+
*/
|
|
466
|
+
export async function ensureTrackKey({ api, repo, cwd, home = defaultHome(), fetchImpl = fetch }) {
|
|
467
|
+
try {
|
|
468
|
+
const p = getProject(cwd, home) || {};
|
|
469
|
+
if (p.trackKeyRepo === repo) return false;
|
|
470
|
+
if (process.env.CODEPLY_TELEMETRY === '0') return false;
|
|
471
|
+
try { if (require('./config.js').getConfig().telemetry === false) return false; } catch {}
|
|
472
|
+
const auth = require('./auth.js');
|
|
473
|
+
const token = await auth.getAccessToken().catch(() => null);
|
|
474
|
+
if (!token) return false;
|
|
475
|
+
const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/track', {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { Authorization: 'Bearer ' + token, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
|
|
478
|
+
body: JSON.stringify({ action: 'mint', label: 'cloud ' + repo }),
|
|
479
|
+
signal: AbortSignal.timeout(15_000),
|
|
480
|
+
});
|
|
481
|
+
const j = await r.json().catch(() => ({}));
|
|
482
|
+
if (!r.ok || !j.key) return false;
|
|
483
|
+
const pk = (await api('GET', '/repos/' + repo + '/actions/secrets/public-key')).json;
|
|
484
|
+
await api('PUT', '/repos/' + repo + '/actions/secrets/CODEPLY_TRACK_KEY', { encrypted_value: sealSecret(pk.key, j.key), key_id: pk.key_id });
|
|
485
|
+
updateProject(cwd, home, (q) => ({ ...q, trackKeyRepo: repo }));
|
|
486
|
+
return true;
|
|
487
|
+
} catch { return false; }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Connected apps (Gmail, Slack, Vercel, Supabase, GitHub) for cloud runs. A run
|
|
492
|
+
* happens on GitHub's servers, which don't have the sign-ins saved on this PC,
|
|
493
|
+
* so Craft keeps an encrypted copy on Codeply's server for the signed-in user
|
|
494
|
+
* (the connections function) and the runner loads it at start with the usage
|
|
495
|
+
* key. Sent only when something changed. Off with "cloudConnections": false in
|
|
496
|
+
* ~/.codeply/config.json. Never fails a setup or a run.
|
|
497
|
+
*/
|
|
498
|
+
export const CONNECTION_APPS = ['gmail', 'slack', 'vercel', 'supabase', 'github'];
|
|
499
|
+
export async function syncCloudConnections({ home = defaultHome(), fetchImpl = fetch } = {}) {
|
|
500
|
+
try {
|
|
501
|
+
const config = require('./config.js');
|
|
502
|
+
if (config.getConfig().cloudConnections === false) return false;
|
|
503
|
+
const connections = {};
|
|
504
|
+
for (const name of CONNECTION_APPS) {
|
|
505
|
+
const c = config.getIntegration(name); // includes the app's OAuth client, which token refresh needs
|
|
506
|
+
if (c.accessToken) connections[name] = c;
|
|
507
|
+
}
|
|
508
|
+
const sig = crypto.createHash('sha256').update(JSON.stringify(connections)).digest('hex');
|
|
509
|
+
const state = readState(home);
|
|
510
|
+
if (state.connectionsSig === sig) return false;
|
|
511
|
+
const auth = require('./auth.js');
|
|
512
|
+
const token = await auth.getAccessToken().catch(() => null);
|
|
513
|
+
if (!token) return false;
|
|
514
|
+
const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/connections', {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: { Authorization: 'Bearer ' + token, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
|
|
517
|
+
body: JSON.stringify({ action: 'save', connections }),
|
|
518
|
+
signal: AbortSignal.timeout(15_000),
|
|
519
|
+
});
|
|
520
|
+
if (!r.ok) return false;
|
|
521
|
+
writeState(home, { ...readState(home), connectionsSig: sig });
|
|
522
|
+
return true;
|
|
523
|
+
} catch { return false; }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** In the runner: put the user's connected apps into this machine's config, so the agent's tools can use them. */
|
|
527
|
+
export async function loadCloudConnections({ env = process.env, fetchImpl = fetch, log = () => {} } = {}) {
|
|
528
|
+
const key = String(env.CODEPLY_TRACK_KEY || '').trim();
|
|
529
|
+
if (!key) return [];
|
|
530
|
+
try {
|
|
531
|
+
const auth = require('./auth.js');
|
|
532
|
+
const r = await fetchImpl(auth.SUPABASE_URL + '/functions/v1/connections', {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: { 'x-codeply-track-key': key, apikey: auth.SUPABASE_ANON_KEY, 'Content-Type': 'application/json' },
|
|
535
|
+
body: JSON.stringify({ action: 'get' }),
|
|
536
|
+
signal: AbortSignal.timeout(15_000),
|
|
537
|
+
});
|
|
538
|
+
const j = await r.json().catch(() => ({}));
|
|
539
|
+
if (!r.ok || !j.connections) return [];
|
|
540
|
+
const config = require('./config.js');
|
|
541
|
+
const loaded = [];
|
|
542
|
+
for (const name of CONNECTION_APPS) {
|
|
543
|
+
const c = j.connections[name];
|
|
544
|
+
if (c && c.accessToken) { config.saveIntegration(name, c); loaded.push(name); }
|
|
545
|
+
}
|
|
546
|
+
if (loaded.length) log('Connected apps: ' + loaded.join(', '));
|
|
547
|
+
return loaded;
|
|
548
|
+
} catch (e) { log('Could not load connected apps: ' + e.message); return []; }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Bumped when CLOUD_WORKFLOW changes, so repos set up earlier get the new file once.
|
|
552
|
+
export const WORKFLOW_REV = 2;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* One-click setup. With `target: 'repo'` (default when the project has a GitHub
|
|
556
|
+
* origin) the cloud works in that repo: new branch per task, merged into the
|
|
557
|
+
* default branch, and the PC pulls. Otherwise a private mirror holds snapshots.
|
|
558
|
+
*/
|
|
559
|
+
export async function setupCloud({ cwd, token, model, target, envText, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
|
|
560
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
561
|
+
const t = await checkToken(api);
|
|
562
|
+
if (!t.ok) throw new Error(t.error);
|
|
563
|
+
const cfg = cloudModelConfig(model);
|
|
564
|
+
if (cfg.error) throw new Error(cfg.error);
|
|
565
|
+
const origin = target === 'mirror' ? null : await githubOrigin(cwd);
|
|
566
|
+
let repo; let created = false; let skipped = []; let base = 'main'; let kind = 'mirror';
|
|
567
|
+
if (origin) {
|
|
568
|
+
const r = await api('GET', `/repos/${origin}`, null, { allow: [404] });
|
|
569
|
+
if (r.status !== 200) throw new Error(`Craft can't open ${origin} with this GitHub login.`);
|
|
570
|
+
if (!r.json.permissions || !r.json.permissions.push) throw new Error(`This GitHub login can't push to ${origin}.`);
|
|
571
|
+
repo = origin; base = r.json.default_branch || 'main'; kind = 'repo';
|
|
572
|
+
updateProject(cwd, home, (p) => ({ ...p, repo, base, kind }));
|
|
573
|
+
await installWorkflowInRepo({ api, repo, base });
|
|
574
|
+
} else {
|
|
575
|
+
({ repo, created } = await ensureMirror({ api, cwd, home }));
|
|
576
|
+
updateProject(cwd, home, (p) => ({ ...p, base: 'main', kind: 'mirror' }));
|
|
577
|
+
skipped = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).skipped;
|
|
578
|
+
}
|
|
579
|
+
await configureModel({ api, repo, model });
|
|
580
|
+
if (envText != null) await setProjectEnv({ api, repo, envText });
|
|
581
|
+
updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: model.name || model.model, enabled: true, workflowRev: WORKFLOW_REV }));
|
|
582
|
+
await ensureTrackKey({ api, repo, cwd, home, fetchImpl });
|
|
583
|
+
await syncCloudConnections({ home, fetchImpl });
|
|
584
|
+
return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ─── Runs ────────────────────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
const newTaskId = () => `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
590
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
591
|
+
const LIVE_FRESH_MS = 60000;
|
|
592
|
+
|
|
593
|
+
/** Is a runner up for this chat right now (idle and waiting, or busy and will get to it)? */
|
|
594
|
+
export async function liveRunner(api, repo, sessionId) {
|
|
595
|
+
if (!sessionId) return null;
|
|
596
|
+
const f = await branchFiles(api, repo).read(SESSION_PATHS.live(sessionId)).catch(() => null);
|
|
597
|
+
const live = f && f.json;
|
|
598
|
+
if (!live || Date.now() - (live.at || 0) > LIVE_FRESH_MS) return null;
|
|
599
|
+
if (!live.busy && (live.until || 0) < Date.now() + 8000) return null;
|
|
600
|
+
return live;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function dispatch(api, repo, base, task, retryMs) {
|
|
604
|
+
let lastErr = null;
|
|
605
|
+
// Right after the workflow file first lands, GitHub can take a few seconds to know it.
|
|
606
|
+
for (let i = 0; i < 10; i++) {
|
|
607
|
+
try {
|
|
608
|
+
await api('POST', `/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: base || 'main', inputs: { prompt: task.prompt, mode: task.mode, task_id: task.id, session_id: task.sessionId } });
|
|
609
|
+
return;
|
|
610
|
+
} catch (e) {
|
|
611
|
+
lastErr = e;
|
|
612
|
+
if (e.status !== 404 && e.status !== 422) break;
|
|
613
|
+
await sleep(retryMs);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
throw lastErr;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Start a cloud task. A runner already up for this chat gets it through the
|
|
621
|
+
* queue at once; otherwise a new runner is dispatched. Mirror projects push
|
|
622
|
+
* the latest snapshot first.
|
|
623
|
+
*/
|
|
624
|
+
export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', bot = null, kind = 'code', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
|
|
625
|
+
const p = getProject(cwd, home);
|
|
626
|
+
if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
|
|
627
|
+
prompt = String(prompt || '').trim();
|
|
628
|
+
if (!prompt) throw new Error('Write a task first.');
|
|
629
|
+
if (prompt.length > MAX_PROMPT) throw new Error(`That task is too long for a cloud run (${prompt.length} characters, the limit is ${MAX_PROMPT}).`);
|
|
630
|
+
mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
|
|
631
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
632
|
+
if (model) {
|
|
633
|
+
const cfg = cloudModelConfig(model);
|
|
634
|
+
if (cfg.error) throw new Error(cfg.error);
|
|
635
|
+
if (cfg.sig !== p.modelSig) {
|
|
636
|
+
await configureModel({ api, repo: p.repo, model });
|
|
637
|
+
updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: model.name || model.model }));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const base = p.base || 'main';
|
|
641
|
+
// repos set up before a workflow change get the new file once (mirrors get it with every snapshot)
|
|
642
|
+
if (p.kind === 'repo' && p.workflowRev !== WORKFLOW_REV) {
|
|
643
|
+
await installWorkflowInRepo({ api, repo: p.repo, base });
|
|
644
|
+
updateProject(cwd, home, (q) => ({ ...q, workflowRev: WORKFLOW_REV }));
|
|
645
|
+
}
|
|
646
|
+
await ensureTrackKey({ api, repo: p.repo, cwd, home, fetchImpl });
|
|
647
|
+
await syncCloudConnections({ home, fetchImpl });
|
|
648
|
+
let baseSha;
|
|
649
|
+
if (p.kind === 'repo') {
|
|
650
|
+
const ref = await api('GET', `/repos/${p.repo}/git/ref/heads/${encodeURIComponent(base)}`, null, { allow: [404] });
|
|
651
|
+
baseSha = ref.status === 200 ? ref.json.object.sha : null;
|
|
652
|
+
} else {
|
|
653
|
+
baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
|
|
654
|
+
}
|
|
655
|
+
const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
|
|
656
|
+
const b = cleanBot(bot);
|
|
657
|
+
const extra = { ...(b ? { bot: b } : {}), ...(cleanKind(kind) === 'task' ? { kind: 'task' } : {}) };
|
|
658
|
+
if (b) task.bot = { name: b.name };
|
|
659
|
+
if (extra.kind) task.kind = 'task';
|
|
660
|
+
const files = branchFiles(api, p.repo);
|
|
661
|
+
const live = await liveRunner(api, p.repo, task.sessionId);
|
|
662
|
+
if (live) {
|
|
663
|
+
await files.write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode, ...extra }, `Craft queue ${task.id}`);
|
|
664
|
+
Object.assign(task, { queued: true, enqueuedAt: Date.now() });
|
|
665
|
+
} else {
|
|
666
|
+
// A bot's prompt is too big for a workflow input: the runner reads it from craft-sessions.
|
|
667
|
+
if (Object.keys(extra).length) {
|
|
668
|
+
if (baseSha) await files.ensure(baseSha).catch(() => {});
|
|
669
|
+
if (!(await files.write(SESSION_PATHS.request(task.id), extra, `Craft request ${task.id}`))) throw new Error('Could not hand the bot to the cloud. Try again.');
|
|
670
|
+
}
|
|
671
|
+
await dispatch(api, p.repo, base, task, retryMs);
|
|
672
|
+
}
|
|
673
|
+
saveTask(cwd, home, task);
|
|
674
|
+
return task;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** A task's live status document (tasks/<id>.json), or null before the runner has written it. */
|
|
678
|
+
export async function readTaskDoc(api, repo, taskId) {
|
|
679
|
+
const f = await branchFiles(api, repo).read(SESSION_PATHS.task(taskId)).catch(() => null);
|
|
680
|
+
return f && f.json ? f.json : null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
|
|
684
|
+
export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
|
|
685
|
+
const task = getTask(cwd, taskId, home);
|
|
686
|
+
if (!task) throw new Error('Unknown cloud task.');
|
|
687
|
+
if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') return task;
|
|
688
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
689
|
+
const next = { ...task };
|
|
690
|
+
const doc = await readTaskDoc(api, task.repo, task.id);
|
|
691
|
+
if (doc) {
|
|
692
|
+
const { events = [], status, ...rest } = doc;
|
|
693
|
+
if (doc.runId) { next.runId = doc.runId; next.runUrl = `https://github.com/${task.repo}/actions/runs/${doc.runId}`; }
|
|
694
|
+
if (status === 'done' || status === 'failed') {
|
|
695
|
+
const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
|
|
696
|
+
Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
|
|
697
|
+
} else next.status = 'running';
|
|
698
|
+
saveTask(cwd, home, next);
|
|
699
|
+
return { ...next, events };
|
|
700
|
+
}
|
|
701
|
+
if (task.queued) {
|
|
702
|
+
// Handed to a runner that went away before taking it: start a new one.
|
|
703
|
+
if (Date.now() - task.enqueuedAt > 30000 && !(await liveRunner(api, task.repo, task.sessionId))) {
|
|
704
|
+
const p = getProject(cwd, home) || {};
|
|
705
|
+
await dispatch(api, task.repo, p.base || 'main', task, retryMs);
|
|
706
|
+
Object.assign(next, { queued: false, status: 'starting', redispatchedAt: Date.now() });
|
|
707
|
+
} else next.status = 'queued';
|
|
708
|
+
saveTask(cwd, home, next);
|
|
709
|
+
return { ...next, events: [] };
|
|
710
|
+
}
|
|
711
|
+
let run = null;
|
|
712
|
+
if (task.runId) run = (await api('GET', `/repos/${task.repo}/actions/runs/${task.runId}`)).json;
|
|
713
|
+
else {
|
|
714
|
+
const list = (await api('GET', `/repos/${task.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=30`)).json;
|
|
715
|
+
run = (list.workflow_runs || []).find((r) => r.display_title === checkName(task.id)) || null;
|
|
716
|
+
}
|
|
717
|
+
if (!run) {
|
|
718
|
+
// GitHub drops a dispatch it can't start; don't wait forever.
|
|
719
|
+
if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the repo.' });
|
|
720
|
+
saveTask(cwd, home, next);
|
|
721
|
+
return { ...next, events: [] };
|
|
722
|
+
}
|
|
723
|
+
next.runId = run.id;
|
|
724
|
+
next.runUrl = run.html_url;
|
|
725
|
+
if (run.status === 'completed') {
|
|
726
|
+
// Finished without a status file: it died before Craft could report (or an older engine ran).
|
|
727
|
+
const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
|
|
728
|
+
const check = (checks.check_runs || [])[0];
|
|
729
|
+
let data = {};
|
|
730
|
+
try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
|
|
731
|
+
if (check && check.status === 'completed' && data.mode) {
|
|
732
|
+
const { events = [], ...result } = data;
|
|
733
|
+
Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
|
|
734
|
+
saveTask(cwd, home, next);
|
|
735
|
+
return { ...next, events };
|
|
736
|
+
}
|
|
737
|
+
const why = run.conclusion === 'cancelled' ? 'The run was cancelled.' : (await diagnoseRun(api, task.repo, run.id)) || `The run ended (${run.conclusion}) before Craft could report back. The run log has details.`;
|
|
738
|
+
Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
|
|
739
|
+
} else {
|
|
740
|
+
next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
|
|
741
|
+
}
|
|
742
|
+
saveTask(cwd, home, next);
|
|
743
|
+
return { ...next, events: [] };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Download a cloud screenshot (shots/<task>/<n>.png on craft-sessions) to `dest`. */
|
|
747
|
+
export async function downloadShot({ token, repo, path: rel, dest, apiUrl, fetchImpl }) {
|
|
748
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
749
|
+
const r = await api('GET', `/repos/${repo}/contents/${rel.split('/').map(encodeURIComponent).join('/')}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
|
|
750
|
+
if (r.status !== 200 || !r.json || !r.json.content) return null;
|
|
751
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
752
|
+
fs.writeFileSync(dest, Buffer.from(r.json.content, 'base64'));
|
|
753
|
+
return dest;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Before coding: is the local branch behind GitHub (a cloud run merged work
|
|
758
|
+
* into it)? Only for projects whose cloud target is their own repo.
|
|
759
|
+
*/
|
|
760
|
+
export async function checkBehind(cwd) {
|
|
761
|
+
try {
|
|
762
|
+
const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
763
|
+
const upstream = await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => null);
|
|
764
|
+
if (!upstream) return { ok: false, reason: 'no upstream' };
|
|
765
|
+
await git(cwd, ['fetch', '-q', '--no-tags', upstream.split('/')[0]]);
|
|
766
|
+
const [behind, ahead] = (await git(cwd, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).split(/\s+/).map(Number);
|
|
767
|
+
const dirty = !!(await git(cwd, ['status', '--porcelain', '--untracked-files=no']));
|
|
768
|
+
const latest = behind ? (await git(cwd, ['log', '-1', '--format=%s', upstream]).catch(() => '')) : '';
|
|
769
|
+
return { ok: true, branch, upstream, behind, ahead, dirty, latest };
|
|
770
|
+
} catch (e) { return { ok: false, reason: e.message }; }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Pull what GitHub has (a fast-forward when possible, else a merge; stashes local edits around it). */
|
|
774
|
+
export async function pullLatest(cwd) {
|
|
775
|
+
const st = await checkBehind(cwd);
|
|
776
|
+
if (!st.ok) throw new Error(`Can't pull: ${st.reason}.`);
|
|
777
|
+
if (!st.behind) return { pulled: 0 };
|
|
778
|
+
let stashed = false;
|
|
779
|
+
if (st.dirty) { await git(cwd, ['stash', 'push', '-q', '-m', 'craft: before pulling cloud changes']); stashed = true; }
|
|
780
|
+
try {
|
|
781
|
+
await git(cwd, ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com', 'pull', '-q', '--no-rebase', '--no-edit']);
|
|
782
|
+
} catch (e) {
|
|
783
|
+
// A conflicting pull is undone, so the project is never left half-merged.
|
|
784
|
+
const conflicted = (await git(cwd, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).split('\n').filter(Boolean);
|
|
785
|
+
await git(cwd, ['merge', '--abort']).catch(() => {});
|
|
786
|
+
throw new Error(conflicted.length
|
|
787
|
+
? `GitHub's changes clash with your commits in ${conflicted.join(', ')}, so nothing was pulled. Commit or undo those, then pull again.`
|
|
788
|
+
: `Could not pull: ${e.message}`);
|
|
789
|
+
} finally {
|
|
790
|
+
if (stashed) await git(cwd, ['stash', 'pop', '-q']).catch(() => { throw new Error('Pulled, but your own edits clashed with them. They are saved in git stash.'); });
|
|
791
|
+
}
|
|
792
|
+
return { pulled: st.behind };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Runs started somewhere else (the phone, while this PC was off) become local
|
|
798
|
+
* tasks, so their changes can be pulled here. Only finished runs are imported.
|
|
799
|
+
*/
|
|
800
|
+
export async function importRemoteTasks({ cwd, token, home = defaultHome(), apiUrl, fetchImpl, limit = 20 }) {
|
|
801
|
+
const p = getProject(cwd, home);
|
|
802
|
+
if (!p || !p.repo) return [];
|
|
803
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
804
|
+
const known = new Set((p.tasks || []).map((t) => t.id));
|
|
805
|
+
const list = (await api('GET', `/repos/${p.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&status=completed&per_page=${limit}`, null, { allow: [404] })).json;
|
|
806
|
+
const added = [];
|
|
807
|
+
for (const run of (list && list.workflow_runs) || []) {
|
|
808
|
+
const id = String(run.display_title || '').replace(/^craft /, '');
|
|
809
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id) || known.has(id)) continue;
|
|
810
|
+
const checks = (await api('GET', `/repos/${p.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(id))}`)).json;
|
|
811
|
+
const check = (checks.check_runs || [])[0];
|
|
812
|
+
if (!check || check.status !== 'completed') continue;
|
|
813
|
+
let result = {};
|
|
814
|
+
try { result = JSON.parse(check.output.text || '{}'); } catch {}
|
|
815
|
+
delete result.events;
|
|
816
|
+
const task = {
|
|
817
|
+
id, prompt: result.prompt || '(started elsewhere)', mode: result.mode || 'Build', sessionId: result.sessionId || '', baseSha: result.baseSha || run.head_sha,
|
|
818
|
+
repo: p.repo, runId: run.id, runUrl: run.html_url, status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null,
|
|
819
|
+
startedAt: Date.parse(run.created_at) || Date.now(), finishedAt: Date.parse(run.updated_at) || Date.now(), remote: true,
|
|
820
|
+
};
|
|
821
|
+
saveTask(cwd, home, task);
|
|
822
|
+
added.push(task);
|
|
823
|
+
}
|
|
824
|
+
return added;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Stop a cloud run. */
|
|
828
|
+
export async function cancelCloudRun({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl }) {
|
|
829
|
+
const task = await cloudRunStatus({ cwd, taskId, token, home, apiUrl, fetchImpl });
|
|
830
|
+
if (!task.runId || ['done', 'failed', 'cancelled'].includes(task.status)) return task;
|
|
831
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
832
|
+
await api('POST', `/repos/${task.repo}/actions/runs/${task.runId}/cancel`, null, { allow: [409] });
|
|
833
|
+
return task;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Bring a finished run's code changes into the local project. Stages the
|
|
838
|
+
* current working tree first, so the 3-way merge keeps edits made since the
|
|
839
|
+
* run started; overlapping edits come back as conflict markers.
|
|
840
|
+
*/
|
|
841
|
+
export async function pullCloudRun({ cwd, taskId, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
|
|
842
|
+
const task = getTask(cwd, taskId, home);
|
|
843
|
+
if (!task) throw new Error('Unknown cloud task.');
|
|
844
|
+
if (task.status !== 'done') throw new Error('That cloud run has not finished.');
|
|
845
|
+
if (!task.result || !task.result.branch) return { files: [], conflicts: [], applied: false, message: 'That run did not change any files.' };
|
|
846
|
+
if (task.pulledAt) return { files: task.result.files || [], conflicts: [], applied: false, message: 'Already applied.' };
|
|
847
|
+
const m = await initMirrorGit(cwd, home);
|
|
848
|
+
const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${task.repo}.git`;
|
|
849
|
+
const ref = `refs/craft/tasks/${task.id}`;
|
|
850
|
+
await git(cwd, ['--git-dir', m.gitDir, 'fetch', '-q', '--no-tags', url, `+refs/heads/${task.result.branch}:${ref}`], { auth: authFor(token, serverUrl) });
|
|
851
|
+
const patch = await git(cwd, ['--git-dir', m.gitDir, 'diff', '--binary', '--full-index', task.baseSha, ref, '--', '.', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).codeply']);
|
|
852
|
+
if (!patch.trim()) return { files: [], conflicts: [], applied: false, message: 'That run did not change any files.' };
|
|
853
|
+
const files = (await git(cwd, ['--git-dir', m.gitDir, 'diff', '--name-only', task.baseSha, ref, '--', '.', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).codeply'])).split('\n').filter(Boolean);
|
|
854
|
+
await stageWorkingTree(cwd, home);
|
|
855
|
+
let conflicts = [];
|
|
856
|
+
try {
|
|
857
|
+
await m.G(['apply', '--3way', '--whitespace=nowarn', '-'], { input: `${patch}\n` });
|
|
858
|
+
} catch (e) {
|
|
859
|
+
const unmerged = await m.G(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
|
|
860
|
+
conflicts = unmerged.split('\n').filter(Boolean);
|
|
861
|
+
if (!conflicts.length) throw new Error(`Could not apply the changes: ${e.message}`);
|
|
862
|
+
}
|
|
863
|
+
saveTask(cwd, home, { ...task, pulledAt: Date.now(), conflicts });
|
|
864
|
+
return { files, conflicts, applied: true };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ─── The runner (inside GitHub Actions) ─────────────────────────────────
|
|
868
|
+
|
|
869
|
+
function describeTool(ev) {
|
|
870
|
+
const a = ev.args || {};
|
|
871
|
+
const what = a.path || a.file || a.command || a.pattern || a.query || a.url || '';
|
|
872
|
+
return `- ${ev.ok === false ? '(failed) ' : ''}${ev.name}${what ? ` ${String(what).split('\n')[0].slice(0, 100)}` : ''}`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function renderProgress(lines, note) {
|
|
876
|
+
const shown = lines.length > 60 ? [`- ... ${lines.length - 60} earlier steps`, ...lines.slice(-60)] : lines;
|
|
877
|
+
return `${note}\n\n${shown.join('\n')}`.slice(0, MAX_OUTPUT);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ─── The run as a chat: every step the agent took, published live ─────────
|
|
881
|
+
// Cloud chats look like local ones (commands, edits with their + and - lines,
|
|
882
|
+
// narration, the "what actually happened" card), so the runner records the
|
|
883
|
+
// same events the desktop gets from a local run and puts them in the check
|
|
884
|
+
// run's text, where the PC and the phone read them while it works.
|
|
885
|
+
|
|
886
|
+
const EVENT_BUDGET = 58000;
|
|
887
|
+
const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n...` : s);
|
|
888
|
+
|
|
889
|
+
/** One agent event as a small record; tool arguments kept the way the chat shows them. */
|
|
890
|
+
export function recordEvent(ev) {
|
|
891
|
+
if (ev.type === 'text') return { t: 'text', text: clipText(ev.text, 8000), interim: !!ev.interim };
|
|
892
|
+
if (ev.type === 'reasoning') return { t: 'reasoning', text: clipText(ev.text, 2000), ms: ev.ms };
|
|
893
|
+
if (ev.type === 'notice') return { t: 'notice', level: ev.level || 'info', text: clipText(ev.text, 1000) };
|
|
894
|
+
if (ev.type === 'tool_end') {
|
|
895
|
+
const a = ev.args || {};
|
|
896
|
+
let args;
|
|
897
|
+
if (ev.name === 'write_file') args = { path: a.path };
|
|
898
|
+
else if (ev.name === 'apply_patch') args = { files: (ev.meta && ev.meta.files) || [] };
|
|
899
|
+
else {
|
|
900
|
+
args = {};
|
|
901
|
+
for (const [k, v] of Object.entries(a)) {
|
|
902
|
+
if (typeof v === 'string') args[k] = clipText(v, k === 'search' || k === 'replace' ? 3000 : 1500);
|
|
903
|
+
else if (typeof v === 'number' || typeof v === 'boolean') args[k] = v;
|
|
904
|
+
else if (Array.isArray(v)) args[k] = v.slice(0, 50);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const m = ev.meta || {};
|
|
908
|
+
return {
|
|
909
|
+
t: 'tool', name: ev.name, ok: ev.ok, summary: clipText(ev.summary || '', 300), args,
|
|
910
|
+
...(typeof m.exitCode === 'number' ? { exitCode: m.exitCode } : {}),
|
|
911
|
+
...(typeof m.added === 'number' ? { added: m.added } : {}),
|
|
912
|
+
...(typeof m.removed === 'number' ? { removed: m.removed } : {}),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
if (ev.type === 'done' && Array.isArray(ev.actions)) {
|
|
916
|
+
const changed = ev.actions.filter((x) => x.ok && ['write_file', 'edit_file', 'apply_patch', 'fetch_image'].includes(x.tool))
|
|
917
|
+
.flatMap((x) => (Array.isArray(x.files) && x.files.length ? x.files.map((f) => ({ ...x, label: f })) : [x]));
|
|
918
|
+
const checks = ev.actions.filter((x) => ['run', 'browser_check'].includes(x.tool));
|
|
919
|
+
if (!changed.length && !checks.length) return null;
|
|
920
|
+
return {
|
|
921
|
+
t: 'summary', files: [...new Set(changed.map((x) => x.label))].slice(0, 30),
|
|
922
|
+
checks: [...new Map(checks.map((x) => [`${x.tool}|${x.label}`, x])).values()].slice(-8).map((x) => ({ tool: x.tool, label: x.label, ok: x.ok, exitCode: x.exitCode })),
|
|
923
|
+
unverified: ev.unverifiedFiles || [],
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Fit the event list in the check run's text: shorten diffs, drop thinking, then the oldest steps. */
|
|
930
|
+
export function packEvents(events, budget = EVENT_BUDGET) {
|
|
931
|
+
let list = events.map((e) => ({ ...e }));
|
|
932
|
+
const size = () => JSON.stringify(list).length;
|
|
933
|
+
if (size() <= budget) return list;
|
|
934
|
+
list = list.map((e) => (e.t === 'reasoning' ? { ...e, text: clipText(e.text, 200) } : e));
|
|
935
|
+
for (const n of [800, 200]) {
|
|
936
|
+
if (size() <= budget) return list;
|
|
937
|
+
list = list.map((e) => (e.t === 'tool' && e.args ? { ...e, args: Object.fromEntries(Object.entries(e.args).map(([k, v]) => [k, typeof v === 'string' ? clipText(v, n) : v])) } : e));
|
|
938
|
+
}
|
|
939
|
+
let dropped = 0;
|
|
940
|
+
while (size() > budget && list.length > 1) { list.shift(); dropped++; }
|
|
941
|
+
if (dropped) list.unshift({ t: 'notice', level: 'info', text: `${dropped} earlier step${dropped === 1 ? '' : 's'} are only in the run log.` });
|
|
942
|
+
return list;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ─── Files on the craft-sessions branch (chat memory, status, queue, shots) ──
|
|
946
|
+
|
|
947
|
+
export const SESSION_PATHS = {
|
|
948
|
+
history: (sid) => `sessions/${sid}.json`,
|
|
949
|
+
task: (id) => `tasks/${id}.json`,
|
|
950
|
+
live: (sid) => `live/${sid}.json`,
|
|
951
|
+
queue: (sid) => `queue/${sid}`,
|
|
952
|
+
shot: (id, n) => `shots/${id}/${n}.png`,
|
|
953
|
+
// What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
|
|
954
|
+
request: (id) => `requests/${id}.json`,
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* A task's optional bot: { name, prompt }, the full prompt the PC built
|
|
959
|
+
* (bots.js buildBotPrompt). Anything else, or an empty prompt, is no bot.
|
|
960
|
+
*/
|
|
961
|
+
export function cleanBot(bot) {
|
|
962
|
+
if (!bot || typeof bot !== 'object') return null;
|
|
963
|
+
const prompt = String(bot.prompt || '').trim().slice(0, MAX_BOT_PROMPT);
|
|
964
|
+
if (!prompt) return null;
|
|
965
|
+
return { name: String(bot.name || 'Bot').trim().slice(0, 80) || 'Bot', prompt };
|
|
966
|
+
}
|
|
967
|
+
/** 'task' is a job for a bot (not about the repo): its final reply is what the user hears. */
|
|
968
|
+
const cleanKind = (k) => (k === 'task' ? 'task' : 'code');
|
|
969
|
+
|
|
970
|
+
/** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
|
|
971
|
+
export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
|
|
972
|
+
const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
|
|
973
|
+
const read = async (p) => {
|
|
974
|
+
const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
975
|
+
if (r.status !== 200 || !r.json || Array.isArray(r.json)) return null;
|
|
976
|
+
let json = null;
|
|
977
|
+
try { json = JSON.parse(Buffer.from(r.json.content || '', 'base64').toString('utf8')); } catch {}
|
|
978
|
+
return { json, sha: r.json.sha, size: r.json.size };
|
|
979
|
+
};
|
|
980
|
+
const list = async (p) => {
|
|
981
|
+
const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
982
|
+
return r.status === 200 && Array.isArray(r.json) ? r.json.map((x) => ({ name: x.name, path: x.path, sha: x.sha })) : [];
|
|
983
|
+
};
|
|
984
|
+
const put = async (p, content, message) => {
|
|
985
|
+
for (let i = 0; i < 4; i++) {
|
|
986
|
+
const cur = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
987
|
+
const sha = cur.status === 200 && cur.json && !Array.isArray(cur.json) ? cur.json.sha : undefined;
|
|
988
|
+
const r = await api('PUT', `/repos/${repo}/contents/${enc(p)}`, { message, branch, content, ...(sha ? { sha } : {}) }, { allow: [409, 422] });
|
|
989
|
+
if (r.status < 300) return true;
|
|
990
|
+
await sleep(300 + i * 400);
|
|
991
|
+
}
|
|
992
|
+
return false;
|
|
993
|
+
};
|
|
994
|
+
const write = (p, obj, message = `Craft ${p}`) => put(p, Buffer.from(JSON.stringify(obj)).toString('base64'), message);
|
|
995
|
+
const writeBytes = (p, buf, message = `Craft ${p}`) => put(p, Buffer.from(buf).toString('base64'), message);
|
|
996
|
+
const remove = async (p, sha) => {
|
|
997
|
+
for (let i = 0; i < 3; i++) {
|
|
998
|
+
const s = sha || (await read(p) || {}).sha;
|
|
999
|
+
if (!s) return;
|
|
1000
|
+
const r = await api('DELETE', `/repos/${repo}/contents/${enc(p)}`, { message: `Craft ${p}`, branch, sha: s }, { allow: [404, 409, 422] });
|
|
1001
|
+
if (r.status < 300 || r.status === 404) return;
|
|
1002
|
+
sha = null;
|
|
1003
|
+
await sleep(300);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
const ensure = async (baseSha) => {
|
|
1007
|
+
const ref = await api('GET', `/repos/${repo}/git/ref/heads/${branch}`, null, { allow: [404] });
|
|
1008
|
+
if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha }, { allow: [422] });
|
|
1009
|
+
};
|
|
1010
|
+
return { read, list, write, writeBytes, remove, ensure };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function loadHistory(files, sessionId) {
|
|
1014
|
+
if (!sessionId) return { messages: [] };
|
|
1015
|
+
const f = await files.read(SESSION_PATHS.history(sessionId));
|
|
1016
|
+
return { messages: f && f.json && Array.isArray(f.json.messages) ? f.json.messages : [] };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function saveHistory(files, sessionId, messages) {
|
|
1020
|
+
if (!sessionId) return;
|
|
1021
|
+
await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const branchSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').slice(0, 6).join('-').slice(0, 40) || 'change';
|
|
1025
|
+
export const cloudBranchName = (prompt, id) => `craft/${branchSlug(prompt)}-${String(id).slice(-6)}`;
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* `codeply cloud runner`, inside GitHub Actions. Runs the dispatched task, then
|
|
1029
|
+
* (for a chat) stays up to CRAFT_IDLE_MS (2 minutes) waiting for the next
|
|
1030
|
+
* message in queue/<session>, so a follow-up starts at once instead of waiting
|
|
1031
|
+
* for a new runner. Each task:
|
|
1032
|
+
* - runs like a local run, with a real browser (screenshots) when Chrome is there
|
|
1033
|
+
* - publishes its steps live to tasks/<id>.json (and the first one's check run)
|
|
1034
|
+
* - commits Build work to craft/<what-it-did>-<id>, pushes it, and merges it
|
|
1035
|
+
* into the base branch (CRAFT_MERGE=false keeps it as a branch only)
|
|
1036
|
+
* @returns {Promise<{status:'done'|'failed', result:object, results:object[]}>}
|
|
1037
|
+
*/
|
|
1038
|
+
export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs = 4000, idleMs, pollMs = 3000, browserImpl }) {
|
|
1039
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
1040
|
+
const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
1041
|
+
const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
1042
|
+
const headSha = env.GITHUB_SHA;
|
|
1043
|
+
if (!repo || !token || !firstId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' }, results: [] };
|
|
1044
|
+
const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
|
|
1045
|
+
const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
|
|
1046
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
1047
|
+
const files = branchFiles(api, repo);
|
|
1048
|
+
const idle = idleMs != null ? idleMs : Number(env.CRAFT_IDLE_MS || 120000);
|
|
1049
|
+
const base = env.CRAFT_BASE || env.GITHUB_REF_NAME || 'main';
|
|
1050
|
+
const merge = String(env.CRAFT_MERGE || 'true') !== 'false';
|
|
1051
|
+
await files.ensure(headSha).catch((e) => log(`Could not create ${SESSIONS_BRANCH}: ${e.message}`));
|
|
1052
|
+
await loadCloudConnections({ env, log });
|
|
1053
|
+
|
|
1054
|
+
// The project's environment for running and testing: never committed.
|
|
1055
|
+
const envFile = path.join(cwd, '.env');
|
|
1056
|
+
if (env.CRAFT_ENV && String(env.CRAFT_ENV).trim()) {
|
|
1057
|
+
try { if (!fs.existsSync(envFile)) fs.writeFileSync(envFile, `${String(env.CRAFT_ENV).trim()}\n`); } catch (e) { log(`Could not write .env: ${e.message}`); }
|
|
1058
|
+
}
|
|
1059
|
+
const browser = browserImpl !== undefined ? browserImpl : (await import('./cloud-browser.mjs')).makeCloudBrowser();
|
|
1060
|
+
|
|
1061
|
+
const heartbeat = (busy, until) => files.write(SESSION_PATHS.live(sessionId), { runId: env.GITHUB_RUN_ID || null, busy, at: Date.now(), until }, `Craft live ${sessionId}`).catch(() => {});
|
|
1062
|
+
|
|
1063
|
+
let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
|
|
1064
|
+
// A bot (and the task kind) comes next to the dispatch, on craft-sessions.
|
|
1065
|
+
const req = await files.read(SESSION_PATHS.request(firstId)).catch(() => null);
|
|
1066
|
+
if (req && req.json) {
|
|
1067
|
+
task = { ...task, bot: req.json.bot, kind: req.json.kind };
|
|
1068
|
+
await files.remove(SESSION_PATHS.request(firstId), req.sha).catch(() => {});
|
|
1069
|
+
}
|
|
1070
|
+
const results = [];
|
|
1071
|
+
try {
|
|
1072
|
+
while (task) {
|
|
1073
|
+
if (sessionId) await heartbeat(true, Date.now() + 60 * 60 * 1000);
|
|
1074
|
+
results.push(await runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat: sessionId ? () => heartbeat(true, Date.now() + 60 * 60 * 1000) : null }));
|
|
1075
|
+
if (!sessionId || idle <= 0 || setupError) break;
|
|
1076
|
+
task = await waitForNext({ files, sessionId, idle, pollMs, heartbeat });
|
|
1077
|
+
}
|
|
1078
|
+
} finally {
|
|
1079
|
+
if (sessionId) await files.remove(SESSION_PATHS.live(sessionId)).catch(() => {});
|
|
1080
|
+
if (browser && browser.close) await browser.close().catch(() => {});
|
|
1081
|
+
}
|
|
1082
|
+
const last = results[results.length - 1] || { status: 'failed', result: { error: 'Nothing ran.' } };
|
|
1083
|
+
return { ...last, results };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Idle: the next queued message for this chat, or null after `idle` ms of nothing. */
|
|
1087
|
+
async function waitForNext({ files, sessionId, idle, pollMs, heartbeat }) {
|
|
1088
|
+
const deadline = Date.now() + idle;
|
|
1089
|
+
let beat = 0;
|
|
1090
|
+
while (Date.now() < deadline) {
|
|
1091
|
+
if (Date.now() - beat > 20000) { await heartbeat(false, deadline); beat = Date.now(); }
|
|
1092
|
+
const items = (await files.list(SESSION_PATHS.queue(sessionId)).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
|
|
1093
|
+
for (const it of items) {
|
|
1094
|
+
const f = await files.read(it.path).catch(() => null);
|
|
1095
|
+
await files.remove(it.path, f && f.sha).catch(() => {});
|
|
1096
|
+
const t = f && f.json;
|
|
1097
|
+
if (!t || !t.id) continue;
|
|
1098
|
+
// Picked up by another runner already (it was re-dispatched): skip.
|
|
1099
|
+
if (await files.read(SESSION_PATHS.task(t.id)).catch(() => null)) continue;
|
|
1100
|
+
return { ...t, sessionId, dispatched: false };
|
|
1101
|
+
}
|
|
1102
|
+
await sleep(pollMs);
|
|
1103
|
+
}
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat }) {
|
|
1108
|
+
const taskId = String(task.id).replace(/[^A-Za-z0-9_-]/g, '');
|
|
1109
|
+
const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
|
|
1110
|
+
const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
|
|
1111
|
+
const sessionId = task.sessionId || '';
|
|
1112
|
+
const bot = cleanBot(task.bot);
|
|
1113
|
+
const kind = cleanKind(task.kind);
|
|
1114
|
+
const startedAt = Date.now();
|
|
1115
|
+
const events = [];
|
|
1116
|
+
const lines = [];
|
|
1117
|
+
|
|
1118
|
+
// The dispatched task also gets a check run: it is how a failed start is diagnosed.
|
|
1119
|
+
const check = task.dispatched ? (await api('POST', `/repos/${repo}/check-runs`, {
|
|
1120
|
+
name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
|
|
1121
|
+
output: { title: 'Starting', summary: 'Starting...' },
|
|
1122
|
+
}).catch(() => ({ json: null }))).json : null;
|
|
1123
|
+
|
|
1124
|
+
const statusDoc = (status, extra = {}) => ({ id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
|
|
1125
|
+
const finish = async (ok, title, summary, result) => {
|
|
1126
|
+
const baseLen = JSON.stringify(result).length;
|
|
1127
|
+
const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
|
|
1128
|
+
await files.write(SESSION_PATHS.task(taskId), statusDoc(ok ? 'done' : 'failed', { ...result, events: packEvents(events, 400000) }), `Craft task ${taskId}: ${title}`).catch(() => {});
|
|
1129
|
+
if (check) {
|
|
1130
|
+
await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
|
|
1131
|
+
status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
|
|
1132
|
+
output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
|
|
1133
|
+
}).catch((e) => log(`Could not finish the check run: ${e.message}`));
|
|
1134
|
+
}
|
|
1135
|
+
return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
|
|
1139
|
+
if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
|
|
1140
|
+
|
|
1141
|
+
let lastPush = 0; let pending = null;
|
|
1142
|
+
const pushProgress = async (note, force = false) => {
|
|
1143
|
+
if (!force && Date.now() - lastPush < updateMs) return;
|
|
1144
|
+
lastPush = Date.now();
|
|
1145
|
+
const packed = packEvents(events);
|
|
1146
|
+
pending = Promise.all([
|
|
1147
|
+
files.write(SESSION_PATHS.task(taskId), statusDoc('running', { events: packEvents(events, 400000) }), `Craft task ${taskId}`).catch(() => {}),
|
|
1148
|
+
check ? api('PATCH', `/repos/${repo}/check-runs/${check.id}`, { output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packed }) } }).catch(() => {}) : null,
|
|
1149
|
+
heartbeat ? heartbeat() : null,
|
|
1150
|
+
]);
|
|
1151
|
+
await pending;
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
// Screenshots go next to the status, where the PC and the phone read them.
|
|
1155
|
+
let shots = 0; let pendingShot = null;
|
|
1156
|
+
const agentBrowser = browser ? async (url, opts) => {
|
|
1157
|
+
const r = await browser(url, opts);
|
|
1158
|
+
if (r && r.ok && r.screenshotPath) {
|
|
1159
|
+
try {
|
|
1160
|
+
const rel = SESSION_PATHS.shot(taskId, ++shots);
|
|
1161
|
+
if (await files.writeBytes(rel, fs.readFileSync(r.screenshotPath), `Craft screenshot ${taskId}`)) pendingShot = rel;
|
|
1162
|
+
} catch {}
|
|
1163
|
+
}
|
|
1164
|
+
return r;
|
|
1165
|
+
} : undefined;
|
|
1166
|
+
|
|
1167
|
+
try {
|
|
1168
|
+
// Where this task starts (a later message in the same runner starts after the earlier merge).
|
|
1169
|
+
const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
|
|
1170
|
+
const hist = await loadHistory(files, sessionId);
|
|
1171
|
+
await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
|
|
1172
|
+
const approve = ciApprove(cwd);
|
|
1173
|
+
let answer = ''; let steps = 0; let failure = '';
|
|
1174
|
+
const userMessage = kind === 'task'
|
|
1175
|
+
? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
|
|
1176
|
+
: `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
|
|
1177
|
+
for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps, ...(bot ? { botPrompt: bot.prompt } : {}) })) {
|
|
1178
|
+
const rec = recordEvent(ev);
|
|
1179
|
+
if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
|
|
1180
|
+
// The final answer arrives as the reply; everything else is part of the chat's steps.
|
|
1181
|
+
if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
|
|
1182
|
+
if (ev.type === 'text' && !ev.interim) answer = ev.text;
|
|
1183
|
+
else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev)); }
|
|
1184
|
+
else if (ev.type === 'error') failure = ev.error;
|
|
1185
|
+
if (rec) await pushProgress(`Working: ${steps} step${steps === 1 ? '' : 's'} so far.`);
|
|
1186
|
+
if (ev.type === 'done' || ev.type === 'error' || ev.type === 'aborted') break;
|
|
1187
|
+
}
|
|
1188
|
+
if (pending) await pending;
|
|
1189
|
+
if (failure && !answer) throw new Error(failure);
|
|
1190
|
+
|
|
1191
|
+
// The bot's own last words, before a plan file replaces the answer: what the phone speaks.
|
|
1192
|
+
const reply = answer;
|
|
1193
|
+
if (mode === 'Plan') {
|
|
1194
|
+
const dir = path.join(cwd, '.codeply', 'plans');
|
|
1195
|
+
const plans = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
|
|
1196
|
+
if (plans.length) answer = fs.readFileSync(path.join(dir, plans[0].f), 'utf8');
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
|
|
1200
|
+
if (mode === 'Build') {
|
|
1201
|
+
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
|
|
1202
|
+
const staged = await git(cwd, ['diff', '--cached', '--name-only']);
|
|
1203
|
+
if (staged) {
|
|
1204
|
+
changed = staged.split('\n').filter(Boolean);
|
|
1205
|
+
// Per-file + and - lines, for the chat's record of what was pushed.
|
|
1206
|
+
stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
|
|
1207
|
+
const [add, del, ...name] = l.split('\t');
|
|
1208
|
+
return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
|
|
1209
|
+
});
|
|
1210
|
+
branch = cloudBranchName(prompt, taskId);
|
|
1211
|
+
const subject = prompt.split('\n')[0].slice(0, 60);
|
|
1212
|
+
await git(cwd, [...BOT, 'commit', '-q', '-m', `${subject}\n\nCraft cloud task ${taskId}.`]);
|
|
1213
|
+
sha = await git(cwd, ['rev-parse', 'HEAD']);
|
|
1214
|
+
await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
|
|
1215
|
+
// Into the base branch too, so the next run (and the PC's pull) starts from it.
|
|
1216
|
+
if (merge) {
|
|
1217
|
+
const m = await api('POST', `/repos/${repo}/merges`, { base, head: branch, commit_message: `Merge ${branch}: ${subject}` }, { allow: [204, 404, 409] }).catch((e) => ({ status: 0, error: e.message }));
|
|
1218
|
+
merged = m.status === 201 || m.status === 204
|
|
1219
|
+
? { ok: true, base, sha: m.json && m.json.sha ? m.json.sha : sha }
|
|
1220
|
+
: { ok: false, base, reason: m.status === 409 ? `it conflicts with newer changes on ${base}` : (m.error || `GitHub answered ${m.status}`) };
|
|
1221
|
+
if (merged.ok) {
|
|
1222
|
+
// Keep working from the merged base for the next message in this chat.
|
|
1223
|
+
await git(cwd, ['fetch', '-q', 'origin', base], { auth: authFor(token, serverUrl) }).catch(() => {});
|
|
1224
|
+
await git(cwd, ['reset', '-q', '--hard', 'FETCH_HEAD']).catch(() => {});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
|
|
1228
|
+
const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
|
|
1229
|
+
events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
|
|
1234
|
+
.catch((e) => log(`Could not save the chat: ${e.message}`));
|
|
1235
|
+
const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase,
|
|
1236
|
+
...(bot || kind === 'task' ? { reply: reply.slice(0, 20000) } : {}) };
|
|
1237
|
+
const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
|
|
1238
|
+
return finish(true, title, answer || title, result);
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Route and token from the runner environment, with secrets removed from process.env. */
|
|
1245
|
+
export function runnerSetup(env = process.env) {
|
|
1246
|
+
const cfg = { ...env };
|
|
1247
|
+
for (const k of ['GITHUB_TOKEN', 'GH_TOKEN', 'CODEPLY_API_KEY', 'ACTIONS_RUNTIME_TOKEN', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN']) delete env[k];
|
|
1248
|
+
const r = routeFromEnv(cfg);
|
|
1249
|
+
return { cfg, token: cfg.GITHUB_TOKEN || cfg.GH_TOKEN, route: r.route, setupError: r.error || null };
|
|
1250
|
+
}
|