From 2b8a5dfc99eccf317641accb1ef12b298898a41a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 12:44:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=93=E9=80=9A=20v0.1=20runner=20jo?= =?UTF-8?q?b=20=E6=AD=A3=E5=BC=8F=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 128 ++++++++++++++++ deploy/container/Containerfile | 11 +- deploy/templates/tekton/pipeline.yaml | 3 + docs/reference/spec-v01-agentrun-runner.md | 2 +- docs/reference/spec-v01-cicd.md | 6 +- docs/reference/spec-v01-cli.md | 4 +- .../reference/spec-v01-secret-distribution.md | 2 +- package.json | 1 + scripts/src/cli.ts | 21 ++- scripts/src/gitops-render.ts | 40 ++++- src/mgr/kubernetes-runner-job.ts | 137 ++++++++++++++++++ src/mgr/postgres-store.ts | 15 +- src/mgr/server.ts | 36 ++++- src/mgr/store.ts | 15 ++ src/runner/k8s-job.ts | 5 +- src/runner/manager-api.ts | 4 + src/runner/run-once.ts | 1 + src/selftest/cases/20-runner-k8s-job.ts | 38 ++++- src/selftest/cases/30-codex-stdio.ts | 6 + 19 files changed, 457 insertions(+), 18 deletions(-) create mode 100644 bun.lock create mode 100644 src/mgr/kubernetes-runner-job.ts diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2679f92 --- /dev/null +++ b/bun.lock @@ -0,0 +1,128 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "agentrun", + "dependencies": { + "@openai/codex": "0.133.0", + "pg": "^8.13.1", + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + + "@openai/codex": ["@openai/codex@0.133.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.133.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.133.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.133.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.133.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.133.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.133.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ=="], + + "@openai/codex-darwin-arm64": ["@openai/codex@0.133.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg=="], + + "@openai/codex-darwin-x64": ["@openai/codex@0.133.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg=="], + + "@openai/codex-linux-arm64": ["@openai/codex@0.133.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ=="], + + "@openai/codex-linux-x64": ["@openai/codex@0.133.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA=="], + + "@openai/codex-win32-arm64": ["@openai/codex@0.133.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g=="], + + "@openai/codex-win32-x64": ["@openai/codex@0.133.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ=="], + + "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + } +} diff --git a/deploy/container/Containerfile b/deploy/container/Containerfile index 29a66d0..94c9d05 100644 --- a/deploy/container/Containerfile +++ b/deploy/container/Containerfile @@ -1,11 +1,20 @@ FROM oven/bun:1.2.15-alpine WORKDIR /app +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY ENV NODE_ENV=production ENV PORT=8080 +ENV AGENTRUN_CODEX_COMMAND=/app/node_modules/.bin/codex + +RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \ + apk add --no-cache ca-certificates kubectl nodejs COPY package.json tsconfig.json ./ -RUN bun install --production +RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \ + bun install --production +RUN /app/node_modules/.bin/codex --version && /app/node_modules/.bin/codex app-server --help >/dev/null COPY scripts ./scripts COPY src ./src COPY deploy/deploy.json ./deploy/deploy.json diff --git a/deploy/templates/tekton/pipeline.yaml b/deploy/templates/tekton/pipeline.yaml index 956b4dd..88c587c 100644 --- a/deploy/templates/tekton/pipeline.yaml +++ b/deploy/templates/tekton/pipeline.yaml @@ -170,6 +170,9 @@ spec: --local context=. \ --local dockerfile=deploy/container \ --opt filename=Containerfile \ + --opt build-arg:HTTP_PROXY=http://127.0.0.1:10808 \ + --opt build-arg:HTTPS_PROXY=http://127.0.0.1:10808 \ + --opt build-arg:NO_PROXY=hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.42.0.0/16,10.43.0.0/16,.svc,.cluster.local \ --output type=image,name="$image",push=true,registry.insecure=true digest="$(curl -fsSI "http://127.0.0.1:5000/v2/agentrun/agentrun-mgr/manifests/$(params.revision)" | awk -F': ' 'tolower($1)=="docker-content-digest" {gsub(/\r/,"",$2); print $2; exit}')" test -n "$digest" diff --git a/docs/reference/spec-v01-agentrun-runner.md b/docs/reference/spec-v01-agentrun-runner.md index 023f85c..6cc686a 100644 --- a/docs/reference/spec-v01-agentrun-runner.md +++ b/docs/reference/spec-v01-agentrun-runner.md @@ -99,7 +99,7 @@ Runner 日志必须实时 flush 到文件或 pod log,CLI 启动 runner 时必 | 规格项 | 状态 | 说明 | | --- | --- | --- | | `agentrun-runner` 服务规格 | 已定义 | 本文为 v0.1 runner 权威。 | -| Kubernetes Job runner | 部分实现 | 已提供 `runner job --dry-run` Job manifest 渲染骨架,固定使用 `agentrun-v01-runner` ServiceAccount、manager URL、runId/commandId/attemptId、executionPolicy 和 SecretRef 文件投影;尚未执行真实 Kubernetes create/apply。 | +| Kubernetes Job runner | 部分实现 | 已提供 `runner job --dry-run` Job manifest 渲染骨架;正式 `runner job` 通过 manager REST 创建 Kubernetes Job,固定使用 `agentrun-v01-runner` ServiceAccount、manager URL、runId/commandId/attemptId、executionPolicy 和 SecretRef 文件投影;真实集群综合联调仍需验收。 | | host process runner | 部分实现 | `runner start` 和 `src/runner/main.ts` 进入同一套 `runOnce`,可通过 manager register/claim/poll/report 执行自测试。 | | claim/lease/report client | 部分实现 | 已拆出 runner manager API client,覆盖 register、claim、lease heartbeat、poll command、ack、append event 和 terminal status;durable store 仍待 Postgres adapter 接入。 | | runner redaction | 已定义/未实现 | 需与 backend adapter 共同实现。 | diff --git a/docs/reference/spec-v01-cicd.md b/docs/reference/spec-v01-cicd.md index 38a6ac8..fea3377 100644 --- a/docs/reference/spec-v01-cicd.md +++ b/docs/reference/spec-v01-cicd.md @@ -157,7 +157,7 @@ Tekton promotion 可以读取 `deploy/deploy.json` 来 render runtime desired st | --- | --- | --- | | `v0.1` source branch | 已建立 | `origin/v0.1` 存在。 | | `G14:/root/agentrun-v01` source worktree | 已建立 | 固定 source worktree 使用 `v0.1` 分支。 | -| `agentrun-v01` namespace | 未实现 | 需要后续初始化。 | -| `v0.1-gitops` branch | 未实现 | 需要后续初始化。 | -| 纯 Tekton/Argo lane | 未实现 | 需要后续按本文补齐;不得引入自定义 runner。 | +| `agentrun-v01` namespace | 部分实现 | GitOps rendered manifest 已声明 namespace;运行面初始化和 Argo 同步仍需综合联调验收。 | +| `v0.1-gitops` branch | 部分实现 | Tekton promotion 已生成 artifact catalog 与 runtime desired state;后续每个 source commit 仍需由 PipelineRun 推进。 | +| 纯 Tekton/Argo lane | 部分实现 | 已有 `agentrun-ci` Pipeline、BuildKit 镜像发布、GitOps promotion 模板和 Argo Application 模板;真实 runtime sync 与综合联调仍需收口。 | | `dev/prod` 废弃口径 | 已定义 | 本文明确 `agentrun_dev` 和 `agentrun_prod` 不作为当前规格目标。 | diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index 764bee0..7d29828 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -30,6 +30,8 @@ bun scripts/agentrun-cli.ts runs events --after-seq --limit bun scripts/agentrun-cli.ts commands create --type turn --json-file bun scripts/agentrun-cli.ts commands show bun scripts/agentrun-cli.ts runner start --run-id --backend +bun scripts/agentrun-cli.ts runner job --run-id --command-id +bun scripts/agentrun-cli.ts runner job --dry-run --run-id --command-id --image bun scripts/agentrun-cli.ts secrets codex render --dry-run [--codex-home ] bun scripts/agentrun-cli.ts backends list bun scripts/agentrun-cli.ts server start|status|stop|logs @@ -76,5 +78,5 @@ bun scripts/agentrun-cli.ts server start|status|stop|logs | AgentRun CLI 规格 | 已定义 | 本文为 v0.1 CLI 权威。 | | `scripts/agentrun-cli.ts` | 部分实现 | 已提供 run/command/event/backend/server 基础命令和 JSON envelope。 | | CLI 调 manager REST | 部分实现 | CLI 通过 `ManagerClient` 调 manager REST;当前自测试使用内存 manager。 | -| runner start | 部分实现 | `runner start` 可执行 host process runner;`runner job --dry-run` 可渲染 Kubernetes Job JSON,尚不执行 create/apply。 | +| runner start/job | 部分实现 | `runner start` 可执行 host process runner;`runner job --dry-run` 可渲染 Kubernetes Job JSON;`runner job` 正式路径通过 manager REST 创建 Kubernetes Job 并快速返回 job identity、SecretRef 和轮询命令。 | | CLI 测试规格 | 已定义 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md)。 | diff --git a/docs/reference/spec-v01-secret-distribution.md b/docs/reference/spec-v01-secret-distribution.md index 8487f7b..fd7ea5c 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -151,7 +151,7 @@ bun scripts/agentrun-cli.ts secrets codex render --dry-run | 规格项 | 状态 | 说明 | | --- | --- | --- | | Secret 分发规格 | 已定义 | 本文为 v0.1 provider credential 分发权威。 | -| Kubernetes SecretRef 注入 | 部分实现 | runner Job dry-run 渲染已按 run `executionPolicy.secretScope.providerCredentials` 生成 Secret volume projection 和 `CODEX_HOME`,但尚未 apply 到集群。 | +| Kubernetes SecretRef 注入 | 部分实现 | runner Job dry-run 和正式 Job 创建路径已按 run `executionPolicy.secretScope.providerCredentials` 生成 Secret volume projection 和 `CODEX_HOME`;真实 Secret 与 Codex turn 仍需综合联调验收。 | | Codex Secret dry-run 工具 | 已实现 | `bun scripts/agentrun-cli.ts secrets codex render --dry-run` 只输出 Secret 创建计划、hash 和 redacted manifest 摘要,不执行 apply。 | | Codex auth/config file projection | 部分实现 | backend readiness 检查 `auth.json`/`config.toml` 可读性,缺失时返回 `secret-unavailable`;self-test 使用临时文件模拟投影。 | | redaction 最小规则 | 部分实现 | Secret dry-run 工具、event、Job dry-run 输出和 self-test 已验证不打印测试 token;复杂审计仍待后续补齐。 | diff --git a/package.json b/package.json index 32cb657..3a7a82a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "cli": "bun scripts/agentrun-cli.ts" }, "dependencies": { + "@openai/codex": "0.133.0", "pg": "^8.13.1" }, "devDependencies": { diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 2136102..a10aae4 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -67,13 +67,27 @@ async function dispatch(args: ParsedArgs): Promise { } async function renderRunnerJob(args: ParsedArgs): Promise { - if (args.flags.get("dry-run") !== true) throw new AgentRunError("schema-invalid", "runner job only supports --dry-run in v0.1", { httpStatus: 2 }); const runId = flag(args, "run-id", ""); const commandId = flag(args, "command-id", ""); - const image = flag(args, "image", ""); if (!runId) throw new AgentRunError("schema-invalid", "runner job requires --run-id", { httpStatus: 2 }); if (!commandId) throw new AgentRunError("schema-invalid", "runner job requires --command-id", { httpStatus: 2 }); - if (!image) throw new AgentRunError("schema-invalid", "runner job requires --image", { httpStatus: 2 }); + const image = optionalFlag(args, "image"); + if (args.flags.get("dry-run") !== true) { + const body: JsonRecord = { commandId }; + if (image) body.image = image; + const namespace = optionalFlag(args, "namespace"); + const attemptId = optionalFlag(args, "attempt-id"); + const runnerId = optionalFlag(args, "runner-id"); + const sourceCommit = optionalFlag(args, "source-commit"); + const runnerManagerUrl = optionalFlag(args, "runner-manager-url"); + if (namespace) body.namespace = namespace; + if (attemptId) body.attemptId = attemptId; + if (runnerId) body.runnerId = runnerId; + if (sourceCommit) body.sourceCommit = sourceCommit; + if (runnerManagerUrl) body.managerUrl = runnerManagerUrl; + return await client(args).post(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, body) as JsonRecord; + } + if (!image) throw new AgentRunError("schema-invalid", "runner job --dry-run requires --image", { httpStatus: 2 }); const run = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}`) as RunRecord; const options = { run, @@ -175,6 +189,7 @@ function help(): JsonRecord { "commands create --type turn --json-file ", "commands show --run-id ", "runner start --run-id ", + "runner job --run-id --command-id [--image ] [--runner-manager-url ]", "runner job --dry-run --run-id --command-id --image ", "secrets codex render --dry-run [--codex-home ] [--namespace agentrun-v01] [--secret-name agentrun-v01-provider-codex]", "backends list", diff --git a/scripts/src/gitops-render.ts b/scripts/src/gitops-render.ts index 00ecf4d..cc4df37 100644 --- a/scripts/src/gitops-render.ts +++ b/scripts/src/gitops-render.ts @@ -50,7 +50,7 @@ export async function renderGitops(options: RenderOptions): Promise await writeFile(path.join(options.outDir, "runtime-v01", "kustomization.yaml"), kustomizationYaml()); await writeFile(path.join(options.outDir, "runtime-v01", "namespace.yaml"), namespaceYaml(runtimeNamespace)); await writeFile(path.join(options.outDir, "runtime-v01", "postgres.yaml"), postgresYaml(runtimeNamespace)); - await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image)); + await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit)); await writeFile(path.join(options.outDir, "runtime-v01", "runner-rbac.yaml"), runnerRbacYaml(runtimeNamespace)); return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: image.repositoryDigest, sourceCommit: options.sourceCommit }; } @@ -209,7 +209,7 @@ spec: `; } -function managerYaml(namespace: string, image: { repositoryDigest: string }): string { +function managerYaml(namespace: string, image: { repositoryDigest: string }, sourceCommit: string): string { return `apiVersion: v1 kind: ServiceAccount metadata: @@ -262,6 +262,16 @@ spec: secretKeyRef: name: agentrun-v01-mgr-db key: DATABASE_URL + - name: AGENTRUN_SOURCE_COMMIT + value: ${JSON.stringify(sourceCommit)} + - name: AGENTRUN_RUNTIME_NAMESPACE + value: ${JSON.stringify(namespace)} + - name: AGENTRUN_INTERNAL_MGR_URL + value: ${JSON.stringify(`http://agentrun-mgr.${namespace}.svc.cluster.local:8080`)} + - name: AGENTRUN_RUNNER_IMAGE + value: ${JSON.stringify(image.repositoryDigest)} + - name: AGENTRUN_RUNNER_SERVICE_ACCOUNT + value: "agentrun-v01-runner" readinessProbe: httpGet: path: /health/readiness @@ -277,6 +287,32 @@ spec: limits: cpu: 800m memory: 1Gi +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: agentrun-v01-mgr-runner-job-controller + namespace: ${namespace} +rules: + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create", "get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: agentrun-v01-mgr-runner-job-controller + namespace: ${namespace} +subjects: + - kind: ServiceAccount + name: agentrun-v01-mgr +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: agentrun-v01-mgr-runner-job-controller `; } diff --git a/src/mgr/kubernetes-runner-job.ts b/src/mgr/kubernetes-runner-job.ts new file mode 100644 index 0000000..7451177 --- /dev/null +++ b/src/mgr/kubernetes-runner-job.ts @@ -0,0 +1,137 @@ +import { spawn } from "node:child_process"; +import { AgentRunError } from "../common/errors.js"; +import { redactJson, redactText } from "../common/redaction.js"; +import type { AgentRunStore } from "./store.js"; +import type { JsonRecord } from "../common/types.js"; +import { renderRunnerJobManifest } from "../runner/k8s-job.js"; + +export interface RunnerJobDefaults { + namespace: string; + managerUrl: string; + image: string; + sourceCommit: string; + serviceAccountName?: string; + kubectlCommand?: string; +} + +export interface CreateRunnerJobInput extends JsonRecord { + commandId: string; + managerUrl?: string; + image?: string; + namespace?: string; + attemptId?: string; + runnerId?: string; + sourceCommit?: string; + serviceAccountName?: string; +} + +export async function createKubernetesRunnerJob(options: { store: AgentRunStore; runId: string; input: CreateRunnerJobInput; defaults: RunnerJobDefaults }): Promise { + const commandId = stringField(options.input, "commandId"); + const run = await options.store.getRun(options.runId); + const command = await options.store.getCommand(commandId); + if (command.runId !== run.id) throw new AgentRunError("schema-invalid", `command ${commandId} does not belong to run ${run.id}`, { httpStatus: 400 }); + if (command.type !== "turn") throw new AgentRunError("schema-invalid", `command ${commandId} is not a turn command`, { httpStatus: 400 }); + + const image = optionalString(options.input.image) ?? options.defaults.image; + if (!image) throw new AgentRunError("schema-invalid", "runner job image is required; set --image or AGENTRUN_RUNNER_IMAGE", { httpStatus: 400 }); + const namespace = optionalString(options.input.namespace) ?? options.defaults.namespace; + const managerUrl = optionalString(options.input.managerUrl) ?? options.defaults.managerUrl; + const sourceCommit = optionalString(options.input.sourceCommit) ?? options.defaults.sourceCommit; + const serviceAccountName = optionalString(options.input.serviceAccountName) ?? options.defaults.serviceAccountName; + + const renderOptions = { + run, + commandId, + managerUrl, + image, + namespace, + sourceCommit, + ...(serviceAccountName ? { serviceAccountName } : {}), + }; + const attemptId = optionalString(options.input.attemptId); + const runnerId = optionalString(options.input.runnerId); + const render = renderRunnerJobManifest({ ...renderOptions, ...(attemptId ? { attemptId } : {}), ...(runnerId ? { runnerId } : {}) }); + const created = await kubectlCreate(render.manifest, options.defaults.kubectlCommand ?? "kubectl"); + return { + action: "create-kubernetes-job", + mutation: true, + jobIdentity: { + kind: "Job", + namespace: render.namespace, + name: render.jobName, + serviceAccountName: render.serviceAccountName, + uid: objectPath(created, ["metadata", "uid"]), + }, + runner: { + runId: run.id, + commandId, + attemptId: render.attemptId, + runnerId: render.runnerId, + backendProfile: run.backendProfile, + managerUrl, + sourceCommit, + placement: "kubernetes-job", + logPath: `kubectl -n ${render.namespace} logs job/${render.jobName}`, + }, + secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.mountPath, valuesPrinted: false })), + pollCommands: { + run: `bun scripts/agentrun-cli.ts runs show ${run.id} --manager-url ${managerUrl}`, + command: `bun scripts/agentrun-cli.ts commands show ${commandId} --run-id ${run.id} --manager-url ${managerUrl}`, + events: `bun scripts/agentrun-cli.ts runs events ${run.id} --manager-url ${managerUrl} --after-seq 0 --limit 100`, + }, + warnings: render.warnings, + kubernetes: { + created: true, + valuesPrinted: false, + apiVersion: objectPath(created, ["apiVersion"]), + kind: objectPath(created, ["kind"]), + resourceVersion: objectPath(created, ["metadata", "resourceVersion"]), + }, + }; +} + +async function kubectlCreate(manifest: JsonRecord, kubectlCommand: string): Promise { + const child = spawn(kubectlCommand, ["create", "-f", "-", "-o", "json"], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += String(chunk); }); + child.stderr.on("data", (chunk) => { stderr += String(chunk); }); + child.stdin.end(`${JSON.stringify(manifest)}\n`); + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => resolve({ code, signal })); + }).catch((error: unknown) => { + throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 }); + }); + if (result.code !== 0) { + throw new AgentRunError("infra-failed", `kubectl create runner job failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(stderr.slice(-4000)), stdout: redactText(stdout.slice(-2000)), signal: result.signal }) }); + } + try { + const parsed = JSON.parse(stdout) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return redactJson(parsed as JsonRecord); + } catch (error) { + throw new AgentRunError("infra-failed", `kubectl returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutPreview: redactText(stdout.slice(0, 1000)) } }); + } + throw new AgentRunError("infra-failed", "kubectl returned non-object JSON", { httpStatus: 502 }); +} + +function stringField(record: JsonRecord, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 }); + return value.trim(); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function objectPath(value: unknown, path: string[]): string | null { + let current: unknown = value; + for (const key of path) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return null; + current = (current as Record)[key]; + } + return typeof current === "string" ? current : null; +} diff --git a/src/mgr/postgres-store.ts b/src/mgr/postgres-store.ts index 8c5d815..1d88aef 100644 --- a/src/mgr/postgres-store.ts +++ b/src/mgr/postgres-store.ts @@ -6,7 +6,7 @@ import { redactJson } from "../common/redaction.js"; import type { BackendProfile, BackendTurnResult, CommandRecord, CommandState, CreateCommandInput, CreateRunInput, EventType, FailureKind, JsonRecord, JsonValue, RunEvent, RunnerRecord, RunRecord, RunStatus, TerminalStatus } from "../common/types.js"; import { newId, nowIso, stableHash } from "../common/validation.js"; import type { AgentRunStore, StoreHealth } from "./store.js"; -import { statusFromTerminal } from "./store.js"; +import { commandStateFromTerminal, statusFromTerminal } from "./store.js"; interface PostgresStoreOptions { connectionString: string; @@ -308,6 +308,19 @@ CREATE TABLE IF NOT EXISTS agentrun_schema_migrations ( return commandFromRow(row); } + async finishCommand(commandId: string, result: Pick): Promise { + return this.withTransaction(async (client) => { + const existing = await client.query("SELECT * FROM agentrun_commands WHERE id = $1 FOR UPDATE", [commandId]); + const row = existing.rows[0]; + if (!row) throw new AgentRunError("schema-invalid", `command ${commandId} was not found`, { httpStatus: 404 }); + const command = commandFromRow(row); + const state = commandStateFromTerminal(result.terminalStatus); + const updated = await client.query("UPDATE agentrun_commands SET state = $2, updated_at = $3 WHERE id = $1 RETURNING *", [commandId, state, nowIso()]); + await this.appendEventWithLockedRun(client, command.runId, "backend_status", { phase: "command-terminal", commandId, state, terminalStatus: result.terminalStatus, failureKind: result.failureKind }); + return commandFromRow(updated.rows[0]); + }); + } + async appendEvent(runId: string, type: EventType, payload: JsonRecord): Promise { return this.withTransaction(async (client) => { await this.requireRunForUpdate(client, runId); diff --git a/src/mgr/server.ts b/src/mgr/server.ts index 944a037..547f337 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -6,12 +6,20 @@ import { openAgentRunStoreFromEnv } from "./store.js"; import { AgentRunError, errorToJson } from "../common/errors.js"; import { asRecord, validateCreateCommand, validateCreateRun } from "../common/validation.js"; import type { ApiErrorBody, ApiOkBody, JsonRecord, JsonValue, RunEvent } from "../common/types.js"; +import { createKubernetesRunnerJob } from "./kubernetes-runner-job.js"; export interface ManagerServerOptions { store?: AgentRunStore; port?: number; host?: string; sourceCommit?: string; + runnerJobDefaults?: { + namespace?: string; + managerUrl?: string; + image?: string; + serviceAccountName?: string; + kubectlCommand?: string; + }; } export interface StartedManagerServer { @@ -23,12 +31,13 @@ export interface StartedManagerServer { export async function startManagerServer(options: ManagerServerOptions = {}): Promise { const store = options.store ?? await openAgentRunStoreFromEnv(); const sourceCommit = options.sourceCommit ?? process.env.AGENTRUN_SOURCE_COMMIT ?? "unknown"; + const runnerJobDefaults = options.runnerJobDefaults; const server = createServer(async (req, res) => { const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; try { const method = req.method ?? "GET"; const url = new URL(req.url ?? "/", "http://agentrun.local"); - const data = await route({ method, url, body: await readBody(req), store, sourceCommit }); + const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}) }); writeJson(res, 200, { ok: true, data, traceId }); } catch (error) { const agentError = normalizeError(error); @@ -49,7 +58,7 @@ async function readBody(req: import("node:http").IncomingMessage): Promise { +async function route({ method, url, body, store, sourceCommit, runnerJobDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable }): Promise { const path = url.pathname; if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) { const database = await store.health(); @@ -69,6 +78,23 @@ async function route({ method, url, body, store, sourceCommit }: { method: strin const commandCreateMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/commands$/u); if (method === "POST" && commandCreateMatch) return await store.createCommand(commandCreateMatch[1] ?? "", validateCreateCommand(body)) as unknown as JsonValue; if (method === "GET" && commandCreateMatch) return { items: await store.listCommands(commandCreateMatch[1] ?? "", integerQuery(url, "afterSeq", 0), integerQuery(url, "limit", 20)) as unknown as JsonValue }; + const runnerJobMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/runner-jobs$/u); + if (method === "POST" && runnerJobMatch) { + const namespace = runnerJobDefaults?.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? "agentrun-v01"; + return await createKubernetesRunnerJob({ + store, + runId: runnerJobMatch[1] ?? "", + input: asRecord(body ?? {}, "runnerJob") as never, + defaults: { + namespace, + managerUrl: runnerJobDefaults?.managerUrl ?? process.env.AGENTRUN_INTERNAL_MGR_URL ?? `http://agentrun-mgr.${namespace}.svc.cluster.local:8080`, + image: runnerJobDefaults?.image ?? process.env.AGENTRUN_RUNNER_IMAGE ?? "", + sourceCommit, + serviceAccountName: runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner", + ...(runnerJobDefaults?.kubectlCommand ? { kubectlCommand: runnerJobDefaults.kubectlCommand } : {}), + }, + }) as unknown as JsonValue; + } const commandShowMatch = path.match(/^\/api\/v1\/runs\/([^/]+)\/commands\/([^/]+)$/u); if (method === "GET" && commandShowMatch) return await store.getCommand(commandShowMatch[2] ?? "") as unknown as JsonValue; if (method === "POST" && path === "/api/v1/runners/register") return await store.registerRunner(asRecord(body ?? {}, "runner")) as unknown as JsonValue; @@ -100,6 +126,12 @@ async function route({ method, url, body, store, sourceCommit }: { method: strin } const ackMatch = path.match(/^\/api\/v1\/commands\/([^/]+)\/ack$/u); if (method === "POST" && ackMatch) return await store.ackCommand(ackMatch[1] ?? "") as unknown as JsonValue; + const commandStatusMatch = path.match(/^\/api\/v1\/commands\/([^/]+)\/status$/u); + if (method === "PATCH" && commandStatusMatch) { + const record = asRecord(body, "commandStatus"); + const terminalStatus = record.terminalStatus === "completed" || record.terminalStatus === "failed" || record.terminalStatus === "blocked" || record.terminalStatus === "cancelled" ? record.terminalStatus : "failed"; + return await store.finishCommand(commandStatusMatch[1] ?? "", { terminalStatus, failureKind: typeof record.failureKind === "string" ? record.failureKind as never : null, failureMessage: typeof record.failureMessage === "string" ? record.failureMessage : null }) as unknown as JsonValue; + } throw new AgentRunError("schema-invalid", `unsupported route ${method} ${path}`, { httpStatus: 404 }); } diff --git a/src/mgr/store.ts b/src/mgr/store.ts index ee7a36c..1f4fa75 100644 --- a/src/mgr/store.ts +++ b/src/mgr/store.ts @@ -28,6 +28,7 @@ export interface AgentRunStore { claimRun(runId: string, runnerId: string, leaseMs: number): MaybePromise; heartbeat(runId: string, runnerId: string, leaseMs: number): MaybePromise; ackCommand(commandId: string): MaybePromise; + finishCommand(commandId: string, result: Pick): MaybePromise; appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): MaybePromise; finishRun(runId: string, result: Pick): MaybePromise; backends(): MaybePromise; @@ -132,6 +133,14 @@ export class MemoryAgentRunStore implements AgentRunStore { return next; } + finishCommand(commandId: string, result: Pick): CommandRecord { + const command = this.getCommand(commandId); + const next = { ...command, state: commandStateFromTerminal(result.terminalStatus), updatedAt: nowIso() }; + this.commands.set(commandId, next); + this.appendEvent(command.runId, "backend_status", { phase: "command-terminal", commandId, state: next.state, terminalStatus: result.terminalStatus, failureKind: result.failureKind }); + return next; + } + appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): RunEvent { this.getRun(runId); const events = this.eventsByRun.get(runId) ?? []; @@ -166,3 +175,9 @@ export function statusFromTerminal(terminalStatus: TerminalStatus): RunRecord["s if (terminalStatus === "blocked") return "blocked"; return "failed"; } + +export function commandStateFromTerminal(terminalStatus: TerminalStatus): CommandRecord["state"] { + if (terminalStatus === "completed") return "completed"; + if (terminalStatus === "cancelled") return "cancelled"; + return "failed"; +} diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index 6ce5225..c649172 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -14,6 +14,7 @@ export interface RunnerJobRenderOptions { imagePullPolicy?: string; backoffLimit?: number; ttlSecondsAfterFinished?: number; + dryRun?: boolean; } interface CredentialProjection { @@ -24,7 +25,7 @@ interface CredentialProjection { } export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord { - const render = renderRunnerJobManifest(options); + const render = renderRunnerJobManifest({ ...options, dryRun: true }); return { dryRun: true, mutation: false, @@ -75,7 +76,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani annotations: { "agentrun.pikastech.local/run-id": options.run.id, "agentrun.pikastech.local/command-id": options.commandId, - "agentrun.pikastech.local/dry-run-render": "true", + "agentrun.pikastech.local/dry-run-render": String(options.dryRun === true), }, }, spec: { diff --git a/src/runner/manager-api.ts b/src/runner/manager-api.ts index c707b55..b80c5f3 100644 --- a/src/runner/manager-api.ts +++ b/src/runner/manager-api.ts @@ -76,6 +76,10 @@ export class RunnerManagerApi { return await this.client.patch(`/api/v1/runs/${encodeURIComponent(runId)}/status`, report as unknown as JsonRecord) as RunRecord; } + async reportCommandStatus(commandId: string, report: { terminalStatus: TerminalStatus; failureKind: FailureKind | null; failureMessage: string | null }): Promise { + return await this.client.patch(`/api/v1/commands/${encodeURIComponent(commandId)}/status`, report as unknown as JsonRecord) as CommandRecord; + } + async reportFailure(runId: string, report: RunnerFailureReport): Promise<{ reported: boolean; run: RunRecord | null; reportError: string | null }> { try { await this.appendEvent(runId, { type: "error", payload: { failureKind: report.failureKind, message: report.failureMessage, source: "agentrun-runner" } }); diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index c1cdf72..21b9da8 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -52,6 +52,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise { await api.ackCommand(command.id); const result = await runBackendTurn(claimed, command, options); for (const event of result.events) await api.appendEvent(options.runId, event); + await api.reportCommandStatus(command.id, { terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: result.failureMessage }); const finalRun = await api.reportStatus(options.runId, { terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: result.failureMessage }) as RunRecord; return { runner, commandId: command.id, terminalStatus: result.terminalStatus, failureKind: result.failureKind, run: finalRun } as JsonRecord; } diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 7c067f7..d4c95ce 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { startManagerServer } from "../../mgr/server.js"; import { MemoryAgentRunStore } from "../../mgr/store.js"; import { ManagerClient } from "../../mgr/client.js"; @@ -23,7 +25,41 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(rendered.mutation, false); assert.equal((rendered.jobIdentity as { serviceAccountName?: string }).serviceAccountName, "agentrun-v01-runner"); assertNoSecretLeak(rendered); - return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run"] }; + + const fakeKubectl = path.join(context.tmp, "fake-kubectl.js"); + const createdManifest = path.join(context.tmp, "created-runner-job.json"); + await writeFile(fakeKubectl, `#!/usr/bin/env bun +const chunks = []; +for await (const chunk of Bun.stdin.stream()) chunks.push(chunk); +const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8"); +await Bun.write(${JSON.stringify(createdManifest)}, text); +const manifest = JSON.parse(text); +console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-uid-selftest", resourceVersion: "1", name: manifest.metadata.name, namespace: manifest.metadata.namespace } })); +`); + await chmod(fakeKubectl, 0o755); + await mkdir(path.dirname(fakeKubectl), { recursive: true }); + const serverWithKubectl = await startManagerServer({ + port: 0, + host: "127.0.0.1", + sourceCommit: "self-test", + store: new MemoryAgentRunStore(), + runnerJobDefaults: { + namespace: "agentrun-v01", + managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080", + image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111", + kubectlCommand: fakeKubectl, + }, + }); + try { + const jobClient = new ManagerClient(serverWithKubectl.baseUrl); + const jobItem = await createRunWithCommand(jobClient, context, "job create smoke", "selftest-job-create", 15_000); + const created = await jobClient.post(`/api/v1/runs/${jobItem.runId}/runner-jobs`, { commandId: jobItem.commandId, attemptId: "attempt_selftest_create" }); + assert.equal((created as { mutation?: unknown }).mutation, true); + assertNoSecretLeak(created); + } finally { + await new Promise((resolve) => serverWithKubectl.server.close(() => resolve())); + } + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-create-api"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/30-codex-stdio.ts b/src/selftest/cases/30-codex-stdio.ts index 299937c..09f76dd 100644 --- a/src/selftest/cases/30-codex-stdio.ts +++ b/src/selftest/cases/30-codex-stdio.ts @@ -22,6 +22,8 @@ const selfTest: SelfTestCase = async (context) => { assertNoSecretLeak(events); const finalRun = await client.get(`/api/v1/runs/${happy.runId}`) as { terminalStatus?: string }; assert.equal(finalRun.terminalStatus, "completed"); + const finalCommand = await client.get(`/api/v1/runs/${happy.runId}/commands/${happy.commandId}`) as { state?: string }; + assert.equal(finalCommand.state, "completed"); await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "missing-turn-result", expectedStatus: "failed", expectedFailureKind: "backend-response-invalid" }); await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "invalid-json", expectedStatus: "failed", expectedFailureKind: "backend-json-parse-error" }); @@ -48,6 +50,8 @@ async function runFailureCase(options: { client: ManagerClient; managerUrl: stri assert.equal(result.failureKind, options.expectedFailureKind, options.mode); const events = await options.client.get(`/api/v1/runs/${item.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: unknown }> }; assert.ok(events.items?.some((event) => event.type === "error"), options.mode); + const command = await options.client.get(`/api/v1/runs/${item.runId}/commands/${item.commandId}`) as { state?: string }; + assert.equal(command.state, "failed", options.mode); assertNoSecretLeak(events); } @@ -65,6 +69,8 @@ async function runSpawnFailureCase(options: { client: ManagerClient; managerUrl: assert.equal(result.failureKind, "backend-spawn-failed", "spawn failure"); const events = await options.client.get(`/api/v1/runs/${item.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: unknown }> }; assert.ok(events.items?.some((event) => event.type === "error"), "spawn failure"); + const command = await options.client.get(`/api/v1/runs/${item.runId}/commands/${item.commandId}`) as { state?: string }; + assert.equal(command.state, "failed", "spawn failure"); assertNoSecretLeak(events); }