@wardby/cli 0.4.1 → 0.5.1
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/.env.example +8 -1
- package/README.md +3 -1
- package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
- package/dist/claude-coding-worker/driver.d.ts +2 -0
- package/dist/claude-coding-worker/driver.js +19 -3
- package/dist/claude-coding-worker/sdk.js +1 -1
- package/dist/cli.js +41 -22
- package/dist/coding/continuation-wording.d.ts +10 -0
- package/dist/coding/continuation-wording.js +13 -0
- package/dist/coding/docker-preflight.d.ts +12 -0
- package/dist/coding/docker-preflight.js +40 -0
- package/dist/coding/local-git.d.ts +22 -0
- package/dist/coding/local-git.js +102 -0
- package/dist/coding/local-repo-status.d.ts +7 -0
- package/dist/coding/local-repo-status.js +56 -0
- package/dist/coding/local-repo.d.ts +17 -0
- package/dist/coding/local-repo.js +65 -0
- package/dist/coding/observability.d.ts +1 -1
- package/dist/coding/observability.js +1 -0
- package/dist/coding/profile.d.ts +6 -0
- package/dist/coding/profile.js +13 -4
- package/dist/coding/protocol.d.ts +32 -7
- package/dist/coding/protocol.js +66 -4
- package/dist/coding-worker/artifact.d.ts +1 -0
- package/dist/coding-worker/errors.js +2 -0
- package/dist/config/providers.d.ts +13 -0
- package/dist/config/providers.js +21 -9
- package/dist/core/attribution.d.ts +13 -0
- package/dist/core/attribution.js +30 -0
- package/dist/core/budget-groups.d.ts +3 -1
- package/dist/core/budget-groups.js +53 -5
- package/dist/core/ci-context.d.ts +8 -0
- package/dist/core/ci-context.js +59 -0
- package/dist/core/delegation-siblings.d.ts +22 -0
- package/dist/core/delegation-siblings.js +40 -0
- package/dist/core/dispatch.d.ts +28 -4
- package/dist/core/dispatch.js +57 -15
- package/dist/core/engine-native.d.ts +16 -0
- package/dist/core/engine-native.js +49 -2
- package/dist/core/host-events.d.ts +25 -2
- package/dist/core/host-events.js +361 -19
- package/dist/core/host-status.d.ts +8 -3
- package/dist/core/host-status.js +38 -7
- package/dist/core/issue-events.d.ts +2 -6
- package/dist/core/issue-events.js +13 -19
- package/dist/core/pull-request-state-sync.d.ts +17 -0
- package/dist/core/pull-request-state-sync.js +53 -0
- package/dist/core/reconciler.d.ts +10 -1
- package/dist/core/reconciler.js +15 -2
- package/dist/core/related-pull-requests.d.ts +95 -0
- package/dist/core/related-pull-requests.js +338 -0
- package/dist/core/repo-access.d.ts +5 -1
- package/dist/core/repo-access.js +17 -1
- package/dist/core/review-fix-ledger.d.ts +20 -0
- package/dist/core/review-fix-ledger.js +41 -0
- package/dist/core/review-fix.d.ts +60 -0
- package/dist/core/review-fix.js +155 -0
- package/dist/core/review-host-tools.d.ts +13 -2
- package/dist/core/review-host-tools.js +72 -11
- package/dist/core/runner.d.ts +2 -2
- package/dist/core/runner.js +379 -184
- package/dist/core/serial-gate.d.ts +11 -0
- package/dist/core/serial-gate.js +19 -0
- package/dist/core/timing.d.ts +7 -0
- package/dist/core/timing.js +7 -0
- package/dist/generated/prisma/browser.d.ts +23 -0
- package/dist/generated/prisma/client.d.ts +23 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
- package/dist/generated/prisma/internal/class.d.ts +33 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
- package/dist/generated/prisma/models/Agent.d.ts +422 -1
- package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
- package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
- package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
- package/dist/generated/prisma/models/DeferredReview.js +1 -0
- package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
- package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
- package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
- package/dist/generated/prisma/models/LocalReview.js +1 -0
- package/dist/generated/prisma/models/Run.d.ts +223 -0
- package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
- package/dist/generated/prisma/models.d.ts +3 -0
- package/dist/help-index.json +486 -37
- package/dist/import/neutral-schema.d.ts +2 -2
- package/dist/mcp/auth/access.d.ts +3 -1
- package/dist/mcp/auth/host-account-cli.js +2 -2
- package/dist/mcp/auth/repo-authorization.d.ts +6 -7
- package/dist/mcp/auth/repo-authorization.js +21 -0
- package/dist/mcp/index.js +4 -1
- package/dist/mcp/tools/agents.js +46 -4
- package/dist/mcp/tools/host-accounts.js +2 -2
- package/dist/mcp/tools/repositories.js +71 -12
- package/dist/mcp/tools/runs.js +23 -0
- package/dist/mcp/tools/trigger.js +153 -24
- package/dist/providers/engine/types.d.ts +11 -0
- package/dist/providers/executor/composition.js +12 -7
- package/dist/providers/executor/container.d.ts +40 -3
- package/dist/providers/executor/container.js +140 -9
- package/dist/providers/executor/types.d.ts +1 -1
- package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
- package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
- package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
- package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
- package/dist/providers/jobs/kubernetes-client.js +3 -0
- package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
- package/dist/providers/jobs/kubernetes-isolation.js +8 -3
- package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
- package/dist/providers/jobs/kubernetes-preflight.js +7 -0
- package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
- package/dist/providers/jobs/kubernetes-quota.js +110 -0
- package/dist/providers/jobs/kubernetes.d.ts +9 -0
- package/dist/providers/jobs/kubernetes.js +50 -0
- package/dist/providers/jobs/types.d.ts +7 -0
- package/dist/providers/review-host/ci.d.ts +7 -0
- package/dist/providers/review-host/ci.js +20 -0
- package/dist/providers/review-host/github-events.d.ts +7 -0
- package/dist/providers/review-host/github-events.js +31 -7
- package/dist/providers/review-host/github.d.ts +19 -2
- package/dist/providers/review-host/github.js +145 -2
- package/dist/providers/review-host/index.d.ts +8 -2
- package/dist/providers/review-host/index.js +14 -3
- package/dist/providers/review-host/local.d.ts +72 -0
- package/dist/providers/review-host/local.js +434 -0
- package/dist/providers/review-host/types.d.ts +65 -3
- package/dist/providers/review-host/types.js +5 -3
- package/dist/providers/vcs/git.d.ts +10 -20
- package/dist/providers/vcs/git.js +71 -127
- package/dist/providers/vcs/github-remote.d.ts +67 -0
- package/dist/providers/vcs/github-remote.js +201 -0
- package/dist/providers/vcs/github.d.ts +81 -0
- package/dist/providers/vcs/github.js +203 -6
- package/dist/providers/vcs/index.d.ts +16 -1
- package/dist/providers/vcs/index.js +43 -15
- package/dist/providers/vcs/local-remote.d.ts +44 -0
- package/dist/providers/vcs/local-remote.js +108 -0
- package/dist/providers/vcs/remote.d.ts +58 -0
- package/dist/providers/vcs/remote.js +1 -0
- package/dist/providers/vcs/routing.d.ts +33 -0
- package/dist/providers/vcs/routing.js +52 -0
- package/dist/providers/vcs/types.d.ts +12 -1
- package/dist/quickstart/coding-db.d.ts +11 -0
- package/dist/quickstart/coding-db.js +75 -0
- package/dist/quickstart/coding-doctor.d.ts +13 -0
- package/dist/quickstart/coding-doctor.js +88 -0
- package/dist/quickstart/coding-images.d.ts +15 -0
- package/dist/quickstart/coding-images.js +79 -0
- package/dist/quickstart/coding-seed.d.ts +63 -0
- package/dist/quickstart/coding-seed.js +85 -0
- package/dist/quickstart/coding.d.ts +66 -0
- package/dist/quickstart/coding.js +430 -0
- package/dist/quickstart/images.d.ts +19 -0
- package/dist/quickstart/images.js +63 -0
- package/dist/quickstart/index.js +61 -12
- package/dist/quickstart/starter-services.d.ts +45 -0
- package/dist/quickstart/starter-services.js +186 -0
- package/dist/quickstart-images.json +1 -0
- package/dist/serve.js +14 -1
- package/dist/viewer/api-schema.d.ts +176 -0
- package/dist/viewer/api-schema.js +27 -2
- package/dist/viewer/graph.d.ts +7 -2
- package/dist/viewer/graph.js +25 -11
- package/dist/viewer/http.d.ts +7 -1
- package/dist/viewer/http.js +8 -3
- package/dist/viewer/infra.d.ts +2 -0
- package/dist/viewer/infra.js +23 -0
- package/dist/viewer/run-detail.d.ts +2 -1
- package/dist/viewer/run-detail.js +2 -2
- package/docs/agent-recipes.md +127 -5
- package/docs/code-review-agents.md +293 -38
- package/docs/coding-agent-setup.md +141 -2
- package/docs/coding-packages.md +21 -0
- package/docs/coding-services.md +3 -0
- package/docs/coding-worker-isolation.md +56 -20
- package/docs/getting-started-gke.md +24 -0
- package/docs/getting-started.md +196 -10
- package/docs/jira-agents.md +26 -5
- package/docs/security-deployment.md +9 -0
- package/docs/viewer-api.md +21 -2
- package/help/admin-viewer.md +11 -1
- package/help/agent-recipes.md +30 -4
- package/help/builder-agent.md +4 -0
- package/help/code-review-agents.md +82 -2
- package/help/coding-packages.md +12 -1
- package/help/coding-services.md +4 -0
- package/help/cost-attribution.md +3 -1
- package/help/creating-agents.md +3 -0
- package/help/deploy-gke.md +2 -1
- package/help/errors/coding-provider-not-configured.md +60 -0
- package/help/errors/coding-turn-limit.md +25 -0
- package/help/errors/continuation-closed.md +75 -0
- package/help/errors/local-branch-conflict.md +37 -0
- package/help/errors/local-path-invalid.md +30 -0
- package/help/errors/local-ref-invalid.md +36 -0
- package/help/errors/local-ref-not-found.md +41 -0
- package/help/errors/local-repo-not-allowed.md +46 -0
- package/help/errors/local-repo-not-found.md +39 -0
- package/help/errors/model-unavailable.md +7 -6
- package/help/errors/vcs-github-not-configured.md +34 -0
- package/help/getting-started.md +28 -0
- package/help/github.md +22 -5
- package/help/jira.md +9 -2
- package/help/local-repositories.md +182 -0
- package/help/related-pull-requests.md +89 -0
- package/help/review-fix-rounds.md +69 -0
- package/help/troubleshooting/budgets.md +11 -0
- package/help/troubleshooting/coding-workers.md +22 -0
- package/help/troubleshooting/repository-access.md +5 -0
- package/package.json +3 -2
- package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
- package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
- package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
- package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
- package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
- package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
- package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
- package/prisma/schema.prisma +98 -1
|
@@ -9,8 +9,11 @@ import { parseStoredServices, storedServiceNames, workerServices } from "../../c
|
|
|
9
9
|
import { MAX_SERVICE_DECLARATION_BYTES, SERVICE_DECLARATION_PATH } from "../../coding/services/declaration.js";
|
|
10
10
|
import { SERVICE_UNREADY_CATEGORY, SERVICE_UNREADY_ERROR, serviceUnreadyName, serviceUnreadySentence, } from "../../coding/services/wording.js";
|
|
11
11
|
import { PROTECTED_PATH_CATEGORY, protectedPathFromError, protectedPathSentence, } from "../../coding/protected-path-wording.js";
|
|
12
|
+
import { CONTINUATION_CLOSED_CATEGORY, CONTINUATION_CLOSED_ERROR } from "../../coding/continuation-wording.js";
|
|
12
13
|
import { budgetSentence } from "../../core/budget-wording.js";
|
|
13
14
|
import { fileSelfDefect } from "../../core/self-defects.js";
|
|
15
|
+
import { isTransactionUnavailable } from "../../core/dispatch.js";
|
|
16
|
+
import { CONTENDED_TX_MAX_WAIT_MS } from "../../core/timing.js";
|
|
14
17
|
import { classifyProviderFailure, providerClassOfCategory, providerSentence, } from "../../core/provider-wording.js";
|
|
15
18
|
import { logger } from "../../core/logger.js";
|
|
16
19
|
import { requiredLevel } from "../../core/repo-access.js";
|
|
@@ -23,7 +26,10 @@ import { CODING_PROXY_ALIAS, CODING_PROXY_PORT, isImmutableDockerImage } from ".
|
|
|
23
26
|
import { normalizeRegistryLockfiles } from "../../coding/registry/lockfiles.js";
|
|
24
27
|
import { ISSUE_KEY, ISSUE_TRACKER_NAMES } from "../issue-tracker/types.js";
|
|
25
28
|
import { HEARTBEAT_TIMEOUT_MS } from "../../core/timing.js";
|
|
29
|
+
import { collectRelatedPullRequests } from "../../core/related-pull-requests.js";
|
|
26
30
|
const containerLog = logger.child({ module: "container-executor" });
|
|
31
|
+
/** The input path in a spec built only to ask the launcher about capacity; never written or read. */
|
|
32
|
+
const CAPACITY_PROBE_INPUT = "/dev/null";
|
|
27
33
|
const TERMINAL_STATUSES = new Set(["succeeded", "failed", "refused", "lost", "budget_exhausted", "cancelled"]);
|
|
28
34
|
const PROVISIONING_BACKEND = "provisioning";
|
|
29
35
|
/** Serializes concurrency-slot claims across replicas. Distinct from the OAuth client lock (7412901). */
|
|
@@ -119,6 +125,7 @@ export class PrismaContainerExecutionStore {
|
|
|
119
125
|
toolImage: row.codingRun.toolImage,
|
|
120
126
|
workspaceDiskMb: row.codingRun.workspaceDiskMb,
|
|
121
127
|
debugTrace: row.codingRun.debugTrace,
|
|
128
|
+
maxTurns: row.codingRun.maxTurns,
|
|
122
129
|
services: row.codingRun.services,
|
|
123
130
|
failureCategory: row.codingRun.failureCategory,
|
|
124
131
|
profileRepository: row.agent.codingProfile?.repository ?? null,
|
|
@@ -127,6 +134,15 @@ export class PrismaContainerExecutionStore {
|
|
|
127
134
|
pricingSnapshot: row.pricingSnapshot,
|
|
128
135
|
};
|
|
129
136
|
}
|
|
137
|
+
async relatedPullRequests(runId) {
|
|
138
|
+
const group = await collectRelatedPullRequests(this.db, runId);
|
|
139
|
+
// Every PR of the request so far, merged/closed ones included (listed as context).
|
|
140
|
+
return group.pullRequests.map((pr) => ({
|
|
141
|
+
repository: pr.repository,
|
|
142
|
+
number: pr.number,
|
|
143
|
+
...(pr.state ? { state: pr.state } : {}),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
130
146
|
async claimProvisioning(runId, claimId) {
|
|
131
147
|
try {
|
|
132
148
|
return await this.db.$transaction(async (tx) => {
|
|
@@ -195,14 +211,27 @@ export class PrismaContainerExecutionStore {
|
|
|
195
211
|
throw new RunNoLongerActiveError();
|
|
196
212
|
}
|
|
197
213
|
return "claimed";
|
|
198
|
-
});
|
|
214
|
+
}, { maxWait: CONTENDED_TX_MAX_WAIT_MS });
|
|
199
215
|
}
|
|
200
216
|
catch (err) {
|
|
201
217
|
if (err instanceof RunNoLongerActiveError)
|
|
202
218
|
return "unavailable";
|
|
219
|
+
// A burst of claims queued on the slot lock left no connection in time.
|
|
220
|
+
// Nothing was claimed: wait in the coding queue (drainCodingQueue starts
|
|
221
|
+
// it, oldest first) instead of failing a run that only needed to wait.
|
|
222
|
+
if (isTransactionUnavailable(err)) {
|
|
223
|
+
await this.markQueued(runId);
|
|
224
|
+
return "queued";
|
|
225
|
+
}
|
|
203
226
|
throw err;
|
|
204
227
|
}
|
|
205
228
|
}
|
|
229
|
+
async markQueued(runId) {
|
|
230
|
+
await this.db.codingRun.updateMany({
|
|
231
|
+
where: { runId, jobBackend: null, jobHandle: null, queuedAt: null, run: { status: "pending" } },
|
|
232
|
+
data: { queuedAt: new Date() },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
206
235
|
async persistHandle(runId, claimId, handle) {
|
|
207
236
|
await this.db.$transaction(async (tx) => {
|
|
208
237
|
const coding = await tx.codingRun.findUnique({ where: { runId }, include: { run: true } });
|
|
@@ -236,7 +265,7 @@ export class PrismaContainerExecutionStore {
|
|
|
236
265
|
data: { heartbeatAt: new Date() },
|
|
237
266
|
});
|
|
238
267
|
}
|
|
239
|
-
async complete(runId, status, result) {
|
|
268
|
+
async complete(runId, status, result, record = {}) {
|
|
240
269
|
const finishedAt = new Date();
|
|
241
270
|
const finished = await this.db.$transaction(async (tx) => {
|
|
242
271
|
const run = await tx.run.findUnique({ where: { id: runId }, include: { codingRun: true } });
|
|
@@ -249,7 +278,12 @@ export class PrismaContainerExecutionStore {
|
|
|
249
278
|
}
|
|
250
279
|
await tx.codingRun.update({
|
|
251
280
|
where: { runId },
|
|
252
|
-
data: {
|
|
281
|
+
data: {
|
|
282
|
+
result,
|
|
283
|
+
resultSchema: CODING_PROTOCOL_VERSION,
|
|
284
|
+
...(record.resultBranch ? { resultBranch: record.resultBranch } : {}),
|
|
285
|
+
...(record.baseSha ? { baseSha: record.baseSha } : {}),
|
|
286
|
+
},
|
|
253
287
|
});
|
|
254
288
|
await tx.run.update({
|
|
255
289
|
where: { id: runId },
|
|
@@ -328,7 +362,7 @@ export class ContainerExecutor {
|
|
|
328
362
|
this.artifactRoot = resolve(options.artifactRoot);
|
|
329
363
|
if (this.artifactRoot === resolve("/"))
|
|
330
364
|
throw new Error("coding_artifact_root_invalid");
|
|
331
|
-
if (!isImmutableDockerImage(options.workerImage)) {
|
|
365
|
+
if (options.workerImage !== undefined && !isImmutableDockerImage(options.workerImage)) {
|
|
332
366
|
throw new Error("coding_worker_image_invalid");
|
|
333
367
|
}
|
|
334
368
|
for (const versions of Object.values(options.additionalWorkerImages ?? {})) {
|
|
@@ -464,6 +498,10 @@ export class ContainerExecutor {
|
|
|
464
498
|
if (!handle) {
|
|
465
499
|
if (run.provisioningClaim)
|
|
466
500
|
return;
|
|
501
|
+
// No room in the cluster yet (the namespace quota): wait in the queue rather than claim a
|
|
502
|
+
// slot and then fail when the API server refuses the job.
|
|
503
|
+
if (!(await this.fitsCluster(run)))
|
|
504
|
+
return;
|
|
467
505
|
claimId = randomUUID();
|
|
468
506
|
// "queued": every slot is taken; the run stays pending and
|
|
469
507
|
// drainCodingQueue starts it when one frees. "unavailable": another
|
|
@@ -665,6 +703,28 @@ export class ContainerExecutor {
|
|
|
665
703
|
},
|
|
666
704
|
};
|
|
667
705
|
}
|
|
706
|
+
/** The new PR's Related pull requests input: earlier PRs of the same request, then this one. Never throws. */
|
|
707
|
+
async relatedFor(run) {
|
|
708
|
+
// A continuation pushes onto an existing PR, whose body is never rewritten here.
|
|
709
|
+
if (run.rootCodingRunId || !this.options.store.relatedPullRequests)
|
|
710
|
+
return {};
|
|
711
|
+
try {
|
|
712
|
+
const earlier = await this.options.store.relatedPullRequests(run.runId);
|
|
713
|
+
if (earlier.length === 0)
|
|
714
|
+
return {};
|
|
715
|
+
const { issue } = this.issueFor(run);
|
|
716
|
+
return {
|
|
717
|
+
related: {
|
|
718
|
+
entries: [...earlier, { repository: run.repository, self: true }],
|
|
719
|
+
...(issue ? { issue } : {}),
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
containerLog.warn({ err, runId: run.runId }, "could not list the request's related pull requests");
|
|
725
|
+
return {};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
668
728
|
/** The run's stored model terms; a run from before the catalog resolves them from the current catalog. */
|
|
669
729
|
modelTerms(run) {
|
|
670
730
|
if (run.provider !== "codex" && run.provider !== "claude-code")
|
|
@@ -810,10 +870,18 @@ export class ContainerExecutor {
|
|
|
810
870
|
tag: output.tag,
|
|
811
871
|
...report,
|
|
812
872
|
...this.issueFor(current),
|
|
873
|
+
...(await this.relatedFor(current)),
|
|
813
874
|
});
|
|
875
|
+
// A pushed branch (local repository) is a successful run with no pull
|
|
876
|
+
// request; its branch is recorded on the run for the operator to merge.
|
|
814
877
|
const result = this.resultFor(output, current, finalized.outcome, finalized);
|
|
815
|
-
await this.options.store.complete(run.runId, "succeeded", result
|
|
816
|
-
|
|
878
|
+
await this.options.store.complete(run.runId, "succeeded", result, {
|
|
879
|
+
baseSha: finalized.baseCommit,
|
|
880
|
+
...(finalized.outcome === "branch_pushed" ? { resultBranch: finalized.headRef } : {}),
|
|
881
|
+
});
|
|
882
|
+
if (finalized.outcome === "pull_request_opened" ||
|
|
883
|
+
finalized.outcome === "pull_request_updated" ||
|
|
884
|
+
finalized.outcome === "branch_pushed") {
|
|
817
885
|
this.emit({ stage: finalized.outcome, runId: run.runId, jobId: handle.id });
|
|
818
886
|
}
|
|
819
887
|
this.terminal(current, "succeeded");
|
|
@@ -886,7 +954,9 @@ export class ContainerExecutor {
|
|
|
886
954
|
pullRequestUrl: finalized.pullRequestUrl,
|
|
887
955
|
pullRequestNumber: finalized.pullRequestNumber,
|
|
888
956
|
}
|
|
889
|
-
:
|
|
957
|
+
: outcome === "branch_pushed" && finalized?.outcome === "branch_pushed"
|
|
958
|
+
? { headRef: finalized.headRef, commitSha: finalized.commitSha }
|
|
959
|
+
: {}),
|
|
890
960
|
summary: output.summary,
|
|
891
961
|
tests: output.tests,
|
|
892
962
|
tag: output.tag,
|
|
@@ -911,8 +981,11 @@ export class ContainerExecutor {
|
|
|
911
981
|
throw new Error("coding_worker_image_invalid");
|
|
912
982
|
return selector.workerImageRef;
|
|
913
983
|
}
|
|
914
|
-
if (selector.toolchain === "node")
|
|
984
|
+
if (selector.toolchain === "node") {
|
|
985
|
+
if (!this.options.workerImage)
|
|
986
|
+
throw new Error("coding_provider_not_configured:codex");
|
|
915
987
|
return this.options.workerImage;
|
|
988
|
+
}
|
|
916
989
|
const versions = this.options.additionalWorkerImages?.[selector.toolchain];
|
|
917
990
|
const image = selector.toolchainVersion ? versions?.[selector.toolchainVersion] : undefined;
|
|
918
991
|
if (!image) {
|
|
@@ -971,11 +1044,31 @@ export class ContainerExecutor {
|
|
|
971
1044
|
throw new Error("coding_run_not_found");
|
|
972
1045
|
return current;
|
|
973
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* False when the launcher reports this run's job would not fit the cluster right now; the run
|
|
1049
|
+
* is then queued and drainCodingQueue retries it. Launchers and stores without the hooks, and
|
|
1050
|
+
* any error, answer true: the launch stays the authority.
|
|
1051
|
+
*/
|
|
1052
|
+
async fitsCluster(run) {
|
|
1053
|
+
const { jobs, store } = this.options;
|
|
1054
|
+
if (!jobs.hasCapacityFor || !store.markQueued)
|
|
1055
|
+
return true;
|
|
1056
|
+
const fits = await jobs.hasCapacityFor(this.jobSpec(run, CAPACITY_PROBE_INPUT)).catch(() => true);
|
|
1057
|
+
if (fits)
|
|
1058
|
+
return true;
|
|
1059
|
+
await store.markQueued(run.runId);
|
|
1060
|
+
this.emit({ stage: "queued", runId: run.runId });
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
974
1063
|
jobSpec(run, inputArtifact) {
|
|
975
1064
|
const provider = run.provider === "claude-code" ? "claude-code" : "codex";
|
|
976
1065
|
if (provider === "claude-code" && (!run.workerImage || !this.options.claudeToolRunnerImage)) {
|
|
977
1066
|
throw new Error("coding_provider_not_configured:claude-code");
|
|
978
1067
|
}
|
|
1068
|
+
// Runs keep the image they were dispatched with; one without it needs the deployment default.
|
|
1069
|
+
const image = run.workerImage ?? this.options.workerImage;
|
|
1070
|
+
if (!image)
|
|
1071
|
+
throw new Error("coding_provider_not_configured:codex");
|
|
979
1072
|
if (run.workspaceDiskMb && run.workspaceDiskMb > this.options.maxDiskMb) {
|
|
980
1073
|
throw new Error("coding_workspace_disk_exceeds_limit");
|
|
981
1074
|
}
|
|
@@ -988,7 +1081,7 @@ export class ContainerExecutor {
|
|
|
988
1081
|
kind: "coding-agent",
|
|
989
1082
|
runId: run.runId,
|
|
990
1083
|
provider,
|
|
991
|
-
image
|
|
1084
|
+
image,
|
|
992
1085
|
// A run keeps the tool image it was dispatched with; rows from before CodingRun.toolImage use the default.
|
|
993
1086
|
...(provider === "claude-code" ? { toolImage: run.toolImage ?? this.options.claudeToolRunnerImage } : {}),
|
|
994
1087
|
inputArtifact,
|
|
@@ -1035,6 +1128,8 @@ export class ContainerExecutor {
|
|
|
1035
1128
|
continuationOf: run.rootCodingRunId ? { runId: run.rootCodingRunId } : undefined,
|
|
1036
1129
|
// Only when true: an untraced run's input stays exactly what older workers expect.
|
|
1037
1130
|
...(run.debugTrace ? { debugTrace: true } : {}),
|
|
1131
|
+
// Only when the agent sets one: every other run's input stays exactly what older workers expect.
|
|
1132
|
+
...(run.maxTurns ? { maxTurns: run.maxTurns } : {}),
|
|
1038
1133
|
// Only when there are some: every other run's input stays exactly what older workers expect.
|
|
1039
1134
|
...(services.length > 0 ? { services: workerServices(services) } : {}),
|
|
1040
1135
|
});
|
|
@@ -1248,6 +1343,8 @@ function safeError(error) {
|
|
|
1248
1343
|
* "workspace" because "github" contains "git".
|
|
1249
1344
|
*/
|
|
1250
1345
|
const CATEGORY_BY_PREFIX = [
|
|
1346
|
+
// Claude Code reached the agent's turn limit (codingProfile.maxTurns) before finishing.
|
|
1347
|
+
["job_coding_turn_limit", "turn_limit"],
|
|
1251
1348
|
// A service sidecar never became ready (src/providers/jobs/kubernetes.ts).
|
|
1252
1349
|
[SERVICE_UNREADY_ERROR, SERVICE_UNREADY_CATEGORY],
|
|
1253
1350
|
// Services on a launcher that can't start them: refused at dispatch, failed here as a backstop.
|
|
@@ -1262,15 +1359,49 @@ const CATEGORY_BY_PREFIX = [
|
|
|
1262
1359
|
// Checked before the generic "vcs_" fallback: the collected diff touched a
|
|
1263
1360
|
// path this agent may not edit, not a generic workspace/git failure.
|
|
1264
1361
|
["vcs_protected_path:", PROTECTED_PATH_CATEGORY],
|
|
1362
|
+
// A continuation whose pull request was merged or closed: nothing pushed (coding/continuation-wording.ts).
|
|
1363
|
+
[CONTINUATION_CLOSED_ERROR, CONTINUATION_CLOSED_CATEGORY],
|
|
1364
|
+
// A provider whose worker images this deployment doesn't configure (codex or claude-code): configuration.
|
|
1365
|
+
["coding_provider_not_configured", "preflight"],
|
|
1366
|
+
// A GitHub repository on a server with no GitHub App: configuration, like an unsupported service.
|
|
1367
|
+
["vcs_github_not_configured", "preflight"],
|
|
1265
1368
|
["vcs_", "workspace"],
|
|
1369
|
+
// Local repositories (LocalRepoError codes): the same family as the vcs_ errors.
|
|
1370
|
+
["local_", "workspace"],
|
|
1266
1371
|
["git_", "workspace"],
|
|
1267
1372
|
];
|
|
1373
|
+
/**
|
|
1374
|
+
* Whether `error` or something in its `cause` chain (bounded depth, in case a
|
|
1375
|
+
* cycle somehow formed) is -- or wraps -- an error whose own message starts
|
|
1376
|
+
* with `prefix`.
|
|
1377
|
+
*/
|
|
1378
|
+
function causeChainStartsWith(error, prefix) {
|
|
1379
|
+
let current = error;
|
|
1380
|
+
for (let depth = 0; depth < 5 && current; depth += 1) {
|
|
1381
|
+
const raw = current instanceof Error ? current.message : typeof current === "string" ? current : "";
|
|
1382
|
+
if (raw.startsWith(prefix))
|
|
1383
|
+
return true;
|
|
1384
|
+
current = current instanceof Error ? current.cause : undefined;
|
|
1385
|
+
}
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1268
1388
|
function failureCategory(error) {
|
|
1269
1389
|
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
1270
1390
|
for (const [prefix, category] of CATEGORY_BY_PREFIX) {
|
|
1271
1391
|
if (raw.startsWith(prefix))
|
|
1272
1392
|
return category;
|
|
1273
1393
|
}
|
|
1394
|
+
// A continuation whose pull request is no longer open can fail before a
|
|
1395
|
+
// session or job exists (assertContinuationOpen, called at the top of
|
|
1396
|
+
// prepareWorkspace) -- that failure is re-thrown as a PreflightError whose
|
|
1397
|
+
// own message is `safeError(error)`, a sanitized wrapper that no longer
|
|
1398
|
+
// starts with CONTINUATION_CLOSED_ERROR (see the `!spendEnabled && !handle`
|
|
1399
|
+
// branch above). The real, un-wrapped error survives on `cause`. Only this
|
|
1400
|
+
// one category is looked up through the cause chain this way -- every
|
|
1401
|
+
// other prefix above keeps matching only the top-level message, exactly as
|
|
1402
|
+
// before, so no other prepare-time failure's category changes here.
|
|
1403
|
+
if (causeChainStartsWith(error, CONTINUATION_CLOSED_ERROR))
|
|
1404
|
+
return CONTINUATION_CLOSED_CATEGORY;
|
|
1274
1405
|
const message = safeError(error);
|
|
1275
1406
|
if (message.includes("preflight") || message.includes("ownership") || message.includes("image"))
|
|
1276
1407
|
return "preflight";
|
|
@@ -70,7 +70,7 @@ export interface Executor {
|
|
|
70
70
|
}): Promise<string | null>;
|
|
71
71
|
/**
|
|
72
72
|
* The raw text of one repository file at `baseRef`, or null when absent.
|
|
73
|
-
* Throws (
|
|
73
|
+
* Throws (github_file_* or local_file_*: too_large, not_a_file, not_utf8)
|
|
74
74
|
* when the file is oversized or not UTF-8 text. Called by dispatch before its transaction (network call).
|
|
75
75
|
* Used for the knowledge note (src/knowledge/note.ts).
|
|
76
76
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1Secret, V1Service } from "@kubernetes/client-node";
|
|
1
|
+
import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1ResourceQuota, V1Secret, V1Service } from "@kubernetes/client-node";
|
|
2
2
|
import type { Readable, Writable } from "node:stream";
|
|
3
3
|
import { type KubernetesApi, type KubernetesExecOptions } from "./kubernetes-api.js";
|
|
4
4
|
export interface FakeExecCall {
|
|
@@ -10,7 +10,7 @@ export interface FakeExecCall {
|
|
|
10
10
|
stdout?: Writable;
|
|
11
11
|
timeoutMs: number;
|
|
12
12
|
}
|
|
13
|
-
type Kind = "configmap" | "secret" | "pod" | "networkpolicy" | "service" | "endpoints";
|
|
13
|
+
type Kind = "configmap" | "secret" | "pod" | "networkpolicy" | "service" | "endpoints" | "resourcequota";
|
|
14
14
|
type Obj = {
|
|
15
15
|
metadata?: {
|
|
16
16
|
name?: string;
|
|
@@ -91,6 +91,7 @@ export declare class FakeKubernetesApi implements KubernetesApi {
|
|
|
91
91
|
deleteNetworkPolicy(namespace: string, name: string): Promise<void>;
|
|
92
92
|
readService(namespace: string, name: string): Promise<V1Service | undefined>;
|
|
93
93
|
readEndpoints(namespace: string, name: string): Promise<V1Endpoints | undefined>;
|
|
94
|
+
readResourceQuota(namespace: string, name: string): Promise<V1ResourceQuota | undefined>;
|
|
94
95
|
readApiServerVersion(): Promise<string>;
|
|
95
96
|
readNamespace(name: string): Promise<boolean>;
|
|
96
97
|
exec(namespace: string, pod: string, container: string, command: string[], options: KubernetesExecOptions): Promise<number>;
|
|
@@ -114,6 +114,9 @@ export class FakeKubernetesApi {
|
|
|
114
114
|
async readEndpoints(namespace, name) {
|
|
115
115
|
return this.read("endpoints", namespace, name);
|
|
116
116
|
}
|
|
117
|
+
async readResourceQuota(namespace, name) {
|
|
118
|
+
return this.read("resourcequota", namespace, name);
|
|
119
|
+
}
|
|
117
120
|
async readApiServerVersion() {
|
|
118
121
|
return this.apiServerVersion;
|
|
119
122
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* so every launcher behavior is unit-tested against FakeKubernetesApi and
|
|
4
4
|
* re-proven against a real cluster by kubernetes.integration.test.ts.
|
|
5
5
|
*/
|
|
6
|
-
import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1Secret, V1Service } from "@kubernetes/client-node";
|
|
6
|
+
import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1ResourceQuota, V1Secret, V1Service } from "@kubernetes/client-node";
|
|
7
7
|
import type { Readable, Writable } from "node:stream";
|
|
8
8
|
export declare class KubernetesNotFoundError extends Error {
|
|
9
9
|
constructor(message?: string);
|
|
@@ -46,6 +46,8 @@ export interface KubernetesApi {
|
|
|
46
46
|
* resource (migrating to it is a follow-up); Endpoints is read by name so RBAC stays one object wide.
|
|
47
47
|
*/
|
|
48
48
|
readEndpoints(namespace: string, name: string): Promise<V1Endpoints | undefined>;
|
|
49
|
+
/** Reads one ResourceQuota by name (its status carries hard and used), for the launcher's capacity check. */
|
|
50
|
+
readResourceQuota(namespace: string, name: string): Promise<V1ResourceQuota | undefined>;
|
|
49
51
|
readNamespace(name: string): Promise<boolean>;
|
|
50
52
|
/** Runs a command in a container; resolves with its exit code. Never uses a shell. */
|
|
51
53
|
exec(namespace: string, pod: string, container: string, command: string[], options: KubernetesExecOptions): Promise<number>;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* method is one client call plus HTTP-status translation into the seam's
|
|
4
4
|
* error classes. Proven against a real cluster by kubernetes.integration.test.ts.
|
|
5
5
|
*/
|
|
6
|
-
import { type V1ConfigMap, type V1Endpoints, type V1NetworkPolicy, type V1Pod, type V1Secret, type V1Service } from "@kubernetes/client-node";
|
|
6
|
+
import { type V1ConfigMap, type V1Endpoints, type V1ResourceQuota, type V1NetworkPolicy, type V1Pod, type V1Secret, type V1Service } from "@kubernetes/client-node";
|
|
7
7
|
import { type KubernetesApi, type KubernetesExecOptions } from "./kubernetes-api.js";
|
|
8
8
|
export interface ClientNodeKubernetesApiOptions {
|
|
9
9
|
/** kubeconfig context to use; defaults to the current context (or in-cluster credentials). */
|
|
@@ -29,6 +29,7 @@ export declare class ClientNodeKubernetesApi implements KubernetesApi {
|
|
|
29
29
|
deleteNetworkPolicy(namespace: string, name: string): Promise<void>;
|
|
30
30
|
readService(namespace: string, name: string): Promise<V1Service | undefined>;
|
|
31
31
|
readEndpoints(namespace: string, name: string): Promise<V1Endpoints | undefined>;
|
|
32
|
+
readResourceQuota(namespace: string, name: string): Promise<V1ResourceQuota | undefined>;
|
|
32
33
|
readApiServerVersion(): Promise<string>;
|
|
33
34
|
readNamespace(name: string): Promise<boolean>;
|
|
34
35
|
exec(namespace: string, pod: string, container: string, command: string[], options: KubernetesExecOptions): Promise<number>;
|
|
@@ -121,6 +121,9 @@ export class ClientNodeKubernetesApi {
|
|
|
121
121
|
readEndpoints(namespace, name) {
|
|
122
122
|
return readOrUndefined(() => this.core.readNamespacedEndpoints({ namespace, name }));
|
|
123
123
|
}
|
|
124
|
+
readResourceQuota(namespace, name) {
|
|
125
|
+
return readOrUndefined(() => this.core.readNamespacedResourceQuota({ namespace, name }));
|
|
126
|
+
}
|
|
124
127
|
async readApiServerVersion() {
|
|
125
128
|
return (await this.version.getCode()).gitVersion;
|
|
126
129
|
}
|
|
@@ -33,6 +33,13 @@ export declare const PROXY_POD_LABEL: {
|
|
|
33
33
|
export declare const RUN_COMPONENT_LABEL: {
|
|
34
34
|
readonly "wardby.io/component": "coding-run";
|
|
35
35
|
};
|
|
36
|
+
/** Kubernetes managed-by label on every run pod. */
|
|
37
|
+
export declare const RUN_MANAGED_BY_LABEL: {
|
|
38
|
+
readonly "app.kubernetes.io/managed-by": "wardby";
|
|
39
|
+
};
|
|
40
|
+
/** Label on every coding-run pod/Job: the first 40 hex chars of sha256(runId). */
|
|
41
|
+
export declare const RUN_SHA_LABEL = "wardby.io/run-sha256";
|
|
42
|
+
export declare const RUN_SHA_CHARS = 40;
|
|
36
43
|
/** The probe proved enforcement: the proxy port connected and the deny port was blocked. */
|
|
37
44
|
export declare const ENFORCEMENT_PROBE_PROVEN = 0;
|
|
38
45
|
/** The deny port was reachable: no policy is blocking it, or the policy is not port-scoped. */
|
|
@@ -42,6 +42,11 @@ export const PROXY_POD_LABEL = { "app.kubernetes.io/name": "wardby-coding-proxy"
|
|
|
42
42
|
* Single source: `runLabels` stamps it, `readProxyWitness` checks the proxy admits it.
|
|
43
43
|
*/
|
|
44
44
|
export const RUN_COMPONENT_LABEL = { "wardby.io/component": "coding-run" };
|
|
45
|
+
/** Kubernetes managed-by label on every run pod. */
|
|
46
|
+
export const RUN_MANAGED_BY_LABEL = { "app.kubernetes.io/managed-by": "wardby" };
|
|
47
|
+
/** Label on every coding-run pod/Job: the first 40 hex chars of sha256(runId). */
|
|
48
|
+
export const RUN_SHA_LABEL = "wardby.io/run-sha256";
|
|
49
|
+
export const RUN_SHA_CHARS = 40;
|
|
45
50
|
/** The probe proved enforcement: the proxy port connected and the deny port was blocked. */
|
|
46
51
|
export const ENFORCEMENT_PROBE_PROVEN = 0;
|
|
47
52
|
/** The deny port was reachable: no policy is blocking it, or the policy is not port-scoped. */
|
|
@@ -120,13 +125,13 @@ export function kubernetesRunNames(runId) {
|
|
|
120
125
|
if (!RUN_ID.test(runId))
|
|
121
126
|
throw isolationError();
|
|
122
127
|
const digest = sha256(runId);
|
|
123
|
-
return { ...kubernetesRunNamesForToken(digest.slice(0, 20)), runSha: digest.slice(0,
|
|
128
|
+
return { ...kubernetesRunNamesForToken(digest.slice(0, 20)), runSha: digest.slice(0, RUN_SHA_CHARS) };
|
|
124
129
|
}
|
|
125
130
|
export function runLabels(runId) {
|
|
126
131
|
return {
|
|
127
|
-
|
|
132
|
+
...RUN_MANAGED_BY_LABEL,
|
|
128
133
|
...RUN_COMPONENT_LABEL,
|
|
129
|
-
|
|
134
|
+
[RUN_SHA_LABEL]: kubernetesRunNames(runId).runSha,
|
|
130
135
|
};
|
|
131
136
|
}
|
|
132
137
|
/** Same grammar as Docker's repository digests; a cluster cannot pull a bare local image ID. */
|
|
@@ -11,6 +11,14 @@ export interface KubernetesPreflightOptions {
|
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
cleanupTimeoutMs?: number;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The image the preflight canary runs (it only needs node): the Codex worker when configured,
|
|
16
|
+
* else the Claude Code worker, so a Claude-only deployment is checked too.
|
|
17
|
+
*/
|
|
18
|
+
export declare function preflightCanaryImage(images: {
|
|
19
|
+
workerImage?: string;
|
|
20
|
+
claudeWorkerImage?: string;
|
|
21
|
+
}): string | undefined;
|
|
14
22
|
export interface CanaryResult {
|
|
15
23
|
dns: boolean;
|
|
16
24
|
/** The proxy's deny port: reachable means the run policy is not being enforced. */
|
|
@@ -12,6 +12,13 @@ import { CODING_PROXY_DENY_PORT, CODING_PROXY_PORT } from "./docker-isolation.js
|
|
|
12
12
|
import { KUBERNETES_ISOLATION_ERROR, WORKER_CONTAINER, buildRunNetworkPolicy, buildRunPod, isRegistryDigest, } from "./kubernetes-isolation.js";
|
|
13
13
|
import { KubernetesPlatformError, assertPlatformConfig, platformProfile } from "./kubernetes-platform.js";
|
|
14
14
|
import { readProxyWitness } from "./kubernetes-witness.js";
|
|
15
|
+
/**
|
|
16
|
+
* The image the preflight canary runs (it only needs node): the Codex worker when configured,
|
|
17
|
+
* else the Claude Code worker, so a Claude-only deployment is checked too.
|
|
18
|
+
*/
|
|
19
|
+
export function preflightCanaryImage(images) {
|
|
20
|
+
return images.workerImage || images.claudeWorkerImage || undefined;
|
|
21
|
+
}
|
|
15
22
|
const DEFAULT_TIMEOUT_MS = 90_000;
|
|
16
23
|
const DEFAULT_CLEANUP_TIMEOUT_MS = 15_000;
|
|
17
24
|
const POLL_INTERVAL_MS = 500;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether one more coding-run pod fits a namespace ResourceQuota (KUBERNETES_RESOURCE_QUOTA).
|
|
3
|
+
* The launcher asks before a run claims a slot, so a run that would be refused at pod create
|
|
4
|
+
* ("exceeded quota") waits in the coding queue instead of failing.
|
|
5
|
+
*/
|
|
6
|
+
import type { V1Pod, V1ResourceQuota } from "@kubernetes/client-node";
|
|
7
|
+
/** What a pod adds to a quota: millicores, bytes, and one pod. */
|
|
8
|
+
export interface PodQuotaUsage {
|
|
9
|
+
"requests.cpu": number;
|
|
10
|
+
"limits.cpu": number;
|
|
11
|
+
"requests.memory": number;
|
|
12
|
+
"limits.memory": number;
|
|
13
|
+
pods: number;
|
|
14
|
+
}
|
|
15
|
+
/** A Kubernetes CPU quantity ("250m", "1.5", "8") in millicores, or null when unparseable. */
|
|
16
|
+
export declare function cpuMillicores(quantity: string | number | undefined): number | null;
|
|
17
|
+
/** A Kubernetes memory quantity ("512Mi", "1G", "1048576") in bytes, or null when unparseable. */
|
|
18
|
+
export declare function memoryBytes(quantity: string | number | undefined): number | null;
|
|
19
|
+
/**
|
|
20
|
+
* The pod's effective requests and limits, as quota admission counts them: its containers plus
|
|
21
|
+
* native sidecars (init containers with restartPolicy Always), or its largest ordinary init
|
|
22
|
+
* container if that is bigger. Close enough for an admission pre-check; the API server stays the
|
|
23
|
+
* authority.
|
|
24
|
+
*/
|
|
25
|
+
export declare function podQuotaUsage(pod: V1Pod): PodQuotaUsage;
|
|
26
|
+
/**
|
|
27
|
+
* The first quota resource the pod would exceed (for example "requests.cpu"), or null when it
|
|
28
|
+
* fits. A quota with no status yet, or a value this can't parse, never blocks: the API server
|
|
29
|
+
* still has the final word at create.
|
|
30
|
+
*/
|
|
31
|
+
export declare function quotaShortfall(quota: V1ResourceQuota, usage: PodQuotaUsage): string | null;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const BINARY = { Ki: 2 ** 10, Mi: 2 ** 20, Gi: 2 ** 30, Ti: 2 ** 40, Pi: 2 ** 50 };
|
|
2
|
+
const DECIMAL = { k: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15 };
|
|
3
|
+
const QUANTITY = /^([0-9]+(?:\.[0-9]+)?)(m|Ki|Mi|Gi|Ti|Pi|k|M|G|T|P)?$/;
|
|
4
|
+
/** A Kubernetes CPU quantity ("250m", "1.5", "8") in millicores, or null when unparseable. */
|
|
5
|
+
export function cpuMillicores(quantity) {
|
|
6
|
+
if (quantity === undefined)
|
|
7
|
+
return 0;
|
|
8
|
+
const match = QUANTITY.exec(String(quantity).trim());
|
|
9
|
+
if (!match)
|
|
10
|
+
return null;
|
|
11
|
+
const value = Number(match[1]);
|
|
12
|
+
if (match[2] === "m")
|
|
13
|
+
return value;
|
|
14
|
+
if (match[2] !== undefined)
|
|
15
|
+
return null;
|
|
16
|
+
return value * 1000;
|
|
17
|
+
}
|
|
18
|
+
/** A Kubernetes memory quantity ("512Mi", "1G", "1048576") in bytes, or null when unparseable. */
|
|
19
|
+
export function memoryBytes(quantity) {
|
|
20
|
+
if (quantity === undefined)
|
|
21
|
+
return 0;
|
|
22
|
+
const match = QUANTITY.exec(String(quantity).trim());
|
|
23
|
+
if (!match)
|
|
24
|
+
return null;
|
|
25
|
+
const value = Number(match[1]);
|
|
26
|
+
const unit = match[2];
|
|
27
|
+
if (unit === undefined)
|
|
28
|
+
return value;
|
|
29
|
+
if (unit === "m")
|
|
30
|
+
return value / 1000;
|
|
31
|
+
return value * (BINARY[unit] ?? DECIMAL[unit] ?? NaN);
|
|
32
|
+
}
|
|
33
|
+
function sum(containers) {
|
|
34
|
+
const total = {
|
|
35
|
+
"requests.cpu": 0,
|
|
36
|
+
"limits.cpu": 0,
|
|
37
|
+
"requests.memory": 0,
|
|
38
|
+
"limits.memory": 0,
|
|
39
|
+
pods: 1,
|
|
40
|
+
};
|
|
41
|
+
for (const container of containers) {
|
|
42
|
+
const requests = container.resources?.requests ?? {};
|
|
43
|
+
const limits = container.resources?.limits ?? {};
|
|
44
|
+
total["requests.cpu"] += cpuMillicores(requests.cpu) ?? 0;
|
|
45
|
+
total["limits.cpu"] += cpuMillicores(limits.cpu) ?? 0;
|
|
46
|
+
total["requests.memory"] += memoryBytes(requests.memory) ?? 0;
|
|
47
|
+
total["limits.memory"] += memoryBytes(limits.memory) ?? 0;
|
|
48
|
+
}
|
|
49
|
+
return total;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The pod's effective requests and limits, as quota admission counts them: its containers plus
|
|
53
|
+
* native sidecars (init containers with restartPolicy Always), or its largest ordinary init
|
|
54
|
+
* container if that is bigger. Close enough for an admission pre-check; the API server stays the
|
|
55
|
+
* authority.
|
|
56
|
+
*/
|
|
57
|
+
export function podQuotaUsage(pod) {
|
|
58
|
+
const init = pod.spec?.initContainers ?? [];
|
|
59
|
+
const sidecars = init.filter((container) => container.restartPolicy === "Always");
|
|
60
|
+
const running = sum([...(pod.spec?.containers ?? []), ...sidecars]);
|
|
61
|
+
for (const container of init.filter((c) => c.restartPolicy !== "Always")) {
|
|
62
|
+
const one = sum([container]);
|
|
63
|
+
for (const key of ["requests.cpu", "limits.cpu", "requests.memory", "limits.memory"]) {
|
|
64
|
+
running[key] = Math.max(running[key], one[key]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return running;
|
|
68
|
+
}
|
|
69
|
+
/** Quota keys and the aliases Kubernetes accepts for them ("cpu" means "requests.cpu"). */
|
|
70
|
+
const KEYS = [
|
|
71
|
+
["requests.cpu", ["requests.cpu", "cpu"], "cpu"],
|
|
72
|
+
["limits.cpu", ["limits.cpu"], "cpu"],
|
|
73
|
+
["requests.memory", ["requests.memory", "memory"], "memory"],
|
|
74
|
+
["limits.memory", ["limits.memory"], "memory"],
|
|
75
|
+
["pods", ["pods", "count/pods"], "count"],
|
|
76
|
+
];
|
|
77
|
+
function parse(kind, value) {
|
|
78
|
+
if (kind === "cpu")
|
|
79
|
+
return cpuMillicores(value);
|
|
80
|
+
if (kind === "memory")
|
|
81
|
+
return memoryBytes(value);
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return 0;
|
|
84
|
+
const count = Number(value);
|
|
85
|
+
return Number.isFinite(count) ? count : null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The first quota resource the pod would exceed (for example "requests.cpu"), or null when it
|
|
89
|
+
* fits. A quota with no status yet, or a value this can't parse, never blocks: the API server
|
|
90
|
+
* still has the final word at create.
|
|
91
|
+
*/
|
|
92
|
+
export function quotaShortfall(quota, usage) {
|
|
93
|
+
const hard = quota.status?.hard;
|
|
94
|
+
if (!hard)
|
|
95
|
+
return null;
|
|
96
|
+
const used = quota.status?.used ?? {};
|
|
97
|
+
for (const [field, names, kind] of KEYS) {
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
if (hard[name] === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
const limit = parse(kind, hard[name]);
|
|
102
|
+
const current = parse(kind, used[name]);
|
|
103
|
+
if (limit === null || current === null)
|
|
104
|
+
continue;
|
|
105
|
+
if (current + usage[field] > limit)
|
|
106
|
+
return name;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
@@ -73,6 +73,15 @@ export declare class KubernetesJobLauncher implements WorkspaceJobLauncher {
|
|
|
73
73
|
* launcher itself has no `list` permission and so can never rediscover stray objects).
|
|
74
74
|
*/
|
|
75
75
|
plannedHandle(spec: JobSpec): JobHandle | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Whether this spec's pod fits the free room in the namespace's ResourceQuota
|
|
78
|
+
* (KubernetesJobConfig.resourceQuota), counted from the same pod `launch` would create. Any
|
|
79
|
+
* doubt (no quota configured, unreadable, unparseable, a spec the pod builder rejects) answers
|
|
80
|
+
* yes, so the launch and the API server stay the authority.
|
|
81
|
+
*/
|
|
82
|
+
hasCapacityFor(spec: JobSpec): Promise<boolean>;
|
|
83
|
+
private readonly warnedOnce;
|
|
84
|
+
private warnOnce;
|
|
76
85
|
launch(spec: JobSpec): Promise<JobHandle>;
|
|
77
86
|
status(handle: JobHandle): Promise<JobStatus>;
|
|
78
87
|
collect(handle: JobHandle): Promise<JobResult>;
|