@kody-ade/kody-engine 0.4.115 → 0.4.117
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/dist/bin/kody.js +278 -60
- package/package.json +1 -1
package/dist/bin/kody.js
CHANGED
|
@@ -880,7 +880,7 @@ var init_loadPriorArt = __esm({
|
|
|
880
880
|
// package.json
|
|
881
881
|
var package_default = {
|
|
882
882
|
name: "@kody-ade/kody-engine",
|
|
883
|
-
version: "0.4.
|
|
883
|
+
version: "0.4.117",
|
|
884
884
|
description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
|
|
885
885
|
license: "MIT",
|
|
886
886
|
type: "module",
|
|
@@ -4785,6 +4785,7 @@ import { createServer as createServer3 } from "http";
|
|
|
4785
4785
|
var FLY_API_BASE = "https://api.machines.dev/v1";
|
|
4786
4786
|
var POOL_METADATA_KEY = "kody_pool";
|
|
4787
4787
|
var POOL_METADATA_VALUE = "1";
|
|
4788
|
+
var POOL_REPO_METADATA_KEY = "kody_pool_repo";
|
|
4788
4789
|
var FlyClient = class {
|
|
4789
4790
|
constructor(opts) {
|
|
4790
4791
|
this.opts = opts;
|
|
@@ -4814,7 +4815,7 @@ var FlyClient = class {
|
|
|
4814
4815
|
if (!raw.trim()) return null;
|
|
4815
4816
|
return JSON.parse(raw);
|
|
4816
4817
|
}
|
|
4817
|
-
/** Create + start a pooled machine in serve mode
|
|
4818
|
+
/** Create + start a pooled machine in serve mode, tagged for `repoTag`. */
|
|
4818
4819
|
async createPooled(input) {
|
|
4819
4820
|
const body = {
|
|
4820
4821
|
region: input.region,
|
|
@@ -4824,7 +4825,10 @@ var FlyClient = class {
|
|
|
4824
4825
|
auto_destroy: true,
|
|
4825
4826
|
restart: { policy: "no" },
|
|
4826
4827
|
init: { entrypoint: ["/usr/local/bin/entrypoint-serve.sh"] },
|
|
4827
|
-
metadata: {
|
|
4828
|
+
metadata: {
|
|
4829
|
+
[POOL_METADATA_KEY]: POOL_METADATA_VALUE,
|
|
4830
|
+
[POOL_REPO_METADATA_KEY]: input.repoTag
|
|
4831
|
+
},
|
|
4828
4832
|
env: {
|
|
4829
4833
|
RUNNER_API_KEY: input.runnerApiKey,
|
|
4830
4834
|
KODY_LITELLM_URL: input.litellmUrl,
|
|
@@ -4839,11 +4843,14 @@ var FlyClient = class {
|
|
|
4839
4843
|
async get(id) {
|
|
4840
4844
|
return this.call(`/apps/${enc(this.opts.app)}/machines/${enc(id)}`, { allow404: true });
|
|
4841
4845
|
}
|
|
4842
|
-
/**
|
|
4843
|
-
|
|
4846
|
+
/**
|
|
4847
|
+
* List pooled machines for `repoTag` (kody_pool + matching repo tag),
|
|
4848
|
+
* excluding destroyed/destroying. Each repo's pool sees only its own.
|
|
4849
|
+
*/
|
|
4850
|
+
async listPooled(repoTag) {
|
|
4844
4851
|
const all = await this.call(`/apps/${enc(this.opts.app)}/machines`, { allow404: true }) ?? [];
|
|
4845
4852
|
return all.filter(
|
|
4846
|
-
(m) => m.config?.metadata?.[POOL_METADATA_KEY] === POOL_METADATA_VALUE && m.state !== "destroyed" && m.state !== "destroying"
|
|
4853
|
+
(m) => m.config?.metadata?.[POOL_METADATA_KEY] === POOL_METADATA_VALUE && m.config?.metadata?.[POOL_REPO_METADATA_KEY] === repoTag && m.state !== "destroyed" && m.state !== "destroying"
|
|
4847
4854
|
);
|
|
4848
4855
|
}
|
|
4849
4856
|
/** Suspend (freeze) a machine — wakes in ~1s from the snapshot. */
|
|
@@ -4884,6 +4891,7 @@ function sleep2(ms) {
|
|
|
4884
4891
|
}
|
|
4885
4892
|
|
|
4886
4893
|
// src/pool/manager.ts
|
|
4894
|
+
var MAX_CLAIM_ATTEMPTS = 3;
|
|
4887
4895
|
var PoolManager = class {
|
|
4888
4896
|
constructor(deps) {
|
|
4889
4897
|
this.deps = deps;
|
|
@@ -4912,7 +4920,7 @@ var PoolManager = class {
|
|
|
4912
4920
|
* free; anything else is left to finish/auto-destroy. Then refill to `min`.
|
|
4913
4921
|
*/
|
|
4914
4922
|
async reconcile() {
|
|
4915
|
-
const machines = await this.deps.fly.listPooled();
|
|
4923
|
+
const machines = await this.deps.fly.listPooled(this.deps.config.repoTag);
|
|
4916
4924
|
this.free = [];
|
|
4917
4925
|
for (const m of machines) {
|
|
4918
4926
|
if ((m.state === "suspended" || m.state === "suspending") && m.private_ip) {
|
|
@@ -4923,43 +4931,84 @@ var PoolManager = class {
|
|
|
4923
4931
|
await this.refill();
|
|
4924
4932
|
}
|
|
4925
4933
|
/**
|
|
4926
|
-
* Claim a warm machine for a job.
|
|
4927
|
-
*
|
|
4928
|
-
* the
|
|
4934
|
+
* Claim a warm machine for a job. Tries free machines in turn: if a woken
|
|
4935
|
+
* machine is stale/unhealthy/rejecting (e.g. it vanished out-of-band), it's
|
|
4936
|
+
* destroyed and the next free one is tried, up to MAX_CLAIM_ATTEMPTS. Only
|
|
4937
|
+
* when none work (or the pool is empty) does it return ok:false so the
|
|
4938
|
+
* caller falls back to create-fresh. The pick (shift) is synchronous — the
|
|
4939
|
+
* atomic step that prevents two concurrent claims grabbing the same machine.
|
|
4929
4940
|
*/
|
|
4930
4941
|
async claim(job) {
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
this.
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4942
|
+
let lastReason = "pool empty";
|
|
4943
|
+
for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
|
|
4944
|
+
const machine = this.free.shift();
|
|
4945
|
+
if (!machine) break;
|
|
4946
|
+
this.claimsInFlight++;
|
|
4947
|
+
try {
|
|
4948
|
+
await this.deps.fly.start(machine.id);
|
|
4949
|
+
const healthy = await this.deps.fly.waitHealthy(this.baseUrl(machine), {
|
|
4950
|
+
timeoutMs: this.deps.config.healthTimeoutMs
|
|
4951
|
+
});
|
|
4952
|
+
if (!healthy) {
|
|
4953
|
+
this.log(`claim: machine ${machine.id} unhealthy after wake \u2014 destroying, trying next`);
|
|
4954
|
+
await this.safeDestroy(machine.id);
|
|
4955
|
+
lastReason = "woken machine unhealthy";
|
|
4956
|
+
continue;
|
|
4957
|
+
}
|
|
4958
|
+
const accepted = await this.postRun(machine, job, this.deps.config);
|
|
4959
|
+
if (!accepted) {
|
|
4960
|
+
this.log(`claim: machine ${machine.id} rejected job \u2014 destroying, trying next`);
|
|
4961
|
+
await this.safeDestroy(machine.id);
|
|
4962
|
+
lastReason = "machine rejected job";
|
|
4963
|
+
continue;
|
|
4964
|
+
}
|
|
4965
|
+
this.log(`claim: machine ${machine.id} took job ${job.jobId}`);
|
|
4966
|
+
void this.refill();
|
|
4967
|
+
return { ok: true, machineId: machine.id };
|
|
4968
|
+
} catch (err) {
|
|
4969
|
+
this.log(`claim: error on ${machine.id}: ${errMsg2(err)} \u2014 destroying, trying next`);
|
|
4950
4970
|
await this.safeDestroy(machine.id);
|
|
4951
|
-
|
|
4971
|
+
lastReason = errMsg2(err);
|
|
4972
|
+
} finally {
|
|
4973
|
+
this.claimsInFlight--;
|
|
4952
4974
|
}
|
|
4953
|
-
|
|
4954
|
-
|
|
4975
|
+
}
|
|
4976
|
+
void this.refill();
|
|
4977
|
+
return { ok: false, reason: lastReason };
|
|
4978
|
+
}
|
|
4979
|
+
/**
|
|
4980
|
+
* Periodic self-heal: reconcile the in-memory free list against actual Fly
|
|
4981
|
+
* state. Prunes free entries whose machine vanished out-of-band (auto-destroy
|
|
4982
|
+
* after a job, manual ops) so a later claim never tries a dead machine, and
|
|
4983
|
+
* adopts any suspended machines we lost track of. Then tops up. Unlike
|
|
4984
|
+
* reconcile() this MERGES rather than rebuilds, so it won't drop a machine
|
|
4985
|
+
* that's momentarily not yet reflected as suspended by Fly's eventual
|
|
4986
|
+
* consistency.
|
|
4987
|
+
*/
|
|
4988
|
+
async resync() {
|
|
4989
|
+
let machines;
|
|
4990
|
+
try {
|
|
4991
|
+
machines = await this.deps.fly.listPooled(this.deps.config.repoTag);
|
|
4955
4992
|
} catch (err) {
|
|
4956
|
-
this.log(`
|
|
4957
|
-
|
|
4958
|
-
return { ok: false, reason: errMsg2(err) };
|
|
4959
|
-
} finally {
|
|
4960
|
-
this.claimsInFlight--;
|
|
4961
|
-
void this.refill();
|
|
4993
|
+
this.log(`resync: listPooled failed: ${errMsg2(err)}`);
|
|
4994
|
+
return;
|
|
4962
4995
|
}
|
|
4996
|
+
const liveIds = new Set(machines.map((m) => m.id));
|
|
4997
|
+
const before = this.free.length;
|
|
4998
|
+
this.free = this.free.filter((f) => liveIds.has(f.id));
|
|
4999
|
+
const pruned = before - this.free.length;
|
|
5000
|
+
const tracked = new Set(this.free.map((f) => f.id));
|
|
5001
|
+
let adopted = 0;
|
|
5002
|
+
for (const m of machines) {
|
|
5003
|
+
if ((m.state === "suspended" || m.state === "suspending") && m.private_ip && !tracked.has(m.id)) {
|
|
5004
|
+
this.free.push({ id: m.id, privateIp: m.private_ip });
|
|
5005
|
+
adopted++;
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
if (pruned > 0 || adopted > 0) {
|
|
5009
|
+
this.log(`resync: pruned ${pruned} stale, adopted ${adopted} (free=${this.free.length})`);
|
|
5010
|
+
}
|
|
5011
|
+
await this.refill();
|
|
4963
5012
|
}
|
|
4964
5013
|
/** Top up free machines to `min`. Serialized so it never overshoots. */
|
|
4965
5014
|
async refill() {
|
|
@@ -4989,6 +5038,7 @@ var PoolManager = class {
|
|
|
4989
5038
|
guest: cfg.guest,
|
|
4990
5039
|
runnerApiKey: cfg.runnerApiKey,
|
|
4991
5040
|
litellmUrl: cfg.litellmUrl,
|
|
5041
|
+
repoTag: cfg.repoTag,
|
|
4992
5042
|
port: cfg.port
|
|
4993
5043
|
});
|
|
4994
5044
|
if (!m.private_ip) {
|
|
@@ -5036,6 +5086,160 @@ function errMsg2(err) {
|
|
|
5036
5086
|
return err instanceof Error ? err.message : String(err);
|
|
5037
5087
|
}
|
|
5038
5088
|
|
|
5089
|
+
// src/pool/vault.ts
|
|
5090
|
+
import { createDecipheriv } from "crypto";
|
|
5091
|
+
var GITHUB_API = "https://api.github.com";
|
|
5092
|
+
var VAULT_PATH = ".kody/secrets.enc";
|
|
5093
|
+
var CACHE_TTL_MS = 6e4;
|
|
5094
|
+
var cache = /* @__PURE__ */ new Map();
|
|
5095
|
+
function decryptVault(payload, masterKey) {
|
|
5096
|
+
const parts = payload.split(":");
|
|
5097
|
+
if (parts.length !== 4 || parts[0] !== "v1") {
|
|
5098
|
+
throw new Error("invalid vault payload format");
|
|
5099
|
+
}
|
|
5100
|
+
const [, ivB64, ctB64, tagB64] = parts;
|
|
5101
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
5102
|
+
const ct = Buffer.from(ctB64, "base64");
|
|
5103
|
+
const tag = Buffer.from(tagB64, "base64");
|
|
5104
|
+
const decipher = createDecipheriv("aes-256-gcm", masterKey, iv);
|
|
5105
|
+
decipher.setAuthTag(tag);
|
|
5106
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
|
|
5107
|
+
}
|
|
5108
|
+
async function readVaultSecrets(opts) {
|
|
5109
|
+
const key = `${opts.owner}/${opts.repo}`.toLowerCase();
|
|
5110
|
+
const hit = cache.get(key);
|
|
5111
|
+
if (hit && hit.expiresAt > Date.now()) return hit.secrets;
|
|
5112
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
5113
|
+
const res = await doFetch(
|
|
5114
|
+
`${GITHUB_API}/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${VAULT_PATH}`,
|
|
5115
|
+
{
|
|
5116
|
+
headers: {
|
|
5117
|
+
Authorization: `Bearer ${opts.githubToken}`,
|
|
5118
|
+
Accept: "application/vnd.github+json",
|
|
5119
|
+
"User-Agent": "kody-pool-serve"
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
);
|
|
5123
|
+
if (res.status === 404) {
|
|
5124
|
+
cache.set(key, { secrets: {}, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
5125
|
+
return {};
|
|
5126
|
+
}
|
|
5127
|
+
if (!res.ok) {
|
|
5128
|
+
throw new Error(`vault read ${res.status} for ${key}: ${(await res.text().catch(() => "")).slice(0, 160)}`);
|
|
5129
|
+
}
|
|
5130
|
+
const body = await res.json();
|
|
5131
|
+
if (!body.content) {
|
|
5132
|
+
cache.set(key, { secrets: {}, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
5133
|
+
return {};
|
|
5134
|
+
}
|
|
5135
|
+
const ciphertext = Buffer.from(body.content, body.encoding ?? "base64").toString("utf8").trim();
|
|
5136
|
+
const doc = JSON.parse(decryptVault(ciphertext, opts.masterKey));
|
|
5137
|
+
const flat = {};
|
|
5138
|
+
for (const [name, entry] of Object.entries(doc.secrets ?? {})) {
|
|
5139
|
+
if (entry && typeof entry.value === "string") flat[name] = entry.value;
|
|
5140
|
+
}
|
|
5141
|
+
cache.set(key, { secrets: flat, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
5142
|
+
return flat;
|
|
5143
|
+
}
|
|
5144
|
+
async function readRepoSecret(opts) {
|
|
5145
|
+
const secrets = await readVaultSecrets(opts);
|
|
5146
|
+
const v = secrets[opts.name];
|
|
5147
|
+
return v && v.trim() ? v : null;
|
|
5148
|
+
}
|
|
5149
|
+
async function readRepoSecrets(opts) {
|
|
5150
|
+
return readVaultSecrets(opts);
|
|
5151
|
+
}
|
|
5152
|
+
|
|
5153
|
+
// src/pool/registry.ts
|
|
5154
|
+
var PoolRegistry = class {
|
|
5155
|
+
constructor(cfg) {
|
|
5156
|
+
this.cfg = cfg;
|
|
5157
|
+
this.log = cfg.log ?? (() => {
|
|
5158
|
+
});
|
|
5159
|
+
this.resolveFlyToken = cfg.resolveFlyToken ?? ((owner, repo) => readRepoSecret({
|
|
5160
|
+
githubToken: cfg.githubToken,
|
|
5161
|
+
masterKey: cfg.masterKey,
|
|
5162
|
+
owner,
|
|
5163
|
+
repo,
|
|
5164
|
+
name: "FLY_API_TOKEN"
|
|
5165
|
+
}));
|
|
5166
|
+
}
|
|
5167
|
+
cfg;
|
|
5168
|
+
pools = /* @__PURE__ */ new Map();
|
|
5169
|
+
resolveFlyToken;
|
|
5170
|
+
log;
|
|
5171
|
+
key(owner, repo) {
|
|
5172
|
+
return `${owner}/${repo}`.toLowerCase();
|
|
5173
|
+
}
|
|
5174
|
+
/** Get-or-create the pool for a repo, or null if the repo has no Fly token. */
|
|
5175
|
+
async getPool(owner, repo) {
|
|
5176
|
+
const repoTag = this.key(owner, repo);
|
|
5177
|
+
const existing = this.pools.get(repoTag);
|
|
5178
|
+
if (existing) return existing;
|
|
5179
|
+
let flyToken;
|
|
5180
|
+
try {
|
|
5181
|
+
flyToken = await this.resolveFlyToken(owner, repo);
|
|
5182
|
+
} catch (err) {
|
|
5183
|
+
this.log(`registry: vault read failed for ${repoTag}: ${err instanceof Error ? err.message : String(err)}`);
|
|
5184
|
+
return null;
|
|
5185
|
+
}
|
|
5186
|
+
if (!flyToken) {
|
|
5187
|
+
this.log(`registry: ${repoTag} has no FLY_API_TOKEN \u2014 no pool`);
|
|
5188
|
+
return null;
|
|
5189
|
+
}
|
|
5190
|
+
const fly = new FlyClient({ token: flyToken, app: this.cfg.base.app });
|
|
5191
|
+
const pm = new PoolManager({
|
|
5192
|
+
fly,
|
|
5193
|
+
config: { ...this.cfg.base, repoTag },
|
|
5194
|
+
log: (m) => this.log(`[${repoTag}] ${m}`)
|
|
5195
|
+
});
|
|
5196
|
+
this.pools.set(repoTag, pm);
|
|
5197
|
+
void pm.reconcile().catch((err) => this.log(`[${repoTag}] reconcile: ${err instanceof Error ? err.message : String(err)}`));
|
|
5198
|
+
return pm;
|
|
5199
|
+
}
|
|
5200
|
+
async claim(owner, repo, req) {
|
|
5201
|
+
const pm = await this.getPool(owner, repo);
|
|
5202
|
+
if (!pm) return { ok: false, reason: "repo has no FLY_API_TOKEN (no pool)" };
|
|
5203
|
+
let allSecrets = {};
|
|
5204
|
+
try {
|
|
5205
|
+
const vault = await readRepoSecrets({
|
|
5206
|
+
githubToken: this.cfg.githubToken,
|
|
5207
|
+
masterKey: this.cfg.masterKey,
|
|
5208
|
+
owner,
|
|
5209
|
+
repo
|
|
5210
|
+
});
|
|
5211
|
+
allSecrets = Object.fromEntries(Object.entries(vault).filter(([k]) => k !== "FLY_API_TOKEN"));
|
|
5212
|
+
} catch (err) {
|
|
5213
|
+
this.log(`[${this.key(owner, repo)}] vault secrets read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5214
|
+
}
|
|
5215
|
+
const job = {
|
|
5216
|
+
jobId: req.jobId,
|
|
5217
|
+
repo: `${owner}/${repo}`,
|
|
5218
|
+
issueNumber: req.issueNumber,
|
|
5219
|
+
githubToken: this.cfg.githubToken,
|
|
5220
|
+
ref: req.ref,
|
|
5221
|
+
allSecrets,
|
|
5222
|
+
model: req.model,
|
|
5223
|
+
sessionId: req.sessionId,
|
|
5224
|
+
dashboardUrl: req.dashboardUrl
|
|
5225
|
+
};
|
|
5226
|
+
return pm.claim(job);
|
|
5227
|
+
}
|
|
5228
|
+
/** Status for a single repo's pool, or null if none exists yet. */
|
|
5229
|
+
status(owner, repo) {
|
|
5230
|
+
return this.pools.get(this.key(owner, repo))?.status() ?? null;
|
|
5231
|
+
}
|
|
5232
|
+
/** Resync every active repo pool (periodic self-heal). */
|
|
5233
|
+
async resyncAll() {
|
|
5234
|
+
for (const [repoTag, pm] of this.pools) {
|
|
5235
|
+
await pm.resync().catch((err) => this.log(`[${repoTag}] resync: ${err instanceof Error ? err.message : String(err)}`));
|
|
5236
|
+
}
|
|
5237
|
+
}
|
|
5238
|
+
activeRepos() {
|
|
5239
|
+
return [...this.pools.keys()];
|
|
5240
|
+
}
|
|
5241
|
+
};
|
|
5242
|
+
|
|
5039
5243
|
// src/pool/keys.ts
|
|
5040
5244
|
import { hkdfSync } from "crypto";
|
|
5041
5245
|
var POOL_API_KEY_INFO = "kody-pool-api:v1";
|
|
@@ -5107,7 +5311,7 @@ function readJsonBody3(req) {
|
|
|
5107
5311
|
req.on("error", reject);
|
|
5108
5312
|
});
|
|
5109
5313
|
}
|
|
5110
|
-
function
|
|
5314
|
+
function parseClaimRequest(body) {
|
|
5111
5315
|
if (typeof body !== "object" || body === null) return { error: "body must be an object" };
|
|
5112
5316
|
const b = body;
|
|
5113
5317
|
const jobId = typeof b.jobId === "string" ? b.jobId.trim() : "";
|
|
@@ -5116,15 +5320,12 @@ function parsePoolJob(body) {
|
|
|
5116
5320
|
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return { error: "repo must be 'owner/name'" };
|
|
5117
5321
|
const issueNumber = Number(b.issueNumber);
|
|
5118
5322
|
if (!Number.isInteger(issueNumber) || issueNumber <= 0) return { error: "issueNumber required" };
|
|
5119
|
-
const
|
|
5120
|
-
if (
|
|
5121
|
-
|
|
5122
|
-
if (typeof b.
|
|
5123
|
-
if (typeof b.
|
|
5124
|
-
|
|
5125
|
-
if (typeof b.dashboardUrl === "string" && b.dashboardUrl.trim()) job.dashboardUrl = b.dashboardUrl.trim();
|
|
5126
|
-
if (b.allSecrets && typeof b.allSecrets === "object") job.allSecrets = b.allSecrets;
|
|
5127
|
-
return { job };
|
|
5323
|
+
const req = { jobId, repo, issueNumber };
|
|
5324
|
+
if (typeof b.ref === "string" && b.ref.trim()) req.ref = b.ref.trim();
|
|
5325
|
+
if (typeof b.model === "string" && b.model.trim()) req.model = b.model.trim();
|
|
5326
|
+
if (typeof b.sessionId === "string" && b.sessionId.trim()) req.sessionId = b.sessionId.trim();
|
|
5327
|
+
if (typeof b.dashboardUrl === "string" && b.dashboardUrl.trim()) req.dashboardUrl = b.dashboardUrl.trim();
|
|
5328
|
+
return { req };
|
|
5128
5329
|
}
|
|
5129
5330
|
function superviseLitellm() {
|
|
5130
5331
|
if (process.env.POOL_DISABLE_LITELLM === "1") return null;
|
|
@@ -5156,8 +5357,8 @@ var poolServe = async (ctx) => {
|
|
|
5156
5357
|
ctx.skipAgent = true;
|
|
5157
5358
|
const masterRaw = process.env.KODY_MASTER_KEY?.trim();
|
|
5158
5359
|
if (!masterRaw) throw new Error("KODY_MASTER_KEY required for pool-serve");
|
|
5159
|
-
const
|
|
5160
|
-
if (!
|
|
5360
|
+
const githubToken = process.env.GITHUB_TOKEN?.trim();
|
|
5361
|
+
if (!githubToken) throw new Error("GITHUB_TOKEN required for pool-serve (reads per-repo vaults)");
|
|
5161
5362
|
const master = masterKeyBytes(masterRaw);
|
|
5162
5363
|
const poolApiKey = derivePoolApiKey(master);
|
|
5163
5364
|
const runnerApiKey = deriveRunnerApiKey(master);
|
|
@@ -5171,23 +5372,36 @@ var poolServe = async (ctx) => {
|
|
|
5171
5372
|
const apiPort = envInt("POOL_API_PORT", 4100);
|
|
5172
5373
|
const healthTimeoutMs = envInt("POOL_HEALTH_TIMEOUT_MS", 12e4);
|
|
5173
5374
|
const litellm = superviseLitellm();
|
|
5174
|
-
const
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5375
|
+
const registry = new PoolRegistry({
|
|
5376
|
+
githubToken,
|
|
5377
|
+
masterKey: master,
|
|
5378
|
+
base: {
|
|
5379
|
+
min,
|
|
5380
|
+
image: process.env.FLY_RUNNER_IMAGE ?? "registry.fly.io/kody-runner:latest",
|
|
5381
|
+
region,
|
|
5382
|
+
guest,
|
|
5383
|
+
runnerApiKey,
|
|
5384
|
+
litellmUrl,
|
|
5385
|
+
port: runnerPort,
|
|
5386
|
+
healthTimeoutMs,
|
|
5387
|
+
app
|
|
5388
|
+
},
|
|
5178
5389
|
log
|
|
5179
5390
|
});
|
|
5180
|
-
manager.reconcile().catch((err) => log(`reconcile failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
5181
5391
|
const refillMs = envInt("POOL_REFILL_INTERVAL_MS", 6e4);
|
|
5182
5392
|
const tick = setInterval(() => {
|
|
5183
|
-
|
|
5393
|
+
registry.resyncAll().catch((err) => log(`resync tick failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
5184
5394
|
}, refillMs);
|
|
5185
5395
|
const server = createServer3(async (req, res) => {
|
|
5186
5396
|
try {
|
|
5187
5397
|
if (!req.method || !req.url) return sendJson3(res, 400, { error: "bad request" });
|
|
5188
5398
|
const url = new URL(req.url, "http://localhost");
|
|
5189
5399
|
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
5190
|
-
return sendJson3(res, 200, {
|
|
5400
|
+
return sendJson3(res, 200, {
|
|
5401
|
+
ok: true,
|
|
5402
|
+
litellm: litellm ? "supervised" : "off",
|
|
5403
|
+
repos: registry.activeRepos()
|
|
5404
|
+
});
|
|
5191
5405
|
}
|
|
5192
5406
|
const authed = bearerOk(
|
|
5193
5407
|
req.headers["authorization"],
|
|
@@ -5196,7 +5410,10 @@ var poolServe = async (ctx) => {
|
|
|
5196
5410
|
);
|
|
5197
5411
|
if (!authed) return sendJson3(res, 401, { error: "unauthorized" });
|
|
5198
5412
|
if (req.method === "GET" && url.pathname === "/pool/status") {
|
|
5199
|
-
|
|
5413
|
+
const repoParam = (url.searchParams.get("repo") ?? "").trim();
|
|
5414
|
+
const [owner, repo] = repoParam.split("/");
|
|
5415
|
+
if (!owner || !repo) return sendJson3(res, 400, { error: "repo query (owner/name) required" });
|
|
5416
|
+
return sendJson3(res, 200, { status: registry.status(owner, repo) });
|
|
5200
5417
|
}
|
|
5201
5418
|
if (req.method === "POST" && url.pathname === "/pool/claim") {
|
|
5202
5419
|
let body;
|
|
@@ -5205,9 +5422,10 @@ var poolServe = async (ctx) => {
|
|
|
5205
5422
|
} catch {
|
|
5206
5423
|
return sendJson3(res, 400, { error: "invalid JSON body" });
|
|
5207
5424
|
}
|
|
5208
|
-
const parsed =
|
|
5425
|
+
const parsed = parseClaimRequest(body);
|
|
5209
5426
|
if ("error" in parsed) return sendJson3(res, 400, { error: parsed.error });
|
|
5210
|
-
const
|
|
5427
|
+
const [owner, repo] = parsed.req.repo.split("/");
|
|
5428
|
+
const result = await registry.claim(owner, repo, parsed.req);
|
|
5211
5429
|
if (result.ok) return sendJson3(res, 200, { ok: true, machineId: result.machineId });
|
|
5212
5430
|
return sendJson3(res, 503, { ok: false, reason: result.reason ?? "pool unavailable" });
|
|
5213
5431
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kody-ade/kody-engine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.117",
|
|
4
4
|
"description": "kody — autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|